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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: da30e26518ff9b626552115d24595276b5f6f371cc4cd9c614b126666a065898
|
|
4
|
+
data.tar.gz: d863cf940c61196483a7521ec8066385e4b2cc9071ec00c96bc6f794c266342c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
155
|
-
the code with itself and must pass. Run it three times: a
|
|
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`,
|
|
172
|
-
|
|
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
|
|
179
|
-
which comments on a pull request with Duel's report
|
|
180
|
-
the report to the job's summary
|
|
181
|
-
|
|
182
|
-
`
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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.
|
|
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
|
-
|
|
232
|
-
that upgrades Ruby, see
|
|
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
|
|
246
|
-
compared,
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
257
|
+
### 🔻 Duel: 1 benchmark failed
|
|
256
258
|
|
|
257
|
-
|
|
258
|
-
- **Candidate:** `8e4d0b7`
|
|
259
|
+
Compared 8e4d0b7 with 3f9c2a1 (where HEAD left `origin/main`).
|
|
259
260
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
280
|
-
|
|
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
|
|
289
|
-
|
|
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
|
|
16
|
-
|
|
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,
|
|
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,
|
|
67
|
-
|
|
68
|
-
|
|
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:
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
--jq ".[] | select(.user.login == \"github-actions[bot]\"
|
|
80
|
-
if [ -n "$
|
|
81
|
-
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
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),
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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:
|
|
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:
|
|
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 "$
|
|
264
|
+
ln -sfn "$PREFIX" ~/.rubies/ruby-"$("$PREFIX/bin/ruby" -e 'print RUBY_VERSION')"
|
|
141
265
|
```
|
data/docs/configuration.md
CHANGED
|
@@ -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
|
|
7
|
-
at its root. Both the
|
|
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] #
|
|
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
|
|
173
|
-
nothing to compare, and Duel says
|
|
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,
|