duel 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/AGENT_PROMPT.md +20 -16
- data/CHANGELOG.md +82 -1
- data/README.md +39 -24
- data/docs/ci.md +159 -35
- data/docs/configuration.md +66 -4
- data/docs/how-it-works.md +4 -2
- data/docs/json.md +5 -2
- data/docs/troubleshooting.md +6 -5
- data/lib/duel/baseline.rb +31 -3
- data/lib/duel/cli.rb +109 -40
- data/lib/duel/config.rb +37 -3
- data/lib/duel/init.rb +10 -2
- data/lib/duel/json_report.rb +11 -9
- data/lib/duel/runner.rb +1 -1
- data/lib/duel/summary.rb +145 -73
- data/lib/duel/templates/duel.rb.erb +7 -2
- data/lib/duel/templates/perf.yml.erb +89 -22
- data/lib/duel/version.rb +1 -1
- data/sig/duel/baseline.rbs +16 -1
- data/sig/duel/cli.rbs +26 -5
- data/sig/duel/config.rbs +16 -1
- data/sig/duel/init.rbs +6 -0
- data/sig/duel/json_report.rbs +7 -5
- data/sig/duel/runner.rbs +6 -3
- data/sig/duel/summary.rbs +47 -17
- metadata +1 -1
data/lib/duel/summary.rb
CHANGED
|
@@ -4,11 +4,11 @@ require_relative "runner"
|
|
|
4
4
|
|
|
5
5
|
module Duel
|
|
6
6
|
# A run's report in Markdown, for a pull request: GitHub Actions' job
|
|
7
|
-
# summary, or a comment (see CLI).
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
7
|
+
# summary, or a comment (see CLI). In view, only what the author needs:
|
|
8
|
+
# the verdict, the two commits, and what failed, in a table. The rest is
|
|
9
|
+
# collapsed: what made each regression, every benchmark's timings, the
|
|
10
|
+
# verdicts across the board, each version's Ruby where they differ, and
|
|
11
|
+
# how Duel decides. So it stays short with hundreds of benchmarks.
|
|
12
12
|
# @api private
|
|
13
13
|
class Summary
|
|
14
14
|
# The most characters it has (a GitHub comment has at most 65,536):
|
|
@@ -26,6 +26,19 @@ module Duel
|
|
|
26
26
|
# The verdicts that fail a run, which stand out.
|
|
27
27
|
FAILED = %w[regressed error].freeze
|
|
28
28
|
|
|
29
|
+
# What the heading starts with: a run that passed, one that failed (or
|
|
30
|
+
# couldn't compare), and one with nothing compared.
|
|
31
|
+
PASSED_MARK = "🟢"
|
|
32
|
+
FAILED_MARK = "🔻"
|
|
33
|
+
NEUTRAL_MARK = "⬜"
|
|
34
|
+
|
|
35
|
+
# What an unsettled timing means.
|
|
36
|
+
UNSETTLED = "An unsettled timing was too close to the limit to settle. It doesn't fail the run, but one that's unsettled " \
|
|
37
|
+
"in run after run is noisy."
|
|
38
|
+
|
|
39
|
+
# How Duel decides that a timing is slower, in its docs.
|
|
40
|
+
DECIDES = "https://github.com/sferik/duel-ruby/blob/main/docs/how-it-works.md#how-duel-tells-a-regression-from-noise"
|
|
41
|
+
|
|
29
42
|
# +text+ as code, in Markdown: in a span of more backticks than any it
|
|
30
43
|
# has (and spaced from one at either end).
|
|
31
44
|
def self.code(text)
|
|
@@ -34,8 +47,17 @@ module Duel
|
|
|
34
47
|
"#{ticks}#{padded}#{ticks}"
|
|
35
48
|
end
|
|
36
49
|
|
|
37
|
-
#
|
|
38
|
-
|
|
50
|
+
# +text+ as it is, in Markdown: what Markdown, or the HTML in it, would
|
|
51
|
+
# take for something else, escaped.
|
|
52
|
+
def self.escape(text) = text.gsub(/[\\`*_\[\]<>|~&#]/) { |char| "\\#{char}" }
|
|
53
|
+
|
|
54
|
+
# A run that didn't compare: a heading of +mark+ and +title+, and the
|
|
55
|
+
# first line of +reason+, with all of it collapsed if it has more.
|
|
56
|
+
def self.note(mark, title, reason)
|
|
57
|
+
first, _, rest = reason.partition("\n")
|
|
58
|
+
more = "\n\n<details>\n<summary>The whole message</summary>\n\n#{block(reason)}\n\n</details>" unless rest.empty?
|
|
59
|
+
"### #{mark} Duel: #{title}\n\n#{escape(first)}#{more}\n"
|
|
60
|
+
end
|
|
39
61
|
|
|
40
62
|
# +text+ in a fenced block of its own: fenced with more backticks than
|
|
41
63
|
# any run of them it has, three at least.
|
|
@@ -67,13 +89,16 @@ module Duel
|
|
|
67
89
|
end
|
|
68
90
|
|
|
69
91
|
# +runner+ ran, and made +results+, comparing +baseline+ with
|
|
70
|
-
# +candidate+ (each a description, in Markdown
|
|
92
|
+
# +candidate+ (each a description, in Markdown, whose commit stands
|
|
93
|
+
# bare, so GitHub links it) as +settings+ say.
|
|
71
94
|
def initialize(runner, results, settings:, baseline:, candidate:)
|
|
72
95
|
@runner = runner
|
|
73
96
|
@results = results
|
|
74
97
|
@settings = settings
|
|
75
|
-
@
|
|
98
|
+
@baseline = baseline
|
|
99
|
+
@candidate = candidate
|
|
76
100
|
@regressed = results.select { |r| Summary.verdict(r) == "regressed" }
|
|
101
|
+
@failed = @regressed + results.select(&:error)
|
|
77
102
|
end
|
|
78
103
|
|
|
79
104
|
# The Markdown: every regression explained, or as many as LIMIT has
|
|
@@ -89,38 +114,89 @@ module Duel
|
|
|
89
114
|
|
|
90
115
|
# The Markdown, with the first +explained+ regressions explained.
|
|
91
116
|
def render(explained)
|
|
92
|
-
|
|
93
|
-
[heading, versions, overview, table(changed), regressions(explained), across, unsettled, errors, unchanged(same)]
|
|
94
|
-
.compact.join("\n\n") + "\n"
|
|
117
|
+
[heading, compared, failures, across(confirmed: true), details(explained)].compact.join("\n\n") + "\n"
|
|
95
118
|
end
|
|
96
119
|
|
|
120
|
+
# The verdict: what failed (benchmarks, or a metric across the board),
|
|
121
|
+
# or that every benchmark compared passed, and those skipped.
|
|
97
122
|
def heading
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
123
|
+
skipped = @results.count(&:skipped)
|
|
124
|
+
compared = @results.size > skipped
|
|
125
|
+
slower = @runner.regressed_across
|
|
126
|
+
said = [("#{Runner.count(@failed.size, "benchmark")} failed" unless @failed.empty?),
|
|
127
|
+
("slower across the board in #{slower.join(", ")}" unless slower.empty?)].compact
|
|
128
|
+
mark = if said.any? then FAILED_MARK
|
|
129
|
+
elsif compared then PASSED_MARK
|
|
130
|
+
else NEUTRAL_MARK
|
|
131
|
+
end
|
|
132
|
+
said << (compared ? "100% of benchmarks passed" : "no benchmarks compared") if said.empty?
|
|
133
|
+
said << "#{skipped} skipped" if skipped.positive?
|
|
134
|
+
"### #{mark} Duel: #{said.join(", ")}"
|
|
135
|
+
end
|
|
103
136
|
|
|
104
|
-
|
|
137
|
+
# The two versions, and why any benchmark was skipped.
|
|
138
|
+
def compared
|
|
139
|
+
skipped = @results.count(&:skipped)
|
|
140
|
+
why = " The baseline can't run the skipped #{(skipped == 1) ? "benchmark" : "benchmarks"}." if skipped.positive?
|
|
141
|
+
"Compared #{@candidate} with #{@baseline}.#{why}"
|
|
105
142
|
end
|
|
106
143
|
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
def
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
144
|
+
# The benchmarks that failed: what got worse and by how much, or the
|
|
145
|
+
# error, and where the most extra CPU went, if a profile says.
|
|
146
|
+
def failures
|
|
147
|
+
return if @failed.empty?
|
|
148
|
+
|
|
149
|
+
header = ["Benchmark", "Failure"]
|
|
150
|
+
header << "Most extra CPU in" unless @failed.all? { |r| top(r).empty? }
|
|
151
|
+
rows = @failed.map { |r| row([Summary.code(cell(r.name)), failure(r), top(r)].first(header.size)) }
|
|
152
|
+
([row(header), "|#{"---|" * header.size}"] + rows).join("\n")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# What got worse, or the error's first line.
|
|
156
|
+
def failure(result)
|
|
157
|
+
error = result.error
|
|
158
|
+
return "error: #{Summary.code(cell(error.partition("\n").first))}" if error
|
|
159
|
+
|
|
160
|
+
worse = result.regressed.map do |metric|
|
|
161
|
+
timed = result.timed[metric]
|
|
162
|
+
counted = result.counted[metric]
|
|
163
|
+
if timed then format("%s %+.1f%%", metric, (timed.fetch("median") - 1) * 100)
|
|
164
|
+
elsif counted
|
|
165
|
+
n = counted.fetch("n")
|
|
166
|
+
format("%s %.4g → %.4g", metric, counted.fetch("baseline").fdiv(n), counted.fetch("candidate").fdiv(n))
|
|
167
|
+
else
|
|
168
|
+
# (Peak memory, which grew too much.)
|
|
169
|
+
memory = result.memory #: Hash[String, Integer]
|
|
170
|
+
"#{metric} #{mb(memory.fetch("baseline"))} → #{mb(memory.fetch("candidate"))}"
|
|
171
|
+
end
|
|
115
172
|
end
|
|
116
|
-
|
|
117
|
-
|
|
173
|
+
worse.join(result.mixed? ? " or " : ", ")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# The frame whose own share of the CPU grew the most, and by how much,
|
|
177
|
+
# if there's a profile with one (or "").
|
|
178
|
+
def top(result)
|
|
179
|
+
profile = result.profile
|
|
180
|
+
return "" unless profile && !profile.key?("unavailable")
|
|
181
|
+
|
|
182
|
+
# (A Profile's: Steep doesn't narrow on key?.)
|
|
183
|
+
grew = profile.fetch("grew") #: Array[Profile::growth]
|
|
184
|
+
most = grew.first
|
|
185
|
+
return "" unless most
|
|
186
|
+
|
|
187
|
+
frame, own, = most
|
|
188
|
+
format("%s %+.1f%%", Summary.code(cell(frame)), own * 100)
|
|
118
189
|
end
|
|
119
190
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
191
|
+
# The rest, collapsed: each regression explained, the errors, every
|
|
192
|
+
# benchmark, the verdicts across the board, the Rubies, and how Duel
|
|
193
|
+
# decides.
|
|
194
|
+
def details(explained)
|
|
195
|
+
unsettled = @results.count { |r| Summary.verdict(r) == "unsettled" }
|
|
196
|
+
title = format("Details: %s in %.0fs%s", Runner.count(@results.size, "benchmark"), @runner.seconds,
|
|
197
|
+
(", #{unsettled} unsettled" if unsettled.positive?))
|
|
198
|
+
body = [*regressions(explained), *errors, table, (UNSETTLED if unsettled.positive?), across(confirmed: false), rubies, decides]
|
|
199
|
+
"<details>\n<summary>#{title}</summary>\n\n#{body.compact.join("\n\n")}\n\n</details>"
|
|
124
200
|
end
|
|
125
201
|
|
|
126
202
|
# The results, in the order of their VERDICTS, and the run's order in
|
|
@@ -130,36 +206,30 @@ module Duel
|
|
|
130
206
|
VERDICTS.flat_map { |shown| grouped.fetch(shown, []) }
|
|
131
207
|
end
|
|
132
208
|
|
|
133
|
-
|
|
134
|
-
|
|
209
|
+
# Every benchmark, with its timings, and its counts where any changed.
|
|
210
|
+
def table
|
|
211
|
+
return if @results.empty?
|
|
135
212
|
|
|
136
|
-
rows =
|
|
213
|
+
rows = sorted.map do |r|
|
|
137
214
|
shown = Summary.verdict(r)
|
|
138
|
-
|
|
215
|
+
[Summary.code(cell(r.name)), FAILED.include?(shown) ? "**#{shown}**" : shown, timings(r), counts(r)]
|
|
139
216
|
end
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
# Those that didn't change, collapsed.
|
|
144
|
-
def unchanged(same)
|
|
145
|
-
return if same.empty?
|
|
146
|
-
|
|
147
|
-
rows = same.map { |r| row(Summary.code(name(r)), timings(r)) }
|
|
148
|
-
"<details>\n<summary>#{Runner.count(same.size, "benchmark")} unchanged</summary>\n\n" \
|
|
149
|
-
"#{[row("Benchmark", "Timings (ratio [interval])"), "|---|---|", *rows].join("\n")}\n\n</details>"
|
|
217
|
+
header = ["Benchmark", "Verdict", "Timings (ratio [interval])", "Counts an iteration"]
|
|
218
|
+
columns = (rows.any? { |cells| !cells.last.empty? }) ? 4 : 3
|
|
219
|
+
([row(header.first(columns)), "|#{"---|" * columns}"] + rows.map { |cells| row(cells.first(columns)) }).join("\n")
|
|
150
220
|
end
|
|
151
221
|
|
|
152
222
|
# A table's row of +cells+.
|
|
153
|
-
def row(
|
|
223
|
+
def row(cells) = "| #{cells.join(" | ")} |"
|
|
154
224
|
|
|
155
|
-
#
|
|
156
|
-
def
|
|
225
|
+
# +text+ for a table's cell, on one line.
|
|
226
|
+
def cell(text) = text.tr("\n", " ").gsub("|", "\\|")
|
|
157
227
|
|
|
158
228
|
# Each timed measure's ratio and its interval, with its verdict where
|
|
159
229
|
# it isn't the same (or why it was skipped).
|
|
160
230
|
def timings(result)
|
|
161
231
|
skipped = result.skipped
|
|
162
|
-
return skipped
|
|
232
|
+
return cell(skipped) if skipped
|
|
163
233
|
|
|
164
234
|
result.timed.map do |metric, t|
|
|
165
235
|
low, high = t.fetch("interval")
|
|
@@ -186,8 +256,6 @@ module Duel
|
|
|
186
256
|
# The first +explained+ regressions, explained as the terminal report
|
|
187
257
|
# explains them (and the rest named).
|
|
188
258
|
def regressions(explained)
|
|
189
|
-
return if @regressed.empty?
|
|
190
|
-
|
|
191
259
|
shown = @regressed.first(explained).map do |r|
|
|
192
260
|
what = r.mixed? ? "slower in every measurement, by one of #{r.regressed.join(", ")}" : "worse #{r.regressed.join(", ")} in every measurement"
|
|
193
261
|
lines = @runner.explained(r)
|
|
@@ -196,42 +264,46 @@ module Duel
|
|
|
196
264
|
end
|
|
197
265
|
rest = @regressed.size - shown.size
|
|
198
266
|
shown << "#{Runner.count(rest, shown.empty? ? "regression" : "more regression")} explained in the job's log." if rest.positive?
|
|
199
|
-
|
|
267
|
+
shown
|
|
200
268
|
end
|
|
201
269
|
|
|
202
270
|
# The verdicts across the board, of the metrics with enough
|
|
203
|
-
# benchmarks to tell
|
|
204
|
-
|
|
271
|
+
# benchmarks to tell: those slower in both measurements, which fail
|
|
272
|
+
# the run (+confirmed+), or the rest.
|
|
273
|
+
def across(confirmed:)
|
|
205
274
|
lines = @runner.across.filter_map do |metric, v|
|
|
206
275
|
low, high = v.fetch("interval")
|
|
207
|
-
next unless low
|
|
276
|
+
next unless low && v.fetch("confirmed", false) == confirmed
|
|
208
277
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
v.fetch("limit"), Runner.count(v.fetch("benchmarks"), "benchmark"))
|
|
278
|
+
format("- %s %.3f [%.3f, %.3f] %s%s (limit %.4f, %s)", metric, v.fetch("median"), low, high, v.fetch("verdict"),
|
|
279
|
+
(" in both measurements" if confirmed), v.fetch("limit"), Runner.count(v.fetch("benchmarks"), "benchmark"))
|
|
212
280
|
end
|
|
213
|
-
"
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
def unsettled
|
|
217
|
-
names = @results.select { |r| Summary.verdict(r) == "unsettled" }.map { |r| Summary.code(r.name) }
|
|
218
|
-
return if names.empty?
|
|
219
|
-
|
|
220
|
-
"#### Unsettled\n\nA timing too close to the limit to settle doesn't fail the run, but one that's unsettled in run " \
|
|
221
|
-
"after run is noisy: #{names.join(", ")}."
|
|
281
|
+
"Across the board:\n\n#{lines.join("\n")}" unless lines.empty?
|
|
222
282
|
end
|
|
223
283
|
|
|
284
|
+
# The errors of more than a line, all of them (up to ERROR_LINES):
|
|
285
|
+
# their first lines are in view.
|
|
224
286
|
def errors
|
|
225
|
-
failed
|
|
226
|
-
return if failed.empty?
|
|
227
|
-
|
|
228
|
-
shown = failed.map do |r|
|
|
287
|
+
@failed.select { |r| r.error.to_s.chomp.include?("\n") }.map do |r|
|
|
229
288
|
error = r.error #: String
|
|
230
289
|
lines = error.lines
|
|
231
290
|
cut = "\n(#{Runner.count(lines.size - ERROR_LINES, "more line")} in the job's log)" if lines.size > ERROR_LINES
|
|
232
291
|
"**#{Summary.code(r.name)}**\n\n#{Summary.block(lines.first(ERROR_LINES).join)}#{cut}"
|
|
233
292
|
end
|
|
234
|
-
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Each version's Ruby, where they differ.
|
|
296
|
+
def rubies
|
|
297
|
+
rubies = @runner.rubies
|
|
298
|
+
return if rubies.values.uniq.size == 1
|
|
299
|
+
|
|
300
|
+
"Baseline on #{Summary.code(rubies.fetch("baseline"))}, candidate on #{Summary.code(rubies.fetch("candidate"))}."
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# How Duel decides that a timing is slower, as the settings have it.
|
|
304
|
+
def decides
|
|
305
|
+
format("A timing is slower when a %s%% confidence interval for its ratio, the candidate's to the baseline's, is above %s. " \
|
|
306
|
+
"[How Duel decides](%s)", (@settings.confidence * 100).round(1), @settings.limit, DECIDES)
|
|
235
307
|
end
|
|
236
308
|
end
|
|
237
309
|
end
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
#
|
|
5
5
|
# Duel follows conventions, so set only what differs: benchmarks are in
|
|
6
6
|
# duel/**/*_duel.rb, each version's lib/ is first on the load path, a
|
|
7
|
-
# change is compared when it touches lib/ or ext
|
|
8
|
-
# tagged v*.
|
|
7
|
+
# change is compared when it touches lib/ or ext/ (or the benchmarks, or
|
|
8
|
+
# this file), and releases are tagged v*.
|
|
9
9
|
Duel.configure do |c|
|
|
10
10
|
<% if library -%>
|
|
11
11
|
# The library each version's workers require (from the gemspec, unless
|
|
@@ -32,4 +32,9 @@ Duel.configure do |c|
|
|
|
32
32
|
# branch that upgrades Ruby is compared on each version's Ruby.
|
|
33
33
|
c.ruby = :version_file
|
|
34
34
|
<% end -%>
|
|
35
|
+
|
|
36
|
+
# When the workflow comments on a pull request with the report: :always,
|
|
37
|
+
# :results (passed or failed), :failures (failed, or couldn't compare),
|
|
38
|
+
# or :never.
|
|
39
|
+
# c.comment = :failures
|
|
35
40
|
end
|
|
@@ -8,14 +8,20 @@ on:
|
|
|
8
8
|
- cron: "0 4 * * *"
|
|
9
9
|
workflow_dispatch:
|
|
10
10
|
|
|
11
|
+
# The Duel job runs the branch's code (its benchmarks too), so it only
|
|
12
|
+
# reads. Commenting is the Comment job's, which runs none of it.
|
|
11
13
|
permissions:
|
|
12
14
|
contents: read
|
|
13
|
-
pull-requests: write # to comment on a pull request with the report
|
|
14
15
|
|
|
15
16
|
jobs:
|
|
16
17
|
duel:
|
|
17
18
|
name: Duel
|
|
18
19
|
runs-on: ubuntu-latest
|
|
20
|
+
# Whether the Comment job comments (post, change or none), and the
|
|
21
|
+
# run's status (passed, failed or neutral), from Duel.
|
|
22
|
+
outputs:
|
|
23
|
+
comment: ${{ steps.compare.outputs.comment }}
|
|
24
|
+
status: ${{ steps.compare.outputs.status }}
|
|
19
25
|
<% if working_directory -%>
|
|
20
26
|
# The project is in a subdirectory of the repository: Duel runs there,
|
|
21
27
|
# and checks out the whole repository for the baseline.
|
|
@@ -27,27 +33,56 @@ jobs:
|
|
|
27
33
|
- uses: actions/checkout@v7
|
|
28
34
|
with:
|
|
29
35
|
fetch-depth: 0 # the baseline comes from history, tags included
|
|
36
|
+
persist-credentials: false
|
|
30
37
|
# (And whatever else your tests need set up: packages, services.)
|
|
31
38
|
<% if version_file -%>
|
|
32
39
|
# Each version runs on the Ruby its own .ruby-version names (see
|
|
33
|
-
# .duel.rb)
|
|
34
|
-
# too, linked where Duel finds it (as
|
|
35
|
-
# before the project's own Ruby, which runs Duel.
|
|
40
|
+
# .duel.rb). When the baseline's names another Ruby than this code's,
|
|
41
|
+
# that one is installed too, and linked where Duel finds it (as
|
|
42
|
+
# chruby would), before the project's own Ruby, which runs Duel. The
|
|
43
|
+
# baseline is the Compare step's: where HEAD left the main branch
|
|
44
|
+
# (for a pull request, and run by hand), the commit before a push, or
|
|
45
|
+
# on a schedule the last release, the newest tag before HEAD that
|
|
46
|
+
# RELEASE_TAGS matches. (If .duel.rb sets c.main_branch or
|
|
47
|
+
# c.release_tags, set MAIN_BRANCH or RELEASE_TAGS to match.)
|
|
36
48
|
- name: Find the baseline's Ruby
|
|
37
49
|
id: baseline
|
|
38
|
-
|
|
39
|
-
|
|
50
|
+
env:
|
|
51
|
+
BEFORE: ${{ github.event.before }}
|
|
52
|
+
MAIN_BRANCH: origin/${{ github.event.repository.default_branch }}
|
|
53
|
+
RELEASE_TAGS: "<%= release_tags %>"
|
|
54
|
+
run: |
|
|
55
|
+
case "$GITHUB_EVENT_NAME" in
|
|
56
|
+
push) base=$BEFORE ;;
|
|
57
|
+
schedule) base=$(git describe --tags --abbrev=0 --match "$RELEASE_TAGS" HEAD^ 2>/dev/null) || base= ;;
|
|
58
|
+
*) base=$(git merge-base HEAD "$MAIN_BRANCH" 2>/dev/null) || base= ;;
|
|
59
|
+
esac
|
|
60
|
+
ruby_of() { git show "$1:./.ruby-version" 2>/dev/null | awk 'NF { print $1; exit }'; }
|
|
61
|
+
ruby=
|
|
62
|
+
# (None without a baseline: a branch's first push, or no release yet.)
|
|
63
|
+
if [ -n "$base" ] && git cat-file -e "$base^{commit}" 2>/dev/null; then
|
|
64
|
+
ruby=$(ruby_of "$base")
|
|
65
|
+
[ "$ruby" = "$(ruby_of HEAD)" ] && ruby=
|
|
66
|
+
fi
|
|
67
|
+
echo "ruby=$ruby" >> "$GITHUB_OUTPUT"
|
|
40
68
|
- uses: ruby/setup-ruby@v1
|
|
41
69
|
id: baseline-ruby
|
|
42
70
|
if: steps.baseline.outputs.ruby != ''
|
|
43
71
|
with:
|
|
72
|
+
<% if working_directory -%>
|
|
73
|
+
working-directory: <%= working_directory %>
|
|
74
|
+
<% end -%>
|
|
44
75
|
ruby-version: ${{ steps.baseline.outputs.ruby }}
|
|
76
|
+
# The bundle for this Ruby too, which the baseline's workers load.
|
|
77
|
+
# (Gems installed for one Ruby version don't load on another.)
|
|
78
|
+
bundler-cache: true
|
|
45
79
|
- name: Link the baseline's Ruby where Duel finds it
|
|
46
80
|
if: steps.baseline.outputs.ruby != ''
|
|
81
|
+
env:
|
|
82
|
+
PREFIX: ${{ steps.baseline-ruby.outputs.ruby-prefix }}
|
|
47
83
|
run: |
|
|
48
|
-
prefix="${{ steps.baseline-ruby.outputs.ruby-prefix }}"
|
|
49
84
|
mkdir -p ~/.rubies
|
|
50
|
-
ln -sfn "$
|
|
85
|
+
ln -sfn "$PREFIX" ~/.rubies/ruby-"$("$PREFIX/bin/ruby" -e 'print RUBY_VERSION')"
|
|
51
86
|
<% end -%>
|
|
52
87
|
- uses: ruby/setup-ruby@v1
|
|
53
88
|
with:
|
|
@@ -60,9 +95,11 @@ jobs:
|
|
|
60
95
|
bundler-cache: true
|
|
61
96
|
# Against the main branch for a pull request, what was there before
|
|
62
97
|
# for a push, and the last release on a schedule (slow creep). Duel
|
|
63
|
-
# adds its report to the job's summary,
|
|
64
|
-
# the comment
|
|
98
|
+
# adds its report to the job's summary, writes it to duel.md for the
|
|
99
|
+
# comment, and sets the step's outputs to say whether to comment, as
|
|
100
|
+
# c.comment in .duel.rb says, whatever the outcome.
|
|
65
101
|
- name: Compare
|
|
102
|
+
id: compare
|
|
66
103
|
env:
|
|
67
104
|
DUEL_BASE: ${{ github.event_name == 'schedule' && 'last-release' || github.event_name == 'push' && github.event.before || '' }}
|
|
68
105
|
# (Run by hand, it compares anyway: with nothing changed, the code
|
|
@@ -76,22 +113,52 @@ jobs:
|
|
|
76
113
|
exit 0
|
|
77
114
|
fi
|
|
78
115
|
bundle exec duel --markdown "$RUNNER_TEMP/duel.md"
|
|
79
|
-
# The report,
|
|
80
|
-
|
|
81
|
-
|
|
116
|
+
# The report, for the Comment job.
|
|
117
|
+
- name: Keep the report
|
|
118
|
+
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
|
|
119
|
+
uses: actions/upload-artifact@v7
|
|
120
|
+
with:
|
|
121
|
+
name: duel-report
|
|
122
|
+
path: ${{ runner.temp }}/duel.md
|
|
123
|
+
if-no-files-found: ignore # none when Duel didn't get to compare
|
|
124
|
+
retention-days: 1
|
|
125
|
+
|
|
126
|
+
# The report, as a new comment on the pull request, when Duel says to:
|
|
127
|
+
# always (post), or only when its last comment's status differs from
|
|
128
|
+
# this run's (change), so the latest comment is never stale. It never
|
|
129
|
+
# edits or deletes a comment. This job checks nothing out and runs none
|
|
130
|
+
# of the branch's code, so it alone can write. (A pull request from a
|
|
131
|
+
# fork gets no comment, as its token can't write one: the Duel job's
|
|
132
|
+
# summary has the report.)
|
|
133
|
+
comment:
|
|
134
|
+
name: Comment
|
|
135
|
+
needs: duel
|
|
136
|
+
if: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && (needs.duel.outputs.comment == 'post' || needs.duel.outputs.comment == 'change') }}
|
|
137
|
+
runs-on: ubuntu-latest
|
|
138
|
+
permissions:
|
|
139
|
+
pull-requests: write # to comment on the pull request with the report
|
|
140
|
+
steps:
|
|
141
|
+
- name: Get the report
|
|
142
|
+
id: report
|
|
143
|
+
continue-on-error: true # there's none when the Duel job failed before comparing
|
|
144
|
+
uses: actions/download-artifact@v8
|
|
145
|
+
with:
|
|
146
|
+
name: duel-report
|
|
147
|
+
path: ${{ runner.temp }}/report
|
|
82
148
|
- name: Comment on the pull request
|
|
83
|
-
if:
|
|
149
|
+
if: steps.report.outcome == 'success'
|
|
84
150
|
env:
|
|
85
151
|
GH_TOKEN: ${{ github.token }}
|
|
86
152
|
PR: ${{ github.event.pull_request.number }}
|
|
153
|
+
COMMENT: ${{ needs.duel.outputs.comment }}
|
|
154
|
+
STATUS: ${{ needs.duel.outputs.status }}
|
|
87
155
|
run: |
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
--jq ".[] | select(.user.login == \"github-actions[bot]\"
|
|
93
|
-
if [ -n "$
|
|
94
|
-
|
|
95
|
-
else
|
|
156
|
+
marker="<!-- <%= marker %>"
|
|
157
|
+
# The first line of the last comment Duel posted here: its marker,
|
|
158
|
+
# with its status (none in Duel 0.1.0's, which counts as another).
|
|
159
|
+
last=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR/comments" \
|
|
160
|
+
--jq ".[] | select(.user.login == \"github-actions[bot]\") | .body | split(\"\n\")[0] | rtrimstr(\"\r\") | select(startswith(\"$marker status=\") or . == \"$marker -->\")" | tail -n 1)
|
|
161
|
+
if [ "$COMMENT" = post ] || { [ -n "$last" ] && [ "$last" != "$marker status=$STATUS -->" ]; }; then
|
|
162
|
+
{ echo "$marker status=$STATUS -->"; cat "$RUNNER_TEMP/report/duel.md"; } > "$RUNNER_TEMP/comment.md"
|
|
96
163
|
gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -F body=@"$RUNNER_TEMP/comment.md" > /dev/null
|
|
97
164
|
fi
|
data/lib/duel/version.rb
CHANGED
data/sig/duel/baseline.rbs
CHANGED
|
@@ -16,9 +16,15 @@ module Duel
|
|
|
16
16
|
# The commit, or nil for "last-release" with no release yet.
|
|
17
17
|
def ref: () -> String?
|
|
18
18
|
|
|
19
|
-
# Whether nothing
|
|
19
|
+
# Whether nothing #watched changed since the baseline.
|
|
20
20
|
def unchanged?: () -> bool
|
|
21
21
|
|
|
22
|
+
# What a change must touch to be compared: the code (Config#watch),
|
|
23
|
+
# and what decides how it's measured (the benchmarks, the
|
|
24
|
+
# configuration's file, and with each version on its own Ruby, its
|
|
25
|
+
# version files).
|
|
26
|
+
def watched: () -> Array[String]
|
|
27
|
+
|
|
22
28
|
# The baseline's tree, checked out and built if it's a commit.
|
|
23
29
|
def root: () -> String
|
|
24
30
|
|
|
@@ -43,6 +49,15 @@ module Duel
|
|
|
43
49
|
|
|
44
50
|
private
|
|
45
51
|
|
|
52
|
+
# The benchmarks' directory, or for benchmarks at the root, their glob.
|
|
53
|
+
def benchmarks: () -> String
|
|
54
|
+
|
|
55
|
+
# The configuration's file, from the root.
|
|
56
|
+
def config_file: () -> String
|
|
57
|
+
|
|
58
|
+
# The version files at the root.
|
|
59
|
+
def version_files: () -> Array[String]
|
|
60
|
+
|
|
46
61
|
# Where HEAD left the main branch: their merge base, or a failure that
|
|
47
62
|
# says why there's none.
|
|
48
63
|
def merge_base: () -> String
|
data/sig/duel/cli.rbs
CHANGED
|
@@ -19,6 +19,15 @@ module Duel
|
|
|
19
19
|
# Where GitHub Actions has a step add to its job's summary.
|
|
20
20
|
STEP_SUMMARY: String
|
|
21
21
|
|
|
22
|
+
# Where GitHub Actions has a step set its outputs.
|
|
23
|
+
STEP_OUTPUT: String
|
|
24
|
+
|
|
25
|
+
# The statuses the workflow comments on, by when it does.
|
|
26
|
+
COMMENTED: Hash[Config::comment, Array[String]]
|
|
27
|
+
|
|
28
|
+
# A run's status as the comment's marker has it.
|
|
29
|
+
MARKED: Hash[String, String]
|
|
30
|
+
|
|
22
31
|
# The variables that set options, to their options.
|
|
23
32
|
ENV_OPTIONS: Hash[String, String]
|
|
24
33
|
|
|
@@ -33,6 +42,7 @@ module Duel
|
|
|
33
42
|
@markdown: String?
|
|
34
43
|
@json: String?
|
|
35
44
|
@settings: Settings?
|
|
45
|
+
@config: Config?
|
|
36
46
|
|
|
37
47
|
def initialize: (Array[String] argv, ?env: _Env, ?out: _Output, ?err: _Output) -> void
|
|
38
48
|
|
|
@@ -44,6 +54,13 @@ module Duel
|
|
|
44
54
|
# +source+ as a Regexp, or an option's invalid argument.
|
|
45
55
|
def self.regexp: (String source) -> Regexp
|
|
46
56
|
|
|
57
|
+
# Whether the workflow comments on a pull request: "post", "change"
|
|
58
|
+
# or "none".
|
|
59
|
+
def self.comment: (Config::comment mode, String status) -> String
|
|
60
|
+
|
|
61
|
+
# The mode --comment names, or an invalid argument.
|
|
62
|
+
def self.comment_mode: (String name) -> Config::comment
|
|
63
|
+
|
|
47
64
|
def parse: () -> Settings
|
|
48
65
|
|
|
49
66
|
private
|
|
@@ -52,14 +69,18 @@ module Duel
|
|
|
52
69
|
def compare: (Config config, Settings settings) -> Integer
|
|
53
70
|
|
|
54
71
|
# Says there's nothing to compare, and why, and passes.
|
|
55
|
-
def nothing: (String why) -> Integer
|
|
72
|
+
def nothing: (String title, String why) -> Integer
|
|
73
|
+
|
|
74
|
+
# Reports a run that didn't compare, and why, and returns +exit+.
|
|
75
|
+
def unfinished: (String mark, String title, String status, String why, Integer exit) -> Integer
|
|
56
76
|
|
|
57
|
-
#
|
|
58
|
-
|
|
77
|
+
# What the workflow does about its comment on a pull request, for a
|
|
78
|
+
# run of +status+.
|
|
79
|
+
def commenting: (String status) -> String
|
|
59
80
|
|
|
60
81
|
# Writes the reports where they're asked for, each made only if it's
|
|
61
|
-
# written.
|
|
62
|
-
def publish: (^() -> String markdown, ^() -> JSONReport::report json) -> void
|
|
82
|
+
# written, and on GitHub Actions, the step's outputs.
|
|
83
|
+
def publish: (String status, ^() -> String markdown, ^(String comment) -> JSONReport::report json) -> void
|
|
63
84
|
|
|
64
85
|
# Writes +text+ to +path+, in a directory made for it.
|
|
65
86
|
def write: (String path, String text) -> void
|
data/sig/duel/config.rbs
CHANGED
|
@@ -10,6 +10,9 @@ module Duel
|
|
|
10
10
|
# version file's, a path, or what a block makes of the version's tree.
|
|
11
11
|
type ruby = :version_file | String | ^(String root) -> String | nil
|
|
12
12
|
|
|
13
|
+
# When the workflow comments on a pull request (see #comment).
|
|
14
|
+
type comment = :always | :results | :failures | :never
|
|
15
|
+
|
|
13
16
|
@root: String
|
|
14
17
|
@benchmarks: String
|
|
15
18
|
@watch: Array[String]
|
|
@@ -19,6 +22,7 @@ module Duel
|
|
|
19
22
|
@release_tags: String
|
|
20
23
|
@prepares: Hash[Symbol, Array[prepare]]
|
|
21
24
|
@ruby: ruby
|
|
25
|
+
@comment: comment
|
|
22
26
|
@metrics: Array[singleton(Metric)]
|
|
23
27
|
@in_workers: Array[^(Stops stops) -> void]
|
|
24
28
|
@hooks: Hash[Symbol, Array[hook]]
|
|
@@ -32,7 +36,8 @@ module Duel
|
|
|
32
36
|
# A glob, from the root, of the files defining benchmarks.
|
|
33
37
|
attr_accessor benchmarks: String
|
|
34
38
|
|
|
35
|
-
# Paths, from the root, whose changes a comparison is
|
|
39
|
+
# Paths, from the root, of the code whose changes a comparison is
|
|
40
|
+
# about (with what decides how it's measured).
|
|
36
41
|
attr_accessor watch: Array[String]
|
|
37
42
|
|
|
38
43
|
# The library to require from each version, or nil for none.
|
|
@@ -52,6 +57,16 @@ module Duel
|
|
|
52
57
|
# given the version's tree.
|
|
53
58
|
attr_accessor ruby: ruby
|
|
54
59
|
|
|
60
|
+
# When the workflow comments on a pull request: :always, :results,
|
|
61
|
+
# :failures (the default) or :never.
|
|
62
|
+
attr_reader comment: comment
|
|
63
|
+
|
|
64
|
+
# The values #comment takes.
|
|
65
|
+
COMMENTS: Array[comment]
|
|
66
|
+
|
|
67
|
+
# Sets #comment, or fails for a value it doesn't take.
|
|
68
|
+
def comment=: (untyped mode) -> untyped
|
|
69
|
+
|
|
55
70
|
# What #prepare was given, by scope.
|
|
56
71
|
# @api private
|
|
57
72
|
attr_reader prepares: Hash[Symbol, Array[prepare]]
|