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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 24277600f238129c16a52fb4115ccb6a6bd340602e7a38187622bfe1550adc1d
4
- data.tar.gz: 489836bc05d935ef59e369149df8ec47e06279005691fd084ea9b587c489f175
3
+ metadata.gz: da30e26518ff9b626552115d24595276b5f6f371cc4cd9c614b126666a065898
4
+ data.tar.gz: d863cf940c61196483a7521ec8066385e4b2cc9071ec00c96bc6f794c266342c
5
5
  SHA512:
6
- metadata.gz: 2ce8583f3c82f785cd25b95339c9fd7a751e42b7221d0a8b9af793351560945b8ca4d4001c7725db93c3a4d77506baf3cf5de37b5decd4c055f1ca17313de997
7
- data.tar.gz: 81e190402ee6b2eb3705b41fece6ee45e12bc3a6f71645ac1467d7bbe5ed015195d9291fb4b651888b69b759fa4f109f88029e7b7c0272260c2326af11692b65
6
+ metadata.gz: 777b2dc46c93019d739aed99764ac70ecdf761e1309944cdacf449b735b6a931d965fa3fc3b37141874dfeb9b8074ce120f070e4e764ecc15f9b1edbe00617d0
7
+ data.tar.gz: 2879233c357cfd504d0b872efd37495cdf64e115da6ed9917fe1b0fad6ad0b7f070e8c2ed9b1cdf5b4b7444a3d7dbfeaa3155fe1cac58b425890dca99ce4f19a
data/AGENT_PROMPT.md CHANGED
@@ -151,9 +151,9 @@ at the API.
151
151
  candidate's).
152
152
 
153
153
  Then do all three:
154
- - Run `bundle exec duel --force`. With `lib/` unchanged, it compares
155
- the code with itself and must pass. Run it three times: a noisy
156
- benchmark can pass twice by luck. A benchmark that fails or
154
+ - Run `bundle exec duel --force`. With the code unchanged, it
155
+ compares the code with itself and must pass. Run it three times: a
156
+ noisy benchmark can pass twice by luck. A benchmark that fails or
157
157
  errors is broken. One reported unsettled in more than one run is
158
158
  noisy: fix it or drop it. (Across many benchmarks, one unsettled
159
159
  once is normal: it doesn't fail the run.) Benchmarks that run a
@@ -168,24 +168,28 @@ at the API.
168
168
  and rebuild the extension if you changed one.
169
169
  - Run `bundle exec duel --force --filter <pattern>` for a single
170
170
  benchmark, and for one area (`--filter "^parse:"`), to confirm
171
- your names can be picked out. Without `--force`, an unchanged
172
- `lib/` has nothing to compare.
171
+ your names can be picked out. Without `--force`, a tree with
172
+ nothing changed since the main branch has nothing to compare.
173
173
 
174
174
  6. **Check CI.** `duel init` wrote Duel's GitHub Actions
175
175
  workflow (`.github/workflows/perf.yml`). Keep `fetch-depth: 0`:
176
176
  Duel's baseline comes from git history. Keep its check for a push
177
177
  with no commit before it in the history (a branch's first push, or a
178
- force push), which would otherwise fail the job. Keep its last step,
179
- which comments on a pull request with Duel's report (Duel also adds
180
- the report to the job's summary). Check that `ruby-version` is one
181
- the project supports, unless it has a `.ruby-version`. With
182
- `c.ruby = :version_file` and another version file than
183
- `.ruby-version`, add the steps in Duel's `docs/ci.md` that install the
184
- baseline's Ruby for a pull request. Add the project's other CI setup that its
185
- tests need (system packages, services) to the workflow as well. Keep
186
- the scheduled `last-release` run only if the project tags its
187
- releases (`v*` by default, or else set `c.release_tags`), or leave
188
- the schedule out. Until the first release is tagged, that run says
178
+ force push), which would otherwise fail the job. Keep its Comment
179
+ job, which comments on a pull request with Duel's report when a
180
+ benchmark fails (Duel also adds the report to the job's summary, and
181
+ `c.comment` in `.duel.rb` chooses when it comments), and keep
182
+ `pull-requests: write` on that job alone: the Duel job runs the
183
+ branch's code, so it only reads. Check that `ruby-version` is one the project supports, unless
184
+ it has a `.ruby-version`. With `c.ruby = :version_file` and another
185
+ version file than `.ruby-version`, add the steps in Duel's
186
+ `docs/ci.md` that install the baseline's Ruby, reading that file
187
+ instead. If `.duel.rb` sets `c.main_branch` or `c.release_tags`, set
188
+ those steps' `MAIN_BRANCH` or `RELEASE_TAGS` to match. Add the
189
+ project's other CI setup that its tests need (system packages,
190
+ services) to the Duel job as well. Keep the scheduled `last-release`
191
+ run only if the project tags its releases (`v*` by default, or else
192
+ set `c.release_tags`), or leave the schedule out. Until the first release is tagged, that run says
189
193
  there's nothing to compare against, and passes. Once it's pushed,
190
194
  run the workflow by hand: it compares the code with itself there,
191
195
  which shows that the benchmarks run on CI's machines (with their
data/CHANGELOG.md CHANGED
@@ -7,6 +7,86 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.1] - 2026-10-26
11
+
12
+ ### Added
13
+
14
+ - `c.comment` chooses when the workflow that `duel init` writes
15
+ comments on a pull request with the report: `:always`, `:results`
16
+ (when a run passed or failed, but not with nothing to compare),
17
+ `:failures` (when a benchmark failed or Duel couldn't compare, the
18
+ default), or `:never`. `duel --comment MODE` overrides it, and any
19
+ other value fails with the values it takes. On GitHub Actions, `duel`
20
+ sets the step outputs `comment` (`post`, `change`, or `none`) and
21
+ `status` (`passed`, `failed`, or `neutral`), whatever the outcome, and
22
+ the workflow's Comment job acts on them, since it runs none of the
23
+ branch's code and can't read `.duel.rb`. The JSON report's `comment`
24
+ key has the same decision.
25
+
26
+ ### Changed
27
+
28
+ - The workflow that `duel init` writes no longer comments on every pull
29
+ request. By default, it comments only when a benchmark fails or Duel
30
+ couldn't compare. Set `c.comment = :always` for the old behavior.
31
+ - The workflow never edits a comment. It used to update one comment on
32
+ every push, and now posts a new one each time it comments, so the pull
33
+ request keeps every report. Each comment's hidden marker records the
34
+ run's status (`<!-- duel status=failed -->`, say). When a run wouldn't
35
+ get a comment of its own, the workflow still posts one if its last
36
+ comment on the pull request had another status, so after a failure the
37
+ first run that passes says so, and the last comment is never stale. A
38
+ comment from an earlier version, whose marker has no status, counts
39
+ as one with another status.
40
+
41
+ - A change to only the benchmarks or the configuration is compared. Duel
42
+ watches what decides how the code is measured, as well as the code
43
+ that `c.watch` names: the benchmarks' directory (`c.benchmarks` up to
44
+ its first wildcard, `duel/` by default), the configuration's file
45
+ (`.duel.rb`), and, with `c.ruby = :version_file`, the version files at
46
+ the project's root. Each version runs the candidate's benchmarks and
47
+ configuration, so such a change compares the code with itself, which
48
+ checks that they run and pass, where before Duel passed saying there
49
+ was nothing to compare. "Nothing to compare" messages, and the
50
+ candidate's `uncommitted_changes` in JSON, cover them too.
51
+ - The Markdown report (`--markdown`, the job's summary and the pull
52
+ request comment) is shorter. Its heading starts with 🟢 when every
53
+ benchmark passed (`🟢 Duel: 100% of benchmarks passed`), 🔻 when one
54
+ failed or Duel couldn't compare (`🔻 Duel: 3 benchmarks failed`), and
55
+ ⬜ when there's nothing to compare. One line names the two commits,
56
+ bare, so GitHub links them, and how the baseline was chosen. Only the
57
+ benchmarks that failed are in view, in a table of what got worse and
58
+ by how much, or the error, and where the most extra CPU time went.
59
+ Everything else is collapsed, and the report no longer names the
60
+ plugins or explains how Duel decides outside the collapsed details.
61
+ The terminal and JSON reports are unchanged.
62
+
63
+ ### Security
64
+
65
+ - The workflow that `duel init` writes no longer gives the job that runs
66
+ a pull request's code the `pull-requests: write` permission. The Duel
67
+ job only reads (`contents: read`, and its checkout keeps no
68
+ credentials), and keeps the report as an artifact for a day. A
69
+ Comment job of its own, which checks nothing out and runs none of the
70
+ pull request's code, downloads the report and comments with it, for a
71
+ pull request from the same repository.
72
+
73
+ ### Fixed
74
+
75
+ - With `c.ruby = :version_file`, the workflow installs the bundle for
76
+ the baseline's Ruby too, when it differs from the candidate's. The
77
+ baseline's workers load that bundle, and gems installed for one Ruby
78
+ version don't load on another, so a branch that changed the Ruby's
79
+ minor version failed before it compared anything.
80
+
81
+ - With `c.ruby = :version_file`, the workflow that `duel init` writes
82
+ installs the baseline's Ruby for every event that compares, not only
83
+ for a pull request. It finds the baseline that the Compare step uses
84
+ (where HEAD left the main branch for a pull request and a manual run,
85
+ the commit before a push, and the last release on a schedule, with
86
+ the release tags' glob in `RELEASE_TAGS`), and installs its Ruby only
87
+ when its `.ruby-version` names another Ruby than HEAD's. Its scripts
88
+ take the workflow's values from environment variables, not `${{ }}`.
89
+
10
90
  ## [0.1.0] - 2026-10-24
11
91
 
12
92
  The first release. Duel is open source under the MIT license, and
@@ -277,5 +357,6 @@ needs Ruby 3.4 or later (or JRuby 10, or TruffleRuby 40).
277
357
  Anything marked `@api private`, in lib/ and sig/, may change in any
278
358
  release.
279
359
 
280
- [Unreleased]: https://github.com/sferik/duel-ruby/compare/v0.1.0...HEAD
360
+ [Unreleased]: https://github.com/sferik/duel-ruby/compare/v0.1.1...HEAD
361
+ [0.1.1]: https://github.com/sferik/duel-ruby/compare/v0.1.0...v0.1.1
281
362
  [0.1.0]: https://github.com/sferik/duel-ruby/releases/tag/v0.1.0
data/README.md CHANGED
@@ -227,9 +227,10 @@ Duel::RakeTask.new(:perf_since_release, "--base", "last-release")
227
227
  `duel init` writes a GitHub Actions workflow,
228
228
  `.github/workflows/perf.yml`. It compares each pull request with the
229
229
  main branch, each push to `main` with the commit before it, and, on a
230
- schedule, your last release, to catch slow creep. It also comments on
231
- each pull request with Duel's report. For the workflow, and for a branch
232
- that upgrades Ruby, see [Run Duel in CI](docs/ci.md).
230
+ schedule, your last release, to catch slow creep. It also comments on a
231
+ pull request with Duel's report when a benchmark fails. For the
232
+ workflow, and for a branch that upgrades Ruby, see
233
+ [Run Duel in CI](docs/ci.md).
233
234
 
234
235
  ### Report on a pull request
235
236
 
@@ -242,28 +243,27 @@ comment, use `--markdown`:
242
243
  duel --markdown tmp/duel.md
243
244
  ```
244
245
 
245
- The report starts with the verdict and the two versions that Duel
246
- compared, with each version's Ruby when they differ. A table lists the
247
- benchmarks that changed, regressions first, with each timing's ratio and
248
- its interval, and the counts that changed. Each regression is explained
249
- as it is in the terminal, with what its metrics said and where the extra
250
- CPU time went. Then come the benchmarks whose timings were too close to
251
- call, and the benchmarks that failed. The benchmarks that didn't change
252
- are collapsed, so the report stays short with hundreds of benchmarks:
246
+ The report is short. In view, it has the verdict, the two commits that
247
+ Duel compared (bare, so GitHub links them), and how the baseline was
248
+ chosen. When a benchmark fails, a table shows what got worse and by how
249
+ much, or the error, and the frame where the most extra CPU time went.
250
+ Everything else is collapsed: each regression explained as it is in the
251
+ terminal, every benchmark's timings, the timings too close to call, the
252
+ verdicts across the board, each version's Ruby when they differ, and
253
+ how Duel decides. So the report stays short with hundreds of
254
+ benchmarks:
253
255
 
254
256
  ````markdown
255
- ### Duel failed: 1 regression
257
+ ### 🔻 Duel: 1 benchmark failed
256
258
 
257
- - **Baseline:** `3f9c2a1`, where HEAD left `origin/main`
258
- - **Candidate:** `8e4d0b7`
259
+ Compared 8e4d0b7 with 3f9c2a1 (where HEAD left `origin/main`).
259
260
 
260
- 12 benchmarks in 14s. A timing is slower when a 99.0% confidence interval for its ratio, the candidate's to the baseline's, is above 1.02.
261
-
262
- | Benchmark | Verdict | Timings (ratio [interval]) | Counts an iteration |
263
- |---|---|---|---|
264
- | `parse: a large document` | **regressed** | instructions 1.071 [1.069, 1.073] slower<br>cpu 1.081 [1.062, 1.097] slower | allocations 5790 → 5791 |
261
+ | Benchmark | Failure | Most extra CPU in |
262
+ |---|---|---|
263
+ | `parse: a large document` | instructions +7.1%, cpu +8.1%, allocations 5790 → 5791 | `MyParser::Lexer#next_token` +6.8% |
265
264
 
266
- #### Regressions
265
+ <details>
266
+ <summary>Details: 12 benchmarks in 14s</summary>
267
267
 
268
268
  **`parse: a large document`**: worse instructions, cpu, allocations in every measurement
269
269
 
@@ -276,17 +276,32 @@ where the extra CPU went (as a share of the baseline's: in each frame itself, an
276
276
  +0.4% +0.4% String#byteslice
277
277
  ```
278
278
 
279
- <details>
280
- <summary>11 benchmarks unchanged</summary>
279
+ | Benchmark | Verdict | Timings (ratio [interval]) | Counts an iteration |
280
+ |---|---|---|---|
281
+ | `parse: a large document` | **regressed** | instructions 1.071 [1.069, 1.073] slower<br>cpu 1.081 [1.062, 1.097] slower | allocations 5790 → 5791 |
282
+ | `parse: a short line` | same | instructions 1.000 [1.000, 1.000]<br>cpu 1.002 [0.992, 1.017] | |
281
283
 
282
284
  ...
283
285
 
284
286
  </details>
285
287
  ````
286
288
 
289
+ When every benchmark passes, the heading says `🟢 Duel: 100% of
290
+ benchmarks passed`, and only the commits are in view. A timing too
291
+ close to the limit to call doesn't fail the run, and is in the details.
292
+ A benchmark that the baseline can't run (a new one, say) is skipped, and
293
+ the heading counts it. A run that has nothing to compare, or no release
294
+ to compare against, says so under ⬜, and one that couldn't compare
295
+ says why under 🔻.
296
+
287
297
  The workflow that `duel init` writes posts this report as a comment on
288
- the pull request, and updates the same comment on every push. For more
289
- information, see
298
+ the pull request when a benchmark fails, or when Duel couldn't compare.
299
+ After such a comment, the first run that passes posts one more, so the
300
+ last comment is never stale. It never edits a comment, so the pull
301
+ request keeps every report. To comment on every run, on every result
302
+ but nothing to compare, or never, set
303
+ [`c.comment`](docs/configuration.md#choose-when-to-comment-on-a-pull-request)
304
+ in `.duel.rb`. For more information, see
290
305
  [Comment on a pull request](docs/ci.md#comment-on-a-pull-request).
291
306
 
292
307
  ### Show that a project is guarded
data/docs/ci.md CHANGED
@@ -12,8 +12,8 @@ example, a series of changes that are each 1.5% slower would each pass.
12
12
  To catch that kind of slow creep, also compare against your last release
13
13
  on a schedule. The slowdowns add up there until they're a regression.
14
14
 
15
- The following GitHub Actions workflow does both, and reports on each
16
- pull request. `duel init` writes it for you, as
15
+ The following GitHub Actions workflow does both, and comments on a pull
16
+ request when a benchmark fails. `duel init` writes it for you, as
17
17
  `.github/workflows/perf.yml`:
18
18
 
19
19
  ```yaml
@@ -28,18 +28,25 @@ on:
28
28
  - cron: "0 4 * * *"
29
29
  workflow_dispatch:
30
30
 
31
+ # The Duel job runs the branch's code (its benchmarks too), so it only
32
+ # reads. Commenting is the Comment job's, which runs none of it.
31
33
  permissions:
32
34
  contents: read
33
- pull-requests: write # to comment on a pull request with the report
34
35
 
35
36
  jobs:
36
37
  duel:
37
38
  name: Duel
38
39
  runs-on: ubuntu-latest
40
+ # Whether the Comment job comments (post, change or none), and the
41
+ # run's status (passed, failed or neutral), from Duel.
42
+ outputs:
43
+ comment: ${{ steps.compare.outputs.comment }}
44
+ status: ${{ steps.compare.outputs.status }}
39
45
  steps:
40
46
  - uses: actions/checkout@v7
41
47
  with:
42
48
  fetch-depth: 0 # the baseline comes from history, tags included
49
+ persist-credentials: false
43
50
  # (And whatever else your tests need set up: packages, services.)
44
51
  - uses: ruby/setup-ruby@v1
45
52
  with:
@@ -47,9 +54,11 @@ jobs:
47
54
  bundler-cache: true
48
55
  # Against the main branch for a pull request, what was there before
49
56
  # for a push, and the last release on a schedule (slow creep). Duel
50
- # adds its report to the job's summary, and writes it to duel.md for
51
- # the comment.
57
+ # adds its report to the job's summary, writes it to duel.md for the
58
+ # comment, and sets the step's outputs to say whether to comment, as
59
+ # c.comment in .duel.rb says, whatever the outcome.
52
60
  - name: Compare
61
+ id: compare
53
62
  env:
54
63
  DUEL_BASE: ${{ github.event_name == 'schedule' && 'last-release' || github.event_name == 'push' && github.event.before || '' }}
55
64
  # (Run by hand, it compares anyway: with nothing changed, the code
@@ -63,23 +72,53 @@ jobs:
63
72
  exit 0
64
73
  fi
65
74
  bundle exec duel --markdown "$RUNNER_TEMP/duel.md"
66
- # The report, as a comment on the pull request: one comment, updated
67
- # on every push. (A pull request from a fork gets no comment, as its
68
- # token can't write one: the job's summary has the report.)
75
+ # The report, for the Comment job.
76
+ - name: Keep the report
77
+ if: ${{ !cancelled() && github.event_name == 'pull_request' }}
78
+ uses: actions/upload-artifact@v7
79
+ with:
80
+ name: duel-report
81
+ path: ${{ runner.temp }}/duel.md
82
+ if-no-files-found: ignore # none when Duel didn't get to compare
83
+ retention-days: 1
84
+
85
+ # The report, as a new comment on the pull request, when Duel says to:
86
+ # always (post), or only when its last comment's status differs from
87
+ # this run's (change), so the latest comment is never stale. It never
88
+ # edits or deletes a comment. This job checks nothing out and runs none
89
+ # of the branch's code, so it alone can write. (A pull request from a
90
+ # fork gets no comment, as its token can't write one: the Duel job's
91
+ # summary has the report.)
92
+ comment:
93
+ name: Comment
94
+ needs: duel
95
+ 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') }}
96
+ runs-on: ubuntu-latest
97
+ permissions:
98
+ pull-requests: write # to comment on the pull request with the report
99
+ steps:
100
+ - name: Get the report
101
+ id: report
102
+ continue-on-error: true # there's none when the Duel job failed before comparing
103
+ uses: actions/download-artifact@v8
104
+ with:
105
+ name: duel-report
106
+ path: ${{ runner.temp }}/report
69
107
  - name: Comment on the pull request
70
- if: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
108
+ if: steps.report.outcome == 'success'
71
109
  env:
72
110
  GH_TOKEN: ${{ github.token }}
73
111
  PR: ${{ github.event.pull_request.number }}
112
+ COMMENT: ${{ needs.duel.outputs.comment }}
113
+ STATUS: ${{ needs.duel.outputs.status }}
74
114
  run: |
75
- [ -f "$RUNNER_TEMP/duel.md" ] || exit 0
76
- marker="<!-- duel -->"
77
- { echo "$marker"; cat "$RUNNER_TEMP/duel.md"; } > "$RUNNER_TEMP/comment.md"
78
- ids=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR/comments" \
79
- --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"$marker\"))) | .id")
80
- if [ -n "$ids" ]; then
81
- gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/${ids%%$'\n'*}" -F body=@"$RUNNER_TEMP/comment.md" > /dev/null
82
- else
115
+ marker="<!-- duel"
116
+ # The first line of the last comment Duel posted here: its marker,
117
+ # with its status (none in Duel 0.1.0's, which counts as another).
118
+ last=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR/comments" \
119
+ --jq ".[] | select(.user.login == \"github-actions[bot]\") | .body | split(\"\n\")[0] | rtrimstr(\"\r\") | select(startswith(\"$marker status=\") or . == \"$marker -->\")" | tail -n 1)
120
+ if [ "$COMMENT" = post ] || { [ -n "$last" ] && [ "$last" != "$marker status=$STATUS -->" ]; }; then
121
+ { echo "$marker status=$STATUS -->"; cat "$RUNNER_TEMP/report/duel.md"; } > "$RUNNER_TEMP/comment.md"
83
122
  gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -F body=@"$RUNNER_TEMP/comment.md" > /dev/null
84
123
  fi
85
124
  ```
@@ -95,17 +134,61 @@ The workflow compares the following:
95
134
  - When you run the workflow manually, your code against itself. This
96
135
  checks that the benchmarks run on the CI machines.
97
136
 
137
+ Duel compares a change that touches your code (`lib/` or `ext/`, or what
138
+ `c.watch` names), your benchmarks, `.duel.rb`, or, with
139
+ `c.ruby = :version_file`, your version file. Each version runs your
140
+ branch's benchmarks and configuration, so a change to only those compares
141
+ the same code with itself, which checks that they run and pass. A change
142
+ to only the workflow has nothing to compare: to check it, run the
143
+ workflow manually.
144
+
98
145
  ## Comment on a pull request
99
146
 
100
147
  The workflow writes Duel's report
101
148
  ([Report on a pull request](../README.md#report-on-a-pull-request))
102
- to a file, and then comments on the pull request with it. It updates
103
- the same comment on every push, rather than adding one each time. It
104
- finds its comment by the hidden marker on the comment's first line, so
105
- give each workflow that comments a marker of its own. Commenting takes
106
- the `pull-requests: write` permission. A pull request from a fork gets
107
- no comment, because its token can't write one, but the job's summary
108
- still has the report.
149
+ to a file, and comments on the pull request with it when
150
+ [`c.comment`](configuration.md#choose-when-to-comment-on-a-pull-request)
151
+ says to. By default, it comments when a benchmark fails, or when Duel
152
+ couldn't compare. Whatever `c.comment` says, a regression fails the
153
+ check, because `duel` exits with a nonzero status, and the job's
154
+ summary has the report.
155
+
156
+ The workflow never edits or deletes a comment, so the pull request
157
+ keeps every report that it posted. Each comment starts with a hidden
158
+ marker that records the run's status: `<!-- duel status=passed -->`,
159
+ `status=failed` (a run that couldn't compare too), or `status=neutral`
160
+ (nothing to compare). When a run's outcome wouldn't get a comment of
161
+ its own, the workflow still posts one if it commented on the pull
162
+ request before, and its last comment's status differs from this run's.
163
+ With the default, after a comment that a benchmark failed, the first
164
+ push that passes gets one comment saying so, and the pushes after it
165
+ get none. So the last comment is never stale. A comment from Duel 0.1.0,
166
+ whose marker has no status, counts as a comment with another status.
167
+
168
+ The workflow finds its comments by the marker's start. For a project
169
+ in a subdirectory of its repository, the marker names the directory,
170
+ such as `<!-- duel gems/parser status=failed -->`, so each of a
171
+ monorepo's workflows has comments of its own.
172
+
173
+ Duel decides, because it knows the outcome and reads `.duel.rb`. On
174
+ GitHub Actions, the Compare step's `duel` sets two step outputs, which
175
+ the Duel job passes on as its own: `comment`, and `status` (`passed`,
176
+ `failed`, or `neutral`). `comment` is one of the following:
177
+
178
+ | `comment` | What the Comment job does |
179
+ |---|---|
180
+ | `post` | Posts a new comment. |
181
+ | `change` | Posts a new comment only if the pull request has one from the workflow, and the last one's status isn't this run's. |
182
+ | `none` | Nothing: the Comment job doesn't run. |
183
+
184
+ The Duel job runs the pull request's code, its benchmarks included, so
185
+ it only reads (`contents: read`), and it keeps the report as an
186
+ artifact for a day. A job of its own, Comment, posts the report. It
187
+ checks nothing out and runs none of the pull request's code, so it
188
+ alone gets the `pull-requests: write` permission that commenting takes.
189
+ When the Duel job fails before it compares, there's no report, and no
190
+ comment. A pull request from a fork gets no comment either, because its
191
+ token can't write one, but the Duel job's summary still has the report.
109
192
 
110
193
  ## Run each version on its own Ruby on CI
111
194
 
@@ -113,29 +196,70 @@ With [`c.ruby = :version_file`](configuration.md#run-each-version-on-its-own-rub
113
196
  each version runs on the Ruby that its own version file names. On CI, a
114
197
  setup step such as `ruby/setup-ruby` installs the Ruby that
115
198
  runs `duel` where no version manager looks. That's enough while both
116
- versions ask for that Ruby. A pull request that upgrades Ruby needs the
199
+ versions ask for that Ruby. A branch that upgrades Ruby needs the
117
200
  baseline's Ruby installed too, where Duel finds it. For example, for a
118
201
  project with a `.ruby-version`, in the
119
- [GitHub Actions workflow](#catch-slow-creep), install the main branch's
120
- Ruby with a second `ruby/setup-ruby` step, and link it into
121
- `~/.rubies`, where Duel looks for Rubies the way that chruby does.
122
- `duel init` adds these steps for a project that has a `.ruby-version`:
202
+ [GitHub Actions workflow](#catch-slow-creep), find the baseline that the
203
+ Compare step compares with, for each event: where `HEAD` left the main
204
+ branch for a pull request and a manual run, the commit before a push,
205
+ and the last release on a schedule. When its `.ruby-version` names
206
+ another Ruby than yours, install that Ruby with a second
207
+ `ruby/setup-ruby` step, with the bundle for it (`bundler-cache: true`),
208
+ and link it into `~/.rubies`, where Duel looks for Rubies the way that
209
+ chruby does. The baseline's workers load the same bundle as the
210
+ candidate's, and gems installed for one Ruby version don't load on
211
+ another, so without that bundle they can't load the code to compare. Without a baseline, such as for a
212
+ branch's first push, or before your first release, there's no Ruby to
213
+ install. The step finds the main branch and the last release the way
214
+ Duel does by default: if `.duel.rb` sets `c.main_branch` or
215
+ `c.release_tags`, set the step's `MAIN_BRANCH` or `RELEASE_TAGS` to
216
+ match. `duel init` adds these steps for a project that has a
217
+ `.ruby-version`:
123
218
 
124
219
  ```yaml
125
220
  # (Before the step that sets up the candidate's Ruby.)
221
+ # Each version runs on the Ruby its own .ruby-version names (see
222
+ # .duel.rb). When the baseline's names another Ruby than this code's,
223
+ # that one is installed too, and linked where Duel finds it (as
224
+ # chruby would), before the project's own Ruby, which runs Duel. The
225
+ # baseline is the Compare step's: where HEAD left the main branch
226
+ # (for a pull request, and run by hand), the commit before a push, or
227
+ # on a schedule the last release, the newest tag before HEAD that
228
+ # RELEASE_TAGS matches. (If .duel.rb sets c.main_branch or
229
+ # c.release_tags, set MAIN_BRANCH or RELEASE_TAGS to match.)
126
230
  - name: Find the baseline's Ruby
127
231
  id: baseline
128
- if: github.event_name == 'pull_request'
129
- run: echo "ruby=$(git show "origin/$GITHUB_BASE_REF:.ruby-version")" >> "$GITHUB_OUTPUT"
232
+ env:
233
+ BEFORE: ${{ github.event.before }}
234
+ MAIN_BRANCH: origin/${{ github.event.repository.default_branch }}
235
+ RELEASE_TAGS: "v*"
236
+ run: |
237
+ case "$GITHUB_EVENT_NAME" in
238
+ push) base=$BEFORE ;;
239
+ schedule) base=$(git describe --tags --abbrev=0 --match "$RELEASE_TAGS" HEAD^ 2>/dev/null) || base= ;;
240
+ *) base=$(git merge-base HEAD "$MAIN_BRANCH" 2>/dev/null) || base= ;;
241
+ esac
242
+ ruby_of() { git show "$1:./.ruby-version" 2>/dev/null | awk 'NF { print $1; exit }'; }
243
+ ruby=
244
+ # (None without a baseline: a branch's first push, or no release yet.)
245
+ if [ -n "$base" ] && git cat-file -e "$base^{commit}" 2>/dev/null; then
246
+ ruby=$(ruby_of "$base")
247
+ [ "$ruby" = "$(ruby_of HEAD)" ] && ruby=
248
+ fi
249
+ echo "ruby=$ruby" >> "$GITHUB_OUTPUT"
130
250
  - uses: ruby/setup-ruby@v1
131
251
  id: baseline-ruby
132
- if: github.event_name == 'pull_request'
252
+ if: steps.baseline.outputs.ruby != ''
133
253
  with:
134
254
  ruby-version: ${{ steps.baseline.outputs.ruby }}
255
+ # The bundle for this Ruby too, which the baseline's workers load.
256
+ # (Gems installed for one Ruby version don't load on another.)
257
+ bundler-cache: true
135
258
  - name: Link the baseline's Ruby where Duel finds it
136
- if: github.event_name == 'pull_request'
259
+ if: steps.baseline.outputs.ruby != ''
260
+ env:
261
+ PREFIX: ${{ steps.baseline-ruby.outputs.ruby-prefix }}
137
262
  run: |
138
- prefix="${{ steps.baseline-ruby.outputs.ruby-prefix }}"
139
263
  mkdir -p ~/.rubies
140
- ln -sfn "$prefix" ~/.rubies/ruby-"$("$prefix/bin/ruby" -e 'print RUBY_VERSION')"
264
+ ln -sfn "$PREFIX" ~/.rubies/ruby-"$("$PREFIX/bin/ruby" -e 'print RUBY_VERSION')"
141
265
  ```
@@ -2,19 +2,21 @@
2
2
 
3
3
  Most gems don't need any configuration: Duel's conventions cover them.
4
4
  Benchmarks are in `duel/**/*_duel.rb`, each version's `lib/` is first on
5
- the load path, a change is compared when it touches `lib/` or `ext/`,
6
- and releases are tagged `v*`. If your project differs, create a `.duel.rb` file
7
- at its root. Both the runner and every worker load it:
5
+ the load path, a change is compared when it touches `lib/` or `ext/`
6
+ (or the benchmarks, or `.duel.rb`), and releases are tagged `v*`. If
7
+ your project differs, create a `.duel.rb` file at its root. Both the
8
+ runner and every worker load it:
8
9
 
9
10
  ```ruby
10
11
  Duel.configure do |c|
11
12
  c.benchmarks = "perf/**/*_bench.rb" # default: duel/**/*_duel.rb
12
- c.watch = %w[lib ext] # what a change must touch to be compared
13
+ c.watch = %w[lib ext] # the code a change must touch to be compared
13
14
  c.library = "my_gem" # required from each version (default: the gemspec's)
14
15
  c.build = "make" # for both versions (default: rake compile, with ext/)
15
16
  c.main_branch = "origin/trunk"
16
17
  c.release_tags = "release-*" # default: v*
17
18
  c.ruby = :version_file # each version's own Ruby (default: the runner's)
19
+ c.comment = :results # when CI comments on a pull request (default: :failures)
18
20
  c.use :redis # a plugin (see Use plugins)
19
21
 
20
22
  # How a worker loads one version of your code, instead of the library.
@@ -28,6 +30,66 @@ directory. The configuration also takes hooks, such as
28
30
  `c.before_sample`. See
29
31
  [Run code around a benchmark](benchmarks.md#run-code-around-a-benchmark).
30
32
 
33
+ ## Choose what a change must touch to be compared
34
+
35
+ When nothing that Duel watches changed since the baseline, there's
36
+ nothing to compare, and Duel passes without running the benchmarks.
37
+ `c.watch` names your code: `lib/` and `ext/` by default. Duel also
38
+ watches what decides how your code is measured:
39
+
40
+ - The benchmarks' directory: the part of `c.benchmarks` before its first
41
+ wildcard, `duel/` by default, with its helpers and fixtures.
42
+ - The configuration's file, `.duel.rb`.
43
+ - With [`c.ruby = :version_file`](#run-each-version-on-its-own-ruby),
44
+ the version files at your project's root (`.ruby-version`,
45
+ `.tool-versions`, `mise.toml`, or `.mise.toml`).
46
+
47
+ Each version runs your branch's benchmarks and configuration, so a
48
+ change to only those compares your code with itself. That checks that
49
+ the changed benchmarks run, and pass on identical code, instead of
50
+ passing without running them. To compare a change to anything else, such
51
+ as your CI workflow, add it to `c.watch`:
52
+
53
+ ```ruby
54
+ Duel.configure do |c|
55
+ c.watch += %w[.github/workflows/perf.yml]
56
+ end
57
+ ```
58
+
59
+ ## Choose when to comment on a pull request
60
+
61
+ The [GitHub Actions workflow](ci.md#comment-on-a-pull-request) that
62
+ `duel init` writes comments on a pull request with Duel's report.
63
+ `c.comment` chooses when, and `duel --comment` overrides it:
64
+
65
+ | Value | When the workflow comments |
66
+ |---|---|
67
+ | `:always` | On every run, whatever the outcome. |
68
+ | `:results` | When the run passed or failed, but not when there was nothing to compare. |
69
+ | `:failures` | When a benchmark failed, or Duel couldn't compare. This is the default. |
70
+ | `:never` | Never. |
71
+
72
+ ```ruby
73
+ Duel.configure do |c|
74
+ c.comment = :results
75
+ end
76
+ ```
77
+
78
+ The workflow never edits a comment: each one is new, so the pull
79
+ request keeps them all. Once the workflow has commented on a pull
80
+ request, it also comments on a run that it otherwise wouldn't, except
81
+ with `:never`, when the run's status (passed, failed, or nothing to
82
+ compare) differs from its last comment's. So with `:failures`, after a
83
+ comment that a benchmark failed, the first run that passes posts a
84
+ comment saying so, and the runs after it post nothing. The last comment
85
+ is never stale.
86
+
87
+ With `:never`, a regression still fails the check, because `duel` exits
88
+ with a nonzero status, and the job's summary still has the report.
89
+
90
+ `c.comment` takes only these four values: any other fails the run, and
91
+ says which values it takes.
92
+
31
93
  ## Prepare each worker
32
94
 
33
95
  Each worker can get things of its own, such as a server, so that the
data/docs/how-it-works.md CHANGED
@@ -169,8 +169,10 @@ that small. If Duel can't write next to your project, or something else
169
169
  already has that name, it uses `tmp/duel/base` instead. Whenever the two
170
170
  versions' paths differ in length, Duel says so.
171
171
 
172
- If nothing under `lib/` or `ext/` changed since the baseline, there's
173
- nothing to compare, and Duel says so. To compare anyway, use `--force`.
172
+ If nothing under `lib/` or `ext/`, your benchmarks, or `.duel.rb`
173
+ changed since the baseline, there's nothing to compare, and Duel says
174
+ so. To compare anyway, use `--force`. See
175
+ [Choose what a change must touch to be compared](configuration.md#choose-what-a-change-must-touch-to-be-compared).
174
176
 
175
177
  Duel also builds the candidate before every run. Builds are incremental,
176
178
  so a build that's already done costs little. A stale or missing build,