duel 0.1.0

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.
Files changed (63) hide show
  1. checksums.yaml +7 -0
  2. data/AGENT_PROMPT.md +211 -0
  3. data/CHANGELOG.md +281 -0
  4. data/LICENSE +21 -0
  5. data/README.md +354 -0
  6. data/docs/README.md +23 -0
  7. data/docs/benchmarks.md +289 -0
  8. data/docs/ci.md +141 -0
  9. data/docs/configuration.md +429 -0
  10. data/docs/how-it-works.md +207 -0
  11. data/docs/json.md +306 -0
  12. data/docs/plugins.md +408 -0
  13. data/docs/troubleshooting.md +105 -0
  14. data/exe/duel +6 -0
  15. data/lib/duel/across.rb +48 -0
  16. data/lib/duel/baseline.rb +166 -0
  17. data/lib/duel/cli.rb +264 -0
  18. data/lib/duel/config.rb +343 -0
  19. data/lib/duel/context.rb +630 -0
  20. data/lib/duel/init.rb +211 -0
  21. data/lib/duel/json_report.rb +107 -0
  22. data/lib/duel/lock.rb +38 -0
  23. data/lib/duel/metric.rb +496 -0
  24. data/lib/duel/native.rb +162 -0
  25. data/lib/duel/process_sample.rb +144 -0
  26. data/lib/duel/profile.rb +116 -0
  27. data/lib/duel/rake_task.rb +25 -0
  28. data/lib/duel/resource.rb +209 -0
  29. data/lib/duel/rubies.rb +149 -0
  30. data/lib/duel/runner.rb +1114 -0
  31. data/lib/duel/stats.rb +73 -0
  32. data/lib/duel/summary.rb +237 -0
  33. data/lib/duel/templates/benchmark_duel.rb.erb +34 -0
  34. data/lib/duel/templates/duel.rb.erb +35 -0
  35. data/lib/duel/templates/perf.yml.erb +97 -0
  36. data/lib/duel/testing.rb +241 -0
  37. data/lib/duel/version.rb +5 -0
  38. data/lib/duel/worker.rb +211 -0
  39. data/lib/duel.rb +413 -0
  40. data/sig/duel/across.rbs +21 -0
  41. data/sig/duel/baseline.rbs +61 -0
  42. data/sig/duel/cli.rbs +95 -0
  43. data/sig/duel/config.rbs +181 -0
  44. data/sig/duel/context.rbs +387 -0
  45. data/sig/duel/init.rbs +97 -0
  46. data/sig/duel/json_report.rbs +47 -0
  47. data/sig/duel/lock.rbs +12 -0
  48. data/sig/duel/metric.rbs +351 -0
  49. data/sig/duel/native.rbs +98 -0
  50. data/sig/duel/process_sample.rbs +54 -0
  51. data/sig/duel/profile.rbs +64 -0
  52. data/sig/duel/rake_task.rbs +9 -0
  53. data/sig/duel/resource.rbs +106 -0
  54. data/sig/duel/rubies.rbs +45 -0
  55. data/sig/duel/runner.rbs +530 -0
  56. data/sig/duel/stats.rbs +26 -0
  57. data/sig/duel/summary.rbs +87 -0
  58. data/sig/duel/testing.rbs +91 -0
  59. data/sig/duel/version.rbs +3 -0
  60. data/sig/duel/worker.rbs +74 -0
  61. data/sig/duel.rbs +276 -0
  62. data/sig/manifest.yaml +4 -0
  63. metadata +124 -0
data/lib/duel/stats.rb ADDED
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Duel
4
+ # The statistics behind a verdict: a distribution-free confidence
5
+ # interval for the median of paired ratios (candidate / baseline), and
6
+ # the decision it supports.
7
+ # @api private
8
+ module Stats
9
+ class << self
10
+ # The 1-based ranks [lower, upper] of the order statistics that bound
11
+ # a +confidence+ interval for the median of +n+ samples, or nil when
12
+ # +n+ is too small for one (fewer than 8 samples, at 99%). It's
13
+ # distribution-free: the true median lies below the k-th smallest
14
+ # sample with probability P(Binomial(n, 1/2) <= k - 1), whatever the
15
+ # samples' distribution, so one outlier can't move it.
16
+ #
17
+ # The least k whose P(Binomial(n, 1/2) <= k) exceeds the tail, its
18
+ # terms each found from the last (C(n, k + 1) = C(n, k) * (n - k) /
19
+ # (k + 1), exactly), so that a thousand benchmarks across the board
20
+ # take a thousand steps, not a billion.
21
+ def median_ranks(n, confidence)
22
+ tail = (1 - confidence) / 2
23
+ whole = 2**n
24
+ k = 0
25
+ term = cumulative = 1
26
+ # (It ends: at k = n, cumulative is the whole.)
27
+ until cumulative.fdiv(whole) > tail
28
+ term = term * (n - k) / (k + 1)
29
+ k += 1
30
+ cumulative += term
31
+ end
32
+ [k, n + 1 - k] if k.positive?
33
+ end
34
+
35
+ # [low, high] bounds of the +confidence+ interval for the median of
36
+ # +samples+, or nil (see #median_ranks).
37
+ def median_interval(samples, confidence)
38
+ ranks = median_ranks(samples.size, confidence)
39
+ return unless ranks
40
+
41
+ sorted = samples.sort
42
+ lower, upper = ranks
43
+ [sorted.fetch(lower - 1), sorted.fetch(upper - 1)]
44
+ end
45
+
46
+ def median(samples)
47
+ sorted = samples.sort
48
+ mid = sorted.size / 2
49
+ sorted.size.odd? ? sorted.fetch(mid) : (sorted.fetch(mid - 1) + sorted.fetch(mid)) / 2.0
50
+ end
51
+
52
+ # What paired ratios (candidate / baseline, so above 1 is slower) say
53
+ # against +limit+ (a ratio above 1): see #decide.
54
+ def verdict(ratios, limit:, confidence:) = decide(median_interval(ratios, confidence), limit)
55
+
56
+ # What a confidence +interval+ for the median ratio (or nil, for too
57
+ # few) says against +limit+: :slower when it lies above the limit,
58
+ # :faster when it lies below 1 / limit, :same when it lies below the
59
+ # limit (it isn't slower: nothing more needs proving), and :undecided
60
+ # while it straddles the limit.
61
+ def decide(interval, limit)
62
+ return :undecided unless interval
63
+
64
+ low, high = interval
65
+ return :slower if low > limit
66
+ return :faster if high < 1 / limit
67
+ return :same if high < limit
68
+
69
+ :undecided
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "runner"
4
+
5
+ module Duel
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.
12
+ # @api private
13
+ class Summary
14
+ # The most characters it has (a GitHub comment has at most 65,536):
15
+ # past it, regressions go unexplained, from the last, and the job's
16
+ # log has the rest.
17
+ LIMIT = 60_000
18
+
19
+ # The most lines of an error it shows.
20
+ ERROR_LINES = 12
21
+
22
+ # A benchmark's verdict, and where it goes in the table (in that
23
+ # order).
24
+ VERDICTS = %w[regressed error unsettled faster changed skipped same].freeze
25
+
26
+ # The verdicts that fail a run, which stand out.
27
+ FAILED = %w[regressed error].freeze
28
+
29
+ # +text+ as code, in Markdown: in a span of more backticks than any it
30
+ # has (and spaced from one at either end).
31
+ def self.code(text)
32
+ ticks = "`" * ([0, *text.scan(/`+/).map(&:size)].max + 1)
33
+ padded = (text.start_with?("`") || text.end_with?("`")) ? " #{text} " : text
34
+ "#{ticks}#{padded}#{ticks}"
35
+ end
36
+
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"
39
+
40
+ # +text+ in a fenced block of its own: fenced with more backticks than
41
+ # any run of them it has, three at least.
42
+ def self.block(text)
43
+ fence = "`" * [3, *text.scan(/`{3,}/).map { |run| run.size + 1 }].max
44
+ "#{fence}text\n#{text.chomp}\n#{fence}"
45
+ end
46
+
47
+ # +result+'s verdict (see VERDICTS): the JSON report's too (see
48
+ # JSONReport).
49
+ def self.verdict(result)
50
+ if result.error then "error"
51
+ elsif result.skipped then "skipped"
52
+ elsif result.regressed.any? then "regressed"
53
+ elsif result.unsettled? then "unsettled"
54
+ elsif result.timed.any? { |_, t| t.fetch("verdict") == "faster" } then "faster"
55
+ elsif changed_counts(result).any? then "changed"
56
+ else "same"
57
+ end
58
+ end
59
+
60
+ # {metric => [baseline, candidate]}, an iteration, of the counted
61
+ # measures that differ.
62
+ def self.changed_counts(result)
63
+ result.counted.to_h do |metric, c|
64
+ n = c.fetch("n")
65
+ [metric, [c.fetch("baseline").fdiv(n), c.fetch("candidate").fdiv(n)]] #: [String, [Float, Float]]
66
+ end.reject { |_, (base, candidate)| base == candidate }
67
+ end
68
+
69
+ # +runner+ ran, and made +results+, comparing +baseline+ with
70
+ # +candidate+ (each a description, in Markdown) as +settings+ say.
71
+ def initialize(runner, results, settings:, baseline:, candidate:)
72
+ @runner = runner
73
+ @results = results
74
+ @settings = settings
75
+ @versions = {"baseline" => baseline, "candidate" => candidate}
76
+ @regressed = results.select { |r| Summary.verdict(r) == "regressed" }
77
+ end
78
+
79
+ # The Markdown: every regression explained, or as many as LIMIT has
80
+ # room for.
81
+ def markdown
82
+ explained = @regressed.size
83
+ text = render(explained)
84
+ text = render(explained -= 1) while text.size > LIMIT && explained.positive?
85
+ text
86
+ end
87
+
88
+ private
89
+
90
+ # The Markdown, with the first +explained+ regressions explained.
91
+ 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"
95
+ end
96
+
97
+ 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?
103
+
104
+ "### Duel passed: none of #{Runner.count(@results.size, "benchmark")} slower"
105
+ end
106
+
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}"
115
+ end
116
+ lines << "- **Plugins:** #{plugins.map { |plugin| Summary.code(plugin.to_s) }.join(", ")}" unless plugins.empty?
117
+ lines.join("\n")
118
+ end
119
+
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)
124
+ end
125
+
126
+ # The results, in the order of their VERDICTS, and the run's order in
127
+ # each (grouped, not sorted: Ruby's sort isn't stable everywhere).
128
+ def sorted
129
+ grouped = @results.group_by { |r| Summary.verdict(r) }
130
+ VERDICTS.flat_map { |shown| grouped.fetch(shown, []) }
131
+ end
132
+
133
+ def table(changed)
134
+ return if changed.empty?
135
+
136
+ rows = changed.map do |r|
137
+ shown = Summary.verdict(r)
138
+ row(Summary.code(name(r)), FAILED.include?(shown) ? "**#{shown}**" : shown, timings(r), counts(r))
139
+ 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>"
150
+ end
151
+
152
+ # A table's row of +cells+.
153
+ def row(*cells) = "| #{cells.join(" | ")} |"
154
+
155
+ # A benchmark's name, for a table's cell.
156
+ def name(result) = result.name.tr("\n", " ").gsub("|", "\\|")
157
+
158
+ # Each timed measure's ratio and its interval, with its verdict where
159
+ # it isn't the same (or why it was skipped).
160
+ def timings(result)
161
+ skipped = result.skipped
162
+ return skipped.gsub("|", "\\|") if skipped
163
+
164
+ result.timed.map do |metric, t|
165
+ low, high = t.fetch("interval")
166
+ shown = t.fetch("verdict")
167
+ format("%s %.3f [%.3f, %.3f]%s", metric, t.fetch("median"), low, high, (" #{shown}" unless shown == "same"))
168
+ end.join("<br>")
169
+ end
170
+
171
+ # Each counted measure that changed, an iteration, and peak memory
172
+ # that grew too much.
173
+ def counts(result)
174
+ counted = Summary.changed_counts(result).map do |metric, (base, candidate)|
175
+ format("%s %.4g → %.4g", metric, base, candidate)
176
+ end
177
+ if result.more_memory?
178
+ memory = result.memory #: Hash[String, Integer]
179
+ counted << "peak_memory #{mb(memory.fetch("baseline"))} → #{mb(memory.fetch("candidate"))}"
180
+ end
181
+ counted.join("<br>")
182
+ end
183
+
184
+ def mb(bytes) = format("%.1fMB", bytes / 1_048_576.0)
185
+
186
+ # The first +explained+ regressions, explained as the terminal report
187
+ # explains them (and the rest named).
188
+ def regressions(explained)
189
+ return if @regressed.empty?
190
+
191
+ shown = @regressed.first(explained).map do |r|
192
+ what = r.mixed? ? "slower in every measurement, by one of #{r.regressed.join(", ")}" : "worse #{r.regressed.join(", ")} in every measurement"
193
+ lines = @runner.explained(r)
194
+ said = "**#{Summary.code(r.name)}**: #{what}"
195
+ lines.empty? ? said : "#{said}\n\n#{Summary.block(lines.join("\n"))}"
196
+ end
197
+ rest = @regressed.size - shown.size
198
+ 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")}"
200
+ end
201
+
202
+ # The verdicts across the board, of the metrics with enough
203
+ # benchmarks to tell.
204
+ def across
205
+ lines = @runner.across.filter_map do |metric, v|
206
+ low, high = v.fetch("interval")
207
+ next unless low
208
+
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"))
212
+ 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(", ")}."
222
+ end
223
+
224
+ def errors
225
+ failed = @results.select(&:error)
226
+ return if failed.empty?
227
+
228
+ shown = failed.map do |r|
229
+ error = r.error #: String
230
+ lines = error.lines
231
+ cut = "\n(#{Runner.count(lines.size - ERROR_LINES, "more line")} in the job's log)" if lines.size > ERROR_LINES
232
+ "**#{Summary.code(r.name)}**\n\n#{Summary.block(lines.first(ERROR_LINES).join)}#{cut}"
233
+ end
234
+ "#### Errors\n\n#{shown.join("\n\n")}"
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Benchmarks, which Duel runs on two versions of the code, the baseline
4
+ # (where your branch left main) and the candidate (your changes), and
5
+ # fails if the candidate is slower at any of them. Each version's workers
6
+ # load <%= required ? "#{required} and " : "" %>every duel/**/*_duel.rb file. See
7
+ # https://github.com/sferik/duel-ruby/blob/main/docs/benchmarks.md.
8
+ #
9
+ # To write benchmarks that tell a regression from noise:
10
+ #
11
+ # - Benchmark the work that users depend on being fast: the main entry
12
+ # points, and whatever runs in a loop or once per request. Cover typical
13
+ # inputs, and the small and large extremes, as separate benchmarks.
14
+ # - Name each one "area: what it does". `duel --filter "^parse:"` picks
15
+ # out an area.
16
+ # - Time only the work. Build inputs and read fixtures in `once`, which
17
+ # isn't timed. To prepare each sample's work, take |n| (the
18
+ # iterations) and wrap the preparation in `untimed`.
19
+ # - Make every iteration alike: fixed inputs, seeded randomness
20
+ # (Random.new(42)), and caches warmed in `once`. Undo what an
21
+ # iteration changes with an `around_iteration` hook, or put it back
22
+ # with a `before_sample` hook.
23
+ # - Aim for microseconds to a few milliseconds an iteration, and split
24
+ # anything slower.
25
+ # - Use `only: :count` for work that's mostly waiting (on a socket, say),
26
+ # and `only: :timing` for work on threads of its own.
27
+ # - Use only the API that the main branch has: a benchmark that the
28
+ # baseline can't run is skipped.
29
+
30
+ # An example, to replace with benchmarks of your own code.
31
+ duel "example: join words" do
32
+ once { @words = %w[the quick brown fox jumps over the lazy dog] } # not timed
33
+ @words.join(" ") # timed
34
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Duel's configuration: https://github.com/sferik/duel-ruby/blob/main/docs/configuration.md
4
+ #
5
+ # Duel follows conventions, so set only what differs: benchmarks are in
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*.
9
+ Duel.configure do |c|
10
+ <% if library -%>
11
+ # The library each version's workers require (from the gemspec, unless
12
+ # this says otherwise).
13
+ # c.library = "<%= library %>"
14
+ <% elsif guess -%>
15
+ # The library each version's workers require, from its lib/.
16
+ c.library = "<%= guess %>"
17
+ <% else -%>
18
+ # The library each version's workers require, from its lib/ (there's no
19
+ # gemspec to name it). Or, to load each version some other way:
20
+ # c.load { |root| require File.join(root, "config", "boot") }
21
+ # c.library = "my_library"
22
+ <% end -%>
23
+ <% if ext -%>
24
+
25
+ # Builds each version's C extension, so that each is measured with its
26
+ # own build.
27
+ c.build = "bundle exec rake compile"
28
+ <% end -%>
29
+ <% if version_file -%>
30
+
31
+ # Each version runs on the Ruby that its own .ruby-version names, so a
32
+ # branch that upgrades Ruby is compared on each version's Ruby.
33
+ c.ruby = :version_file
34
+ <% end -%>
35
+ end
@@ -0,0 +1,97 @@
1
+ name: Perf
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [main]
7
+ schedule:
8
+ - cron: "0 4 * * *"
9
+ workflow_dispatch:
10
+
11
+ permissions:
12
+ contents: read
13
+ pull-requests: write # to comment on a pull request with the report
14
+
15
+ jobs:
16
+ duel:
17
+ name: Duel
18
+ runs-on: ubuntu-latest
19
+ <% if working_directory -%>
20
+ # The project is in a subdirectory of the repository: Duel runs there,
21
+ # and checks out the whole repository for the baseline.
22
+ defaults:
23
+ run:
24
+ working-directory: <%= working_directory %>
25
+ <% end -%>
26
+ steps:
27
+ - uses: actions/checkout@v7
28
+ with:
29
+ fetch-depth: 0 # the baseline comes from history, tags included
30
+ # (And whatever else your tests need set up: packages, services.)
31
+ <% if version_file -%>
32
+ # 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.
36
+ - name: Find the baseline's Ruby
37
+ 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"
40
+ - uses: ruby/setup-ruby@v1
41
+ id: baseline-ruby
42
+ if: steps.baseline.outputs.ruby != ''
43
+ with:
44
+ ruby-version: ${{ steps.baseline.outputs.ruby }}
45
+ - name: Link the baseline's Ruby where Duel finds it
46
+ if: steps.baseline.outputs.ruby != ''
47
+ run: |
48
+ prefix="${{ steps.baseline-ruby.outputs.ruby-prefix }}"
49
+ mkdir -p ~/.rubies
50
+ ln -sfn "$prefix" ~/.rubies/ruby-"$("$prefix/bin/ruby" -e 'print RUBY_VERSION')"
51
+ <% end -%>
52
+ - uses: ruby/setup-ruby@v1
53
+ with:
54
+ <% if working_directory -%>
55
+ working-directory: <%= working_directory %>
56
+ <% end -%>
57
+ <% if ruby_version -%>
58
+ ruby-version: "<%= ruby_version %>"
59
+ <% end -%>
60
+ bundler-cache: true
61
+ # Against the main branch for a pull request, what was there before
62
+ # 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.
65
+ - name: Compare
66
+ env:
67
+ DUEL_BASE: ${{ github.event_name == 'schedule' && 'last-release' || github.event_name == 'push' && github.event.before || '' }}
68
+ # (Run by hand, it compares anyway: with nothing changed, the code
69
+ # with itself, which checks the setup.)
70
+ DUEL_FORCE: ${{ github.event_name == 'workflow_dispatch' && '1' || '' }}
71
+ run: |
72
+ # (A branch's first push, or one that rewrote history, has no
73
+ # commit before it here to compare against.)
74
+ if [ "$GITHUB_EVENT_NAME" = push ] && ! git cat-file -e "$DUEL_BASE^{commit}" 2>/dev/null; then
75
+ echo "::notice::No commit before this push to compare against"
76
+ exit 0
77
+ fi
78
+ 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.)
82
+ - name: Comment on the pull request
83
+ if: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
84
+ env:
85
+ GH_TOKEN: ${{ github.token }}
86
+ PR: ${{ github.event.pull_request.number }}
87
+ 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
96
+ gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -F body=@"$RUNNER_TEMP/comment.md" > /dev/null
97
+ fi