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.
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). It has the verdict, the two versions
8
- # (and their Rubies, where they differ), a table of the benchmarks that
9
- # changed, regressions first, each regression explained as the terminal
10
- # report explains it, those unsettled and those that failed. The rest
11
- # are collapsed, so it stays short with hundreds of benchmarks.
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
- # A run that didn't compare (+title+ says why), with what it said.
38
- def self.note(title, message) = "### Duel: #{title}\n\n#{block(message)}\n"
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) as +settings+ say.
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
- @versions = {"baseline" => baseline, "candidate" => candidate}
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
- same, changed = sorted.partition { |r| Summary.verdict(r) == "same" }
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
- errors = @results.count(&:error)
99
- failed = [(Runner.count(@regressed.size, "regression") unless @regressed.empty?),
100
- ("slower across the board" unless @runner.regressed_across.empty?),
101
- (Runner.count(errors, "error") unless errors.zero?)].compact
102
- return "### Duel failed: #{failed.join(", ")}" unless failed.empty?
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
- "### Duel passed: none of #{Runner.count(@results.size, "benchmark")} slower"
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
- # Each version, on its Ruby where theirs differ, and the plugins
108
- # used.
109
- def versions
110
- rubies = @runner.rubies
111
- plugins = @runner.plugins
112
- lines = @versions.map do |side, described|
113
- ruby = " on #{Summary.code(rubies.fetch(side))}" if rubies.values.uniq.size > 1
114
- "- **#{side.capitalize}:** #{described}#{ruby}"
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
- lines << "- **Plugins:** #{plugins.map { |plugin| Summary.code(plugin.to_s) }.join(", ")}" unless plugins.empty?
117
- lines.join("\n")
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
- def overview
121
- format("%s in %.0fs. A timing is slower when a %s%% confidence interval for its ratio, the candidate's to the " \
122
- "baseline's, is above %s.", Runner.count(@results.size, "benchmark"), @runner.seconds,
123
- (@settings.confidence * 100).round(1), @settings.limit)
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
- def table(changed)
134
- return if changed.empty?
209
+ # Every benchmark, with its timings, and its counts where any changed.
210
+ def table
211
+ return if @results.empty?
135
212
 
136
- rows = changed.map do |r|
213
+ rows = sorted.map do |r|
137
214
  shown = Summary.verdict(r)
138
- row(Summary.code(name(r)), FAILED.include?(shown) ? "**#{shown}**" : shown, timings(r), counts(r))
215
+ [Summary.code(cell(r.name)), FAILED.include?(shown) ? "**#{shown}**" : shown, timings(r), counts(r)]
139
216
  end
140
- ([row("Benchmark", "Verdict", "Timings (ratio [interval])", "Counts an iteration"), "|---|---|---|---|"] + rows).join("\n")
141
- end
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(*cells) = "| #{cells.join(" | ")} |"
223
+ def row(cells) = "| #{cells.join(" | ")} |"
154
224
 
155
- # A benchmark's name, for a table's cell.
156
- def name(result) = result.name.tr("\n", " ").gsub("|", "\\|")
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.gsub("|", "\\|") if 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
- "#### Regressions\n\n#{shown.join("\n\n")}"
267
+ shown
200
268
  end
201
269
 
202
270
  # The verdicts across the board, of the metrics with enough
203
- # benchmarks to tell.
204
- def across
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
- confirmed = " in both measurements" if v["confirmed"]
210
- format("- %s %.3f [%.3f, %.3f] %s%s (limit %.4f, %s)", metric, v.fetch("median"), low, high, v.fetch("verdict"), confirmed,
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
- "#### Across the board\n\n#{lines.join("\n")}" unless lines.empty?
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 = @results.select(&:error)
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
- "#### Errors\n\n#{shown.join("\n\n")}"
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/, and releases are
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): for a pull request that upgrades Ruby, the main branch's
34
- # too, linked where Duel finds it (as chruby would). It's set up
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
- if: github.event_name == 'pull_request'
39
- run: echo "ruby=$(git show "origin/$GITHUB_BASE_REF:./.ruby-version" 2>/dev/null)" >> "$GITHUB_OUTPUT"
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 "$prefix" ~/.rubies/ruby-"$("$prefix/bin/ruby" -e 'print RUBY_VERSION')"
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, and writes it to duel.md for
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, as a comment on the pull request: one comment, updated
80
- # on every push. (A pull request from a fork gets no comment, as its
81
- # token can't write one: the job's summary has the report.)
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: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
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
- [ -f "$RUNNER_TEMP/duel.md" ] || exit 0
89
- marker="<!-- <%= marker %> -->"
90
- { echo "$marker"; cat "$RUNNER_TEMP/duel.md"; } > "$RUNNER_TEMP/comment.md"
91
- ids=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR/comments" \
92
- --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"$marker\"))) | .id")
93
- if [ -n "$ids" ]; then
94
- gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/${ids%%$'\n'*}" -F body=@"$RUNNER_TEMP/comment.md" > /dev/null
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Duel
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
@@ -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 Config#watch names changed since the baseline.
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
- # Reports a run that didn't compare, and returns +exit+.
58
- def unfinished: (String title, String status, String message, Integer exit) -> Integer
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 about.
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]]