feat(cargo-anvil): benchmark regression detection via cargo-bench-history - #68
feat(cargo-anvil): benchmark regression detection via cargo-bench-history#68martin-kolinek wants to merge 35 commits into
Conversation
…ion detection Captures the design for wrapping cargo-bench-history as a cargo-anvil capability: scheduled collect+analyze with CI-artifact/cache history persistence, fail the scheduled build on an active regression (reusing native GitHub/ADO failure notifications) rather than PR comments, and a reviewed bless-via-file workflow to accept intentional changes. Cross-backend from one catalog; PR stays compile-only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a3ecc845-0526-499a-a233-6b82909c4517
Codecov Report✅ All modified and coverable lines are covered by tests. ❌ Your project status has failed because the head coverage (97.7%) is below the target coverage (100.0%). You can increase the head coverage or adjust the target coverage. Additional details and impacted files@@ Coverage Diff @@
## main #68 +/- ##
=====================================
Coverage 97.7% 97.7%
=====================================
Files 286 286
Lines 62460 62487 +27
=====================================
+ Hits 61032 61061 +29
+ Misses 1428 1426 -2
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Add docs/design/benchmarks.md describing the capability as part of the opinionated baseline: cbh as the detection engine, regression detection as a scheduled concern, history as CI-native cross-run state (rolling-window default, opt-in durable blob), fail-the-scheduled-build surfacing via native GitHub/ADO notifications, and bless-via-reviewed-file to accept intentional changes. Register it in the design index and the check catalog, and point implementation plan 0003 at it as the design companion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a3ecc845-0526-499a-a233-6b82909c4517
…nd design docs Clarify that the cbh history store is CI-native build ARTIFACTS, not the build cache (which stays scoped to tool/dep acceleration). Add a "Benchmark regression detection" section to github.md (Actions artifacts: download-latest-from-default-branch -> collect/analyze -> upload; fail-the-build -> update-in-place tracking issue) and to ado.md (Pipeline Artifacts via the existing §4.1 job-wrapper `artifacts` contract + DownloadPipelineArtifact latestFromBranch; fail-the-build -> native failed-build notifications; one-bit-status limitation noted). Tighten benchmarks.md to name artifacts precisely. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a3ecc845-0526-499a-a233-6b82909c4517
…enchmark design Remove contrastive "artifacts not the cache / separate on purpose" framing, the "validated / to observe" and "cbh is young" status notes, and the "out of scope for the first version" roadmap line. The design docs now state the end state (history persists as build artifacts; the one-bit status limitation is a property) without justifying-against-alternatives or recording decisions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a3ecc845-0526-499a-a233-6b82909c4517
… and catalog Give benchmark regression detection its own scheduled group (isolating the history artifact round-trip and fail-on-regression from the other exhaustive work). Update the checks.md tier/group/check flowchart and its scheduled-tier table (now 5 groups), the github.md and ado.md scheduled-pipeline diagrams and emitted-artifact file trees, and name the group + check (bench-history) in benchmarks.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a3ecc845-0526-499a-a233-6b82909c4517
…e plan Strip the surfacing/persistence/bless/caveats prose from implementation plan 0003 (all of it now lives in design/benchmarks.md and the backend §12 sections) and rewrite it as sequencing only: principles plus six phases that reference the design rather than restating it. Add a "History horizon" boundary to benchmarks.md §7 so the design fully owns the caveats. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a3ecc845-0526-499a-a233-6b82909c4517
… blob a non-goal Artifacts are the only supported history store: state it as such in benchmarks.md §4 and reduce cbh's own durable backends (e.g. Azure Blob) to an explicit non-goal; reframe the §8 history-horizon caveat accordingly and drop plan Phase 6. Add a §6 "Local behavior" section: the recipe is identical locally but has no shared history, so local analysis is a friendly no-op; regression detection is a scheduled/shared concern and the self-contained failure surface (finding + cbh's trend chart) is the interface, with local reproduction a documented manual escape hatch. Note the chart in §5 so the surface is self-contained. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a3ecc845-0526-499a-a233-6b82909c4517
List the anvil-scheduled-benchmarks group recipe (anvil-bench-history) in local.md's groups.just and the anvil-scheduled tier aggregator, and note that the group runs the same recipe locally but needs the shared CI history for regression analysis (pointing to benchmarks.md section 6 rather than duplicating it). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a3ecc845-0526-499a-a233-6b82909c4517
Wire cargo-bench-history into the catalog as a new `bench-history` check in its own `scheduled-benchmarks` group. The check runs the workspace benchmarks, records them into a history that round-trips through CI-native build artifacts, applies committed blessings, analyzes the accumulated series, and exits non-zero on an active regression. Both backends carry the history and the failure surface: GitHub restores from the newest scheduled run carrying the leg artifact and files a create-or-update tracking issue on failure; ADO restores via DownloadPipelineArtifact and publishes through the job wrapper, relying on native failed-build notifications plus the build summary. Restore and publish are both indifferent to the run outcome, so samples taken while the pipeline is red survive. The ADO job wrapper gains a `fetchDepth` parameter, since the analysis walks the commit graph and needs a full checkout. Also fix the actionlint schema test, which silently skipped whenever the tool was installed because its fixture was not a git repository. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a3ecc845-0526-499a-a233-6b82909c4517
Only .anvil.lock conflicted; it is generated, so it was resolved by re-running cargo anvil over the merged tree. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a3ecc845-0526-499a-a233-6b82909c4517
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a3ecc845-0526-499a-a233-6b82909c4517
Adopts two invariants #62 introduced, applying them to the new benchmark artifacts: the "GENERATED BY cargo-anvil" header on owned files, and [script("pwsh", "-NoProfile")] on emitted recipes. The checks.md line stating that benchmark execution is deliberately outside the catalog is superseded by the scheduled-benchmarks group and now describes why the `bench` check stays compile-only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a3ecc845-0526-499a-a233-6b82909c4517
…uled publisher PR #65 adds a generic publish-failure job that upserts one incident issue for any failing scheduled group. The benchmark-specific issue step added here would have filed a second notification for the same event, so it is removed: the group now just fails and leaves its findings on the build summary, and reporting happens the same way it does for every other scheduled failure. The job keeps `actions: read` for the history-artifact restore; `issues: write` is no longer needed on either the impl or the root workflow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a3ecc845-0526-499a-a233-6b82909c4517
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a3ecc845-0526-499a-a233-6b82909c4517
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a3ecc845-0526-499a-a233-6b82909c4517
| displayName: Restore ${{ parameters.artifact }} | ||
| # The first run has no artifact to restore; a missing artifact is a | ||
| # cold start, not a failure. | ||
| continueOnError: true |
There was a problem hiding this comment.
🤖: The ADO restore can cold-start the store while a usable history exists, and cannot tell that apart from a genuine first run. Two mechanisms compound:
continueOnError: truetolerates every failure — authorization, service errors, malformed or partial downloads — not just the missing-artifact case the comment above describes.buildVersionToDownload: latestFromBranch(:30) selects the latest eligible run and downloadsartifactNamefrom that one run; it does not walk back to the newest run that actually carries the artifact. A cancelled or never-publishing latest run therefore yields nothing, even though an older run has the history. That contradicts this template's own comment at :32-35 ("the newest run that carries the artifact"), the rolling chain promised inbenchmarks.md:76-86, and the GitHub side, which does loop.
Either way the benchmark leg then analyzes an empty store, reports no regression, and the wrapper's artifacts contract publishes that empty store as the newest snapshot — silently truncating the chain and producing a false clean. Please resolve a run that actually has the per-leg artifact (as GitHub does), and tolerate only a positively identified missing artifact; other restore failures should fail the job and prevent publication of a replacement snapshot.
There was a problem hiding this comment.
🤖 Both mechanisms fixed; the template no longer uses DownloadPipelineArtifact@2 at all.
You are right that latestFromBranch resolves a single build and does not walk, which contradicted the template's own comment and the GitHub side. The step now walks the build list itself (queryOrder=finishTimeDescending, newest first, outcome-agnostic) and, for each build, queries the artifacts endpoint. A 404 means "this build has no such artifact" and the walk continues; any other status fails the job rather than being read as absence.
continueOnError: true is gone, replaced by that explicit classification. Publishing moved out of the wrapper's artifacts contract into a bench-history-publish.yml step conditioned on ne(variables['ANVIL_BENCH_RESTORE'], ''), so a failed restore cannot truncate the chain. Both backends now behave the same way, which is what the design claimed all along.
AI response generated by rocket
There was a problem hiding this comment.
🤖: Non-blocking — dropping DownloadPipelineArtifact@2 for an explicit walk addresses both mechanisms, and the 404-is-absence / anything-else-is-failure split is the right shape. The follow-on concern is coverage: this replaced a built-in task with a hand-written REST/download/unzip state machine, and the test still only asserts that status-code and variable strings appear in the template.
Please run the PowerShell body with mocked REST/download commands and cover: a 404 walking back to a valid older artifact; successful extraction setting restored; total absence setting cold-start; and non-404 query / download / archive-extraction failures exiting non-zero without setting ANVIL_BENCH_RESTORE. That last one is the branch the guarded publish depends on — it is what keeps a transient failure from overwriting a good chain with a truncated store, so it is worth pinning behaviourally rather than by string match.
There was a problem hiding this comment.
🤖 Added, with the same shape as the GitHub side.
ado_restore_separates_absence_from_failure extracts the pwsh: | body, substitutes the template parameters, and runs it with Invoke-RestMethod / Invoke-WebRequest / Expand-Archive shadowed by stand-ins (function definitions take precedence over cmdlets). Covered:
- a 404 on the newest build walking back to a valid older artifact;
- successful extraction setting
restored; - total absence setting
cold-start; - query (non-404), download, and archive-extraction failures each exiting non-zero without setting
ANVIL_BENCH_RESTORE.
That last case is asserted for all three fault points, since it is what the guarded publish depends on.
One thing worth recording: writing the mock exposed that PowerShell's -like treats ? as a single-character wildcard, so my first discriminator (*_apis/build/builds?*) also matched the artifact URI and the mock returned the build list for both. The test failed until I discriminated on /artifacts instead. The production template does not use -like on these URIs, so this was a fault in the test only — but it is the kind of thing that would have made a string-matching test pass while proving nothing.
AI response generated by rocket
There was a problem hiding this comment.
[Copilot speaking]
Explain or remove the silent missing-download-URL fallback
The restore logic carefully documents that only confirmed artifact absence may be treated as a cold start and that operational failures must fail closed. Immediately after that classification, however, if (-not $found.resource.downloadUrl) { continue } silently treats a successful artifact response with no download URL as if the build had no artifact. No comment or referenced service contract explains why this response shape is benign rather than malformed or operationally incomplete.
Reproducible reasoning: For each candidate build, the artifacts request either throws, returns a response without a download URL, or returns a downloadable artifact. The catch block explains the first case in detail: a not-found response means absence and every other failure stops the job. The second case bypasses that rule without explanation and continues the walk. If every candidate has that shape, control reaches Complete-Restore 'cold-start', even though the API did not positively report that the artifacts were absent. Azure's artifact response model describes resource.downloadUrl as the link used to download the resource, but the template does not identify a documented benign case where an artifact exists without it. This makes the intended invariant impossible to reconstruct from the code.
Consequence: A maintainer may preserve a fail-open branch that can turn incomplete service responses into a new history chain, or remove a deliberately supported edge case because its rationale is not recorded. Either choice risks silently losing benchmark continuity.
Recommended action: Make the classification explicit. If a returned artifact is expected to have a download URL, report an operational error and fail the restore when it is missing. If Azure DevOps has a documented benign response shape that omits the URL, add a comment and reference explaining that contract and why continuing the search cannot misclassify a restore failure as a cold start.
References:
- crates/cargo-anvil/docs/design/ado.md, scheduled benchmark restore
- Azure DevOps Build Artifacts REST API
Impacted locations:
crates/cargo-anvil/templates/ado/steps/bench-history-restore.yml:107-119
| } | ||
|
|
||
| #[test] | ||
| fn bench_history_gates_on_active_regressions_and_applies_blessings() { |
There was a problem hiding this comment.
🤖: The riskiest code in this PR has no execution coverage; the covered parts are the ones that could not plausibly be wrong. This test — and its siblings in ado.rs:396-424 / github.rs:274-297 — assert that selected command strings occur in the emitted template. That verifies registration and spelling, not behaviour. Nothing in the changeset ever runs the recipe: not the mini-TOML parser, not the blessing reconciliation, not the findings.json regression gate, not the store transition, not exit-code propagation.
Please add a functional scenario with the existing fake-cargo/fake-git harness (e.g. in tests/recipe_contracts.rs) covering the Phase 5 acceptance case: clean analysis → an active regression failing the recipe → applying a committed blessing → the next run green without re-appending. Inactive findings, improvements, and command-failure propagation are worth the same treatment.
This is also what 0003.md:69-70 promises ("A fixture repo exercises red → bless → green") and the plan ships in this PR, so as written it reads as satisfied when it isn't. Either land the fixture, or amend the plan to state what the intended verification actually is.
There was a problem hiding this comment.
🤖 Agreed — this was the real gap, and the string assertions were the least valuable part of the changeset.
Added to tests/recipe_contracts.rs, using the existing fake-cargo/fake-git harness (extended to stand in for cargo bench-history and for the commit resolution the reconciliation performs):
bench_history_gates_on_active_regressions_only— an active regression fails and names benchmark and attributed commit; an inactive finding and an improvement both stay green; an empty history is a clean no-op.bench_history_reports_without_gating_outside_ci— reports the finding but exits 0 locally.bench_history_propagates_tool_failure— a tool that fails to run is not "no regressions".bench_history_bless_reconciles_on_exact_prefix_identity— the boundary from your other comment.bench_history_bless_rejects_malformed_entries—#inside a value survives; unquoted values, missing keys and unexpected tables are rejected.
On 0003.md: rather than claim a fixture that does not exist, Phase 5 now describes the reconciliation that shipped. The red → bless → green transition is covered at the recipe level above; a full store-transition fixture would need a real cbh, which the harness deliberately avoids.
AI response generated by rocket
There was a problem hiding this comment.
[Copilot speaking]
Align the promised red-to-bless-to-green lifecycle coverage with the tests
Phase 5 still states that a fixture exercises the complete red → bless → green workflow, while the changed tests cover regression gating and blessing reconciliation independently. The public anvil-bench-history fixtures consume supplied analysis reports without carrying blessing state, and the reconciliation fixtures invoke _anvil-bench-history-bless directly. No scenario starts with an active regression, applies a committed blessing through the public recipe, verifies that the following analysis becomes green, and confirms that a later run does not re-append the blessing. The earlier follow-up said the plan had been narrowed, but the reviewed source still contains the lifecycle-fixture claim.
Reproducible reasoning: bench_history_gates_on_active_regressions_only and the related public-recipe tests feed fixed findings through FAKE_CBH_FINDINGS; their fake blessing command cannot change the report returned by a later analysis. The blessing tests instead call the private helper and inspect whether a cargo bench-history bless command was recorded, so they bypass the public recipe's ordering, store/file argument propagation, machine-key handling, and subsequent analyze/gate path. The embedded-recipe catalog test only checks for command fragments. These independent contracts can therefore remain green if the public recipe disconnects blessing application, moves it after analysis, passes incorrect identity or store arguments, or fails to make the next analysis green. That is weaker than the fixture lifecycle claimed in Phase 5 and in the prior thread's resolution.
Consequence: Reviewers and maintainers can treat the acceptance lifecycle as covered even though an integration defect could leave an intentionally accepted regression permanently red or cause its blessing to be appended repeatedly while all focused tests pass.
Recommended action: Make the verification artifacts describe one honest contract. Either add a stateful, hermetic fake-cargo fixture that drives anvil-bench-history through active regression failure, committed blessing application before analysis, a clean subsequent analysis, and another green run without re-appending; or revise Phase 5 and the pull-request validation claim to state that gating and reconciliation are tested separately. Narrowing the claim is the lower-cost option; add the lifecycle fixture if end-to-end store-transition coverage is an acceptance requirement.
References:
- Earlier lifecycle coverage discussion and claimed resolution
- crates/cargo-anvil/docs/implementation-plans/0003.md, Phase 5
- crates/cargo-anvil/tests/recipe_contracts.rs, benchmark-history contract tests
- crates/cargo-anvil/src/anvil/artifacts/justfile.rs, embedded recipe catalog test
- justfiles/anvil/checks/bench-history.just, public recipe ordering
- crates/cargo-anvil/docs/design/benchmarks.md
Impacted locations:
crates/cargo-anvil/docs/implementation-plans/0003.md:71-76crates/cargo-anvil/tests/recipe_contracts.rs:1539-1888crates/cargo-anvil/src/anvil/artifacts/justfile.rs:638-662justfiles/anvil/checks/bench-history.just:96-108
| $entries = New-Object System.Collections.Generic.List[object] | ||
| $current = $null | ||
| foreach ($rawLine in (Get-Content -LiteralPath $blessingsFile)) { | ||
| $line = ($rawLine -split '#', 2)[0].Trim() |
There was a problem hiding this comment.
🤖: Non-blocking — the accepted subset here is narrower than the name .toml promises, and one narrowing is a silent data-loss bug. ($rawLine -split '#', 2)[0] splits every line at the first # unconditionally, so a reason containing # — an issue or PR reference, which is exactly what one writes in a blessing rationale — is silently truncated rather than rejected. The regex also accepts escapes without decoding them, and the subset (double-quoted single-line values only, no single quotes, no inline tables) is stated only in this template's comment, not in the header of the emitted .config/bench-blessings.toml where its author will look.
Options, in rough order of preference: use a real parser; or name the file honestly for a format PowerShell parses natively (ConvertFrom-Json); or push it upstream — bless --from-file <toml> would remove the parser entirely and is worth asking cbh for at 0.0.9. If the hand-rolled parser stays, at minimum reject rather than truncate on # inside a quoted value, and document the subset in the emitted file's header.
There was a problem hiding this comment.
🤖 Fixed the data-loss half; kept the hand-rolled parser.
# is now only treated as a comment at line start, so reason = "accepted in #1234" survives intact — a test asserts it. The value pattern is "([^"\\]*)", so a value containing an escape is now rejected rather than accepted-and-not-decoded, which was the other quiet inaccuracy.
The subset is documented in the recipe header, and the error message on a malformed line states it explicitly rather than saying only "cannot parse".
I did not switch format or push it upstream. JSON would fix parsing at the cost of comments, which an audit file wants; a real TOML parser means another pinned tool for three string fields; bless --from-file is the right long-term answer but is a cbh change. Reasonable to revisit — flagging that I chose the narrow fix deliberately.
One thing I did not do: document the subset in the emitted .config/bench-blessings.toml header. anvil does not emit that file — it is created by the first person to bless. If you want anvil to emit a commented stub, that is a small addition worth doing.
AI response generated by rocket
There was a problem hiding this comment.
[Copilot speaking]
Publish a durable blessing-file schema and example
The supported fix-or-accept workflow depends on a user-authored .config/bench-blessings.toml, but public design documentation does not define the exact syntax. The parser accepts only [[blessing]] entries with required benchmark, commit, and reason fields, double-quoted single-line values without escapes, comments only on otherwise empty lines, and non-option-shaped benchmark and commit values. benchmark is interpreted as a prefix. The copyable recipe example also uses an abbreviated commit ID even though the file is a long-lived audit record.
Reproducible reasoning: Ordinary valid TOML forms such as single-quoted strings, escaped values, and inline comments are rejected by the deliberately narrow parser. Users must also know that one benchmark value may match a family. The recipe claims the subset is documented in an emitted file header, but cargo-anvil does not emit that file. Finally, git rev-parse --verify accepts an abbreviated object name only while the prefix remains unique; repository growth can make an unchanged committed entry ambiguous. These syntax and identifier rules are part of the user-authored configuration contract, not private parser details.
Consequence: An adopter can commit valid-looking TOML that fails only in the next scheduled run, misunderstand a family-wide blessing, or have a formerly valid abbreviated commit become ambiguous later.
Recommended action: Document the fixed path and supported grammar in benchmarks.md or a linked configuration page. Include a canonical [[blessing]] example, define the required fields and prefix semantics, list the supported quoting/comment restrictions and option-shaped-value rule, and recommend a full commit object ID for durable entries. Link the recipe header to that contract and remove its inaccurate emitted-header claim. Emitting a commented starter file is an alternative, but adds another generated artifact that adopters must own.
References:
- crates/cargo-anvil/docs/design/benchmarks.md, Accepting intentional changes: bless
- crates/cargo-anvil/docs/implementation-plans/0003.md, Phase 5
- Existing narrow-parser discussion
- Git rev-parse object-name disambiguation
Impacted locations:
crates/cargo-anvil/docs/design/benchmarks.md:156-169crates/cargo-anvil/templates/justfiles/anvil/checks/bench-history.just:154-185crates/cargo-anvil/templates/justfiles/anvil/checks/bench-history.just:278-283justfiles/anvil/checks/bench-history.just:154-185justfiles/anvil/checks/bench-history.just:278-283crates/cargo-anvil/tests/recipe_contracts.rs:1841-1886crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap:619-625
| # red from a regression — precisely the window that matters. | ||
| # The in-progress run of this very workflow has not uploaded | ||
| # yet, so it simply fails the download and the loop moves on. | ||
| for run_id in $(gh run list --workflow anvil-scheduled.yml \ |
There was a problem hiding this comment.
🤖: Non-blocking — two fragilities in this lookup, both cheap to remove. (1) The workflow is identified by the literal filename anvil-scheduled.yml, but the root scheduled workflow is an owned, renameable file; a rename yields a permanently empty history whose only symptom is the log line at :170 and a green job. gh run list --workflow accepts a name, and in a called reusable workflow github.workflow_ref / $GITHUB_WORKFLOW resolve to the caller — deriving the identity removes the hazard entirely. (2) --limit 10 means eleven consecutive scheduled runs without the artifact (a paused schedule, a run of infra failures, the 90-day retention lapsing) silently resets the series. Since the whole point is a long series, consider making a total restore miss visible — a line in $GITHUB_STEP_SUMMARY rather than only in the step log — so "history quietly restarted" is distinguishable from "no regressions".
There was a problem hiding this comment.
🤖 Both addressed.
(1) The workflow is identified by ${{ github.workflow }} — the caller's runtime name, since github.workflow resolves to the caller in a called reusable workflow — instead of the literal anvil-scheduled.yml. A rename no longer resets the series. A test asserts the literal filename is absent.
(2) --limit raised 10 → 30, and a total restore miss now writes a "Benchmark history: cold start" section to $GITHUB_STEP_SUMMARY naming the artifact, the window and the branch. "History quietly restarted" is now visibly distinct from "no regressions", which was the substantive half of your point.
AI response generated by rocket
There was a problem hiding this comment.
[Copilot speaking]
Align and justify the GitHub benchmark-history horizon
The restore action lists only the most recent 30 matching workflow runs before declaring a cold start, while the design promises the most recent default-branch run that carries the artifact and describes artifact retention as the GitHub history horizon. The upload retains artifacts for 90 days, but neither the design nor source comments explain how the run-count search limit and time-based retention were selected or are intended to relate.
Reproducible reasoning: gh run list --limit "$WINDOW" makes the run count an independent horizon. Manual dispatches, cancelled runs, or runs whose benchmark job never publishes can consume that window while an older artifact remains unexpired. Retention can instead be limiting when runs are infrequent. The fixed values therefore jointly determine recoverability, API work, and storage duration, but the documented contract attributes the horizon only to retention and provides no tolerated-gap or cost policy from which a maintainer can reproduce equivalent settings.
Consequence: A retained and usable history can be ignored after enough intervening runs, causing an avoidable visible cold start, while future changes to either value can alter API or storage cost without a documented policy.
Recommended action: Make the implementation and history contract agree, and record the selection policy. Either search every run that can still hold an unexpired artifact, or document the bounded run search as a separate horizon. In the bounded design, explain the tolerated missed-run or paused-schedule interval, acceptable API cost, and how the search depth and retention period should change together or remain independent. Reference that policy beside both source-template settings and regenerate the action and snapshot.
References:
- crates/cargo-anvil/docs/design/benchmarks.md, History as cross-run state
- crates/cargo-anvil/docs/design/github.md, Benchmark regression detection
- Existing restore-window discussion
Impacted locations:
crates/cargo-anvil/templates/github/run-group-action.yml:81crates/cargo-anvil/templates/github/run-group-action.yml:123-124crates/cargo-anvil/templates/github/run-group-action.yml:218-222.github/actions/anvil-run-group/action.yml:81.github/actions/anvil-run-group/action.yml:218-222crates/cargo-anvil/docs/design/benchmarks.md:80-110crates/cargo-anvil/docs/design/github.md:1112-1134crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap:481crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap:621
| } | ||
| Write-Host '' | ||
| Write-Host "Findings: $findingsMd" | ||
| Write-Host "Fix the regression, or accept it by adding an entry to $blessingsFile." |
There was a problem hiding this comment.
🤖: Non-blocking — the gate being identical locally and in CI means just anvil-scheduled / anvil-full now fails on laptop measurement noise. That parity is deliberate (benchmarks.md:116-124) and tiers.just:33-40 puts the group on the scheduled tier, which tiers.just documents as the pre-release check. A developer who has run it a few times across commits on a throttling laptop gets an "active regression" no shared history ever saw — and this message tells them to fix it by committing a blessing, polluting the reviewed, audited blessings file with laptop noise.
Parity is worth keeping for what the recipe does; I'd separate it from what the recipe gates on — e.g. always report locally but only exit non-zero under CI, or require an explicit opt-in for the local gate. Mechanism is yours; the outcome to avoid is "the pre-release recipe fails on measurement noise and the suggested remedy is a commit".
There was a problem hiding this comment.
🤖 Agreed, and changed — the "suggested remedy is a commit" framing is what made this convincing.
The gate is now CI-only: the recipe still runs the benches and writes findings identically everywhere, but exits non-zero only under CI (GitHub), TF_BUILD (ADO), or an explicit ANVIL_BENCH_GATE=1. A local run prints the findings, then says the numbers come from this machine and points at the opt-in.
benchmarks.md §6 now states the split and why: parity in what the recipe does, CI-only in what it gates on. bench_history_reports_without_gating_outside_ci pins it.
AI response generated by rocket
There was a problem hiding this comment.
[Copilot speaking]
Align every surface with one local benchmark-history contract
The recipe uses a machine-local history store and reports active regressions without failing unless CI markers or ANVIL_BENCH_GATE enable the gate. Several new or newly affected surfaces describe a different contract: the implementation plan says the check gates identically before cloud wiring, the canonical check catalog says it restores prior scheduled history and exits non-zero, the crate overview says checks behave identically locally and in cloud workflows, and local.md says regression analysis needs shared CI history. The local diagnostic then says it does not gate on “the shared trend” even though only local history was analyzed.
Reproducible reasoning: Without ANVIL_BENCH_WIRED_STORE, the recipe selects target/anvil/bench-history in the checkout, collects into that directory, and analyzes it. Test-Flag gates only when ANVIL_BENCH_GATE, CI, or TF_BUILD is enabled; otherwise the recipe prints the findings and exits successfully. Cloud jobs additionally restore and publish the shared artifact. The implementation plan, checks.md, crate-level documentation, local.md, and local message variously conflate the local store, shared CI history, analysis, and exit status, so a reader cannot derive one consistent local contract.
Consequence: A developer can treat a successful local run as equivalent to the scheduled gate, assume local analysis is unavailable, or commit a blessing for machine-specific noise because the documentation and diagnostic describe different inputs and failure behavior.
Recommended action: Choose one local contract and make the recipe, implementation plan, check catalog, crate overview, local design, and user-facing diagnostic agree. If the current recipe is intended, state that local runs analyze a gitignored machine-local store, always report, and fail only with the explicit gate opt-in; reserve “shared regression signal” for CI. If identical cloud behavior is intended instead, provide the shared history locally and change the gate and tests to match. Avoid saying a local run analyzed a shared trend unless it actually consumed that history.
References:
- crates/cargo-anvil/docs/design/benchmarks.md, Local behavior
- crates/cargo-anvil/docs/design/checks.md, scheduled-benchmarks catalog
- crates/cargo-anvil/docs/design/local.md, scheduled-benchmarks local behavior
- Existing discussion that introduced report-only local execution
Impacted locations:
crates/cargo-anvil/docs/implementation-plans/0003.md:19-20crates/cargo-anvil/docs/implementation-plans/0003.md:34-41crates/cargo-anvil/docs/design/checks.md:131crates/cargo-anvil/docs/design/checks.md:277crates/cargo-anvil/docs/design/local.md:152-154crates/cargo-anvil/src/lib.rs:92-98crates/cargo-anvil/src/lib.rs:322crates/cargo-anvil/templates/justfiles/anvil/checks/bench-history.just:16-33crates/cargo-anvil/templates/justfiles/anvil/checks/bench-history.just:131-149justfiles/anvil/checks/bench-history.just:145-149crates/cargo-anvil/docs/design/benchmarks.md:132-147crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap:490-497crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap:589-608
# Conflicts: # crates/cargo-anvil/README.md
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 51 out of 53 changed files in this pull request and generated 5 comments.
Suppressed comments (2)
justfiles/anvil/groups/scheduled-benchmarks.just:17
anvil-scheduled-benchmarksis defined as a plain dependency list, unlike other scheduled groups which route through_anvil-runand define a private_anvil-scheduled-benchmarksbody. As written, this group won’t match the catalog’s standard runner/impact wiring (and won’t work with_anvil-run-based invocation patterns used elsewhere).
crates/cargo-anvil/docs/implementation-plans/0003.md:60- AGENTS.md discourages referencing design-doc section numbers (e.g. “§4.1”) from other sources because it couples text to doc structure. This paragraph references “the §4.1 wrapper contract”; prefer describing it by name (e.g. “the job wrapper contract in steps/job.yml”) without a section number.
via the runs/artifacts API and downloading with `gh run download`;
`actions/upload-artifact` with retention, guarded on the restore outcome.
- **ADO** — full-history + LFS checkout as an explicit `checkout` step in the
group's own step list (the §4.1 wrapper contract stays frozen, since adopters
fork that file); a restore step resolving the build through the build/artifacts
REST API; a publish step carrying the same guard.
| The design for this capability is [../design/benchmarks.md](../design/benchmarks.md) | ||
| — with the catalog placement in [../design/checks.md](../design/checks.md) and the | ||
| backend wiring in [../design/github.md §12](../design/github.md) and | ||
| [../design/ado.md §12](../design/ado.md). This plan does not restate *what* the | ||
| capability is; it sequences *how* and *in what order* it lands. |
There was a problem hiding this comment.
🤖: Declining this one. AGENTS.md scopes the rule to source: "Do not cite design sections from rustdoc, code comments, or other source", and closes with "Link between design docs freely." The concern is coupling code to doc structure so the design cannot be reorganised without churning source.
An implementation plan is documentation, not source, and it is a transient record of one piece of work rather than something a reorganisation has to keep compiling. Happy to drop the anchors if you would rather read the rule as covering all prose, but as written it does not.
There was a problem hiding this comment.
[Copilot speaking]
Give benchmark regression detection a unique section number
The new benchmark chapter is numbered 12, but the immediately preceding advisory-comments chapter is already 12. This makes references to github.md §12 ambiguous; the implementation plan currently uses exactly that reference for the benchmark backend wiring.
Reproducible reasoning: The document has ## 12. Advisory PR comments at line 1035 and ## 12. Benchmark regression detection at line 1091. A reader following the implementation plan's github.md §12 reference cannot determine from the number which chapter is intended, and sequential navigation no longer reflects the document hierarchy.
Consequence: Cross-references and review discussion can direct readers to the advisory-comment design when they intend the benchmark backend contract, making the documented design harder to navigate and maintain.
Recommended action: Renumber the benchmark chapter to the next unique section number and update documentation references that identify it by number.
References:
- crates/cargo-anvil/docs/implementation-plans/0003.md
- Existing discussion about the implementation plan's GitHub section reference
Impacted locations:
crates/cargo-anvil/docs/design/github.md:1035crates/cargo-anvil/docs/design/github.md:1091crates/cargo-anvil/docs/implementation-plans/0003.md:5-6
# Conflicts: # .anvil.lock # crates/cargo-anvil/README.md # crates/cargo-anvil/docs/design/README.md # crates/cargo-anvil/src/anvil/artifacts/justfile.rs # crates/cargo-anvil/templates/justfiles/anvil/mod.just # crates/cargo-anvil/tests/recipe_contracts.rs # crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap # crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap # crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap # justfiles/anvil/mod.just
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 51 out of 53 changed files in this pull request and generated no new comments.
Suppressed comments (1)
justfiles/anvil/groups/scheduled-benchmarks.just:17
- This scheduled group recipe doesn't follow the established scheduled-group pattern of routing through
_anvil-unscoped(seescheduled-exhaustive.just), which ensuresANVIL_IMPACT=offis applied before dependencies run regardless of the caller environment. As written, this group can behave inconsistently with other scheduled groups and will also stay perpetually out of sync with the generated template (hence the.anvil-proposedsibling).
…arkers as boolean-like Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a3ecc845-0526-499a-a233-6b82909c4517
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a3ecc845-0526-499a-a233-6b82909c4517
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a3ecc845-0526-499a-a233-6b82909c4517
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 49 out of 51 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
crates/cargo-anvil/templates/ado/steps/bench-history-restore.yml:117
- In the ADO restore step, the
catchblock assumesInvoke-RestMethodalways throws an exception with.Response.StatusCode.value__. For network/DNS/TLS/auth failures, the exception may not have aResponse, causing a secondary null-reference error and hiding the real operational failure. Handle the no-response case explicitly and still fail closed (but with a useful message).
# common case while walking back. Anything else is an
# operational failure and must not be read as absence.
$status = $_.Exception.Response.StatusCode.value__
if ($status -eq 404) { continue }
Write-Error "anvil: querying artifacts of build $($run.id) failed with HTTP $status; refusing to continue with an empty history."
…story Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a3ecc845-0526-499a-a233-6b82909c4517
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 49 out of 51 changed files in this pull request and generated no new comments.
Suppressed comments (3)
Previously missed (3) — in code that hasn't changed since the last review.
.github/actions/anvil-run-group/action.yml:146
- Using
| head -n1inside aset -euo pipefailscript can fail the restore unexpectedly if more than one artifact row matches (duplicate artifact names are possible in a workflow run). Prefer making the--jqexpression return a single value so the command substitution is robust without a pipeline.
artifact_id=$(gh api --paginate \
"repos/$REPO/actions/runs/$run_id/artifacts" \
--jq ".artifacts[] | select(.name == \"$ARTIFACT\" and .expired == false) | .id" \
| head -n1)
crates/cargo-anvil/templates/github/run-group-action.yml:146
- Using
| head -n1underset -euo pipefailcan cause an unexpected failure if more than one artifact matches (duplicate artifact names can exist in a run). Make the jq expression select only the first id so the restore logic is robust without relying on a pipeline.
crates/cargo-anvil/templates/ado/steps/bench-history-restore.yml:118 - In the ADO restore walk, a non-HTTP failure (or an exception without
Response.StatusCode) will format asHTTPwith an empty status, which makes the error hard to diagnose. Consider explicitly handling a missing status code and emitting a clearer message for that case.
$status = $_.Exception.Response.StatusCode.value__
if ($status -eq 404) { continue }
Write-Error "anvil: querying artifacts of build $($run.id) failed with HTTP $status; refusing to continue with an empty history."
exit 1
Sander Saares (sandersaares)
left a comment
There was a problem hiding this comment.
[Copilot speaking]
Published 62 findings. 14 findings follow up on existing discussion threads.
See diagnostics
| Diagnostic | Value |
|---|---|
| Cache | Hit |
| sched_impl --> sbench_job["scheduled-benchmarks<br/>matrix: linux, windows"]:::job | ||
| sbench_job ==> sbench_act[".github/actions/<br/>anvil-scheduled-benchmarks"]:::action | ||
| sbench_act ==> sbench_setup[".github/actions/<br/>anvil-setup"]:::action | ||
| sbench_act ==> sbench_just["just anvil-scheduled-benchmarks"]:::recipe | ||
| sbench_setup ==> sbench_setup_just["just anvil-setup"]:::recipe |
There was a problem hiding this comment.
[Copilot speaking]
Align GitHub architecture documentation with the emitted workflow
The scheduled-pipeline diagram depicts scheduled-benchmarks as invoking a nonexistent .github/actions/anvil-scheduled-benchmarks action, while the emitted workflow invokes the shared .github/actions/anvil-run-group action. The diagram also omits the benchmark job's dependency on publish-failure. Separately, the shared-action narrative, sample, and input table do not document the benchmark-history inputs or the conditional restore, save, and summary lifecycle implemented by that action.
Reproducible reasoning: The source workflow and generated workflow pass group: scheduled-benchmarks, bench_history, bench_artifact, and bench_machine_key to anvil-run-group; the shared action owns restoration, group execution, artifact upload, and summary publication. The publisher lists scheduled-benchmarks in needs, so a regression reaches the existing failure issue. The design diagram instead introduces a dedicated benchmark action and no publisher edge, while the shared-action contract lists only its older inputs. These surfaces therefore describe different extension points and different failure-notification paths.
Consequence: Maintainers can customize or create the wrong action, omit required inputs, or accidentally disconnect benchmark failures from the scheduled failure publisher because the design and emitted contract teach different architectures.
Recommended action: Reconcile the design and emitted workflow around one architecture. If the shared-action implementation remains the intended model, route the diagram through anvil-run-group, add the benchmark-to-publish-failure edge, and document bench_history, bench_artifact, and bench_machine_key plus their restore/save/summary behavior. If a dedicated action is intended instead, emit and wire that action and update the tests and input contract accordingly. Label any abbreviated sample explicitly while still enumerating the complete contractual input surface.
References:
- crates/cargo-anvil/docs/design/github.md, scheduled pipeline diagram, Emitted artifacts, Shared group composite action, and Benchmark regression detection
- crates/cargo-anvil/templates/github/scheduled-impl-workflow.yml
- crates/cargo-anvil/templates/github/run-group-action.yml
- crates/cargo-anvil/src/anvil/artifacts/github.rs
- crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap
- .anvil.lock
Impacted locations:
crates/cargo-anvil/docs/design/github.md:139-163crates/cargo-anvil/docs/design/github.md:497-501crates/cargo-anvil/docs/design/github.md:582-590crates/cargo-anvil/docs/design/github.md:1091-1124.github/workflows/anvil-scheduled-impl.yml:172-190crates/cargo-anvil/templates/github/scheduled-impl-workflow.yml:172-190crates/cargo-anvil/templates/github/run-group-action.yml:37-58crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap:437-624crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap:1410-1417
| contents: read | ||
| # Restoring the history walks the Actions runs/artifacts API. An action | ||
| # cannot request permissions, so this has to be granted here. | ||
| actions: read |
There was a problem hiding this comment.
[Copilot speaking]
Align the permission summary with benchmark restoration
The reusable workflow says that after resetting ordinary jobs to contents: read, only the publisher's issue permission is restored. The new scheduled-benchmarks job also restores actions: read so the shared action can list and download prior benchmark-history artifacts.
Reproducible reasoning: The file-level comment describes the permission model for the whole reusable workflow. The benchmark job now adds actions: read, while publish-failure adds issues: write; the nearby benchmark comment explains why an action cannot request the former for itself. The high-level statement is therefore incomplete even though the job-level grants are explicit.
Consequence: A security or maintenance review that relies on the summary can overlook an Actions API capability that must be preserved for benchmark restoration.
Recommended action: Make the high-level summary and the workflow permission model agree. If Actions API restoration remains, describe the reset as followed by capability-specific job grants, including actions: read for benchmark restoration and issues: write for failure publication. If the publisher-only summary is the intended contract, redesign restoration so the benchmark job no longer needs that grant.
References:
- crates/cargo-anvil/docs/design/github.md, Benchmark regression detection
Impacted locations:
.github/workflows/anvil-scheduled-impl.yml:42-43.github/workflows/anvil-scheduled-impl.yml:159-162crates/cargo-anvil/templates/github/scheduled-impl-workflow.yml:42-43crates/cargo-anvil/templates/github/scheduled-impl-workflow.yml:159-162
| strategy: | ||
| fail-fast: false | ||
| matrix: | ||
| os: [linux, windows] |
There was a problem hiding this comment.
[Copilot speaking]
Document why scheduled benchmarks use only x86_64 legs
The new scheduled-benchmarks job selects only the Linux and Windows x86_64 matrix entries even though the reusable workflow exposes ARM runner inputs and neighboring scheduled jobs use all four OS/architecture legs. The design documents identify this as an intentional default matching scheduled-exhaustive, but neither the source template nor its generated workflow has an adjacent explanation or reference for omitting the ARM legs.
Reproducible reasoning: The matrix is a non-obvious policy decision rather than a syntactic necessity: ARM runners are available, while cargo-bench-history partitions measurements per machine, so adding architectures creates separate benchmark history chains and additional long-running jobs. Without a nearby design reference, a maintainer cannot distinguish the deliberate cost and coverage policy from an accidentally incomplete matrix or reliably keep the generated workflow and catalog policy aligned.
Consequence: A future maintainer may add or remove ARM benchmark legs without understanding the intended platform coverage, unexpectedly changing CI cost and machine-specific history series or leaving the documented matrices out of sync.
Recommended action: Add a concise comment above the source template's scheduled-benchmarks matrix stating that its default intentionally matches the x86_64-only scheduled-exhaustive coverage, reference the catalog design entry, note where adopters can customize the matrix when broader histories justify the cost, and regenerate the workflow.
References:
- crates/cargo-anvil/docs/design/README.md, scheduled-benchmarks catalog row and “Cross-OS test matrices”
- crates/cargo-anvil/docs/design/checks.md, scheduled group table and scheduled-benchmarks catalog row
Impacted locations:
.github/workflows/anvil-scheduled-impl.yml:152-156crates/cargo-anvil/templates/github/scheduled-impl-workflow.yml:152-157
| if: always() && inputs.bench_history == 'true' && env.ANVIL_BENCH_RESTORE != '' | ||
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | ||
| with: | ||
| name: ${{ inputs.bench_artifact }} | ||
| path: target/anvil/bench-history | ||
| # Comfortably longer than the scheduled cadence, so a paused or | ||
| # infrequent schedule does not break the chain. | ||
| retention-days: 90 |
There was a problem hiding this comment.
[Copilot speaking]
Align the durable-store claim with implemented support
The pull request description says the CI-artifact rolling window has an opt-in durable Azure Blob option for longer history. The reviewed GitHub and Azure DevOps workflows only restore and republish target/anvil/bench-history through CI-native artifacts, while the benchmark design says durable cargo-bench-history backends such as Azure Blob are outside cargo-anvil's supported scope.
Reproducible reasoning: The GitHub action uploads the local directory as an Actions artifact. Azure DevOps restores and publishes the same directory as a pipeline artifact. No reviewed surface selects an Azure Blob backend or supplies its endpoint, credentials, restore, or publication behavior. That implementation matches the design's explicit exclusion but not the pull request description's substantive capability claim.
Consequence: Reviewers and adopters can plan around a supported long-retention option that the generated workflows cannot configure, then lose history when CI artifact retention expires.
Recommended action: Reconcile the pull request description, benchmark design, and generated workflows without assuming which side is authoritative. Either describe CI-native artifacts as the only supported store in this change, or add and validate the claimed durable backend with explicit configuration, credential handling, documentation, and equivalent behavior on both workflow backends.
References:
- Pull request 68 description
- crates/cargo-anvil/docs/design/benchmarks.md, sections 4 and 8
- crates/cargo-anvil/docs/design/ado.md, Benchmark regression detection
Impacted locations:
.github/actions/anvil-run-group/action.yml:214-221crates/cargo-anvil/docs/design/benchmarks.md:88-111crates/cargo-anvil/docs/design/benchmarks.md:188-193crates/cargo-anvil/docs/design/ado.md:940-963crates/cargo-anvil/templates/justfiles/anvil/checks/bench-history.justcrates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap:617-630crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap:732-909crates/cargo-anvil/templates/ado/scheduled-stages.yml:127-140
| [[bench]] | ||
| name = "catalog" | ||
| harness = false |
There was a problem hiding this comment.
[Copilot speaking]
Exclude development-only sources from the published cargo-anvil package
cargo-anvil is publishable and has no package.include or package.exclude policy, so Cargo's default selection includes tracked development files beneath the crate root. This pull request adds the Criterion benchmark, adds roughly 830 lines of benchmark-history fixtures and contract tests, and enlarges generated snapshots. Those additions support repository development but are not needed to compile or run the published tool.
Reproducible reasoning: Declaring [[bench]] and dev-dependencies does not exclude their source files from the crate archive. With no file-selection fields, Cargo packages tracked benches/**, tests/**, and snapshots together with the runtime source and embedded templates. The pull request therefore increases the published archive with development-only material even though the package's runtime contract is unchanged.
Consequence: Future releases distribute and unpack benchmark code, large test fixtures, and generated snapshots for every registry consumer, and the archive keeps growing with repository-only validation content.
Recommended action: Add an explicit package file-selection policy that omits benches/**, tests/**, and other development-only content while retaining src/**, embedded runtime templates, required metadata, licenses, and package documentation. An allowlist gives the smallest archive but requires care whenever a compile-time asset is added; targeted excludes are less exact but lower-maintenance. Verify the chosen policy with cargo package --list and the repository's existing package checks.
References:
Impacted locations:
crates/cargo-anvil/benches/catalog.rs:1-43crates/cargo-anvil/Cargo.toml:4-16crates/cargo-anvil/Cargo.toml:42crates/cargo-anvil/Cargo.toml:49-51crates/cargo-anvil/tests/recipe_contracts.rs:1493-2283crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap
| actions: read | ||
| steps: | ||
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | ||
| with: |
There was a problem hiding this comment.
[Copilot speaking]
Align the benchmark checkout rationale with LFS behavior
The benchmark job enables Git Large File Storage (LFS) checkout, which replaces repository pointer files with their stored benchmark inputs, but the adjacent source comment explains only the full Git history setting. The backend design and resolved discussion say LFS is needed because benchmark fixtures or binaries may be LFS-tracked.
Reproducible reasoning: actions/checkout does not download LFS objects by default. This job executes the benchmark code and consumes its data, so removing lfs: true can leave pointer text where the benchmark expects the real file. The current block gives maintainers a durable reason for fetch-depth: 0 but no reason for the neighboring correctness-sensitive option, even though the earlier response said that rationale had been added.
Consequence: A maintainer can remove the apparently unexplained LFS download to reduce checkout cost, causing LFS-backed benchmark inputs to fail or measure the wrong content.
Recommended action: Make the option and documented contract agree. If benchmark inputs may be LFS-tracked, state that reason beside lfs: true or reference the backend design. If the capability is not intended to support such inputs, remove the option and revise the design and validation evidence accordingly.
References:
- crates/cargo-anvil/docs/design/github.md, “Benchmark regression detection”
- r3825519684
Impacted locations:
crates/cargo-anvil/templates/github/scheduled-impl-workflow.yml:163-169
| if ($wired) { | ||
| $wantPath = [System.IO.Path]::GetFullPath($wired) | ||
| $gotPath = [System.IO.Path]::GetFullPath($store) | ||
| if ($wantPath -ne $gotPath) { |
There was a problem hiding this comment.
[Copilot speaking]
Compare CI benchmark-store paths without assuming filesystem case rules
The store guard normalizes path syntax, then compares case-insensitively on Windows and case-sensitively elsewhere. Operating-system identity does not determine filesystem case behavior: Windows directories can be case-sensitive, and non-Windows volumes can be case-insensitive. The guard therefore can accept two distinct stores or reject two equivalent spellings while claiming to prove that collection writes into the directory CI restores and publishes.
Reproducible reasoning: GetFullPath resolves .. and relative syntax but does not determine whether case-variant names identify the same filesystem entry. On a case-sensitive Windows directory, -eq can accept different paths and detach collection from persistence. On a case-insensitive non-Windows volume, -ceq can reject one path spelled with different case. The CI override is documented as local-only and normally unnecessary, so the simplest fail-closed contract is to require one normalized spelling rather than infer equivalence. If alternate spellings must be accepted, equivalence has to be established from the actual volume or directory.
Consequence: A detached store can repeatedly lose prior samples and report a false clean; the opposite misclassification can fail a valid job solely because the volume differs from the operating-system default.
Recommended action: Prefer the simpler CI contract: when wiring is present, require ordinal equality after syntactic normalization and document that a CI store override must use the wired spelling exactly. This avoids any filesystem case inference. If accepting case-variant equivalent paths is a requirement, probe identity or case behavior at the store location or nearest existing parent and compare according to that result. Extend the contract test so its expected case behavior comes from the fixture filesystem rather than the operating system.
References:
- Benchmark history persistence design
- Existing history-path comparison review thread
- Microsoft Learn: Windows supports per-directory case sensitivity
- Apple Disk Utility: APFS volumes may be case-sensitive or case-insensitive
- Existing filesystem casing probe in crates/cargo-anvil/tests/container_upgrade.rs
Impacted locations:
crates/cargo-anvil/templates/justfiles/anvil/checks/bench-history.just:58-68crates/cargo-anvil/tests/recipe_contracts.rs:1710-1724crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap:1844-1848crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap:2132-2138crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap:520-525justfiles/anvil/checks/bench-history.just:61-68
| $entries = New-Object System.Collections.Generic.List[object] | ||
| $current = $null | ||
| foreach ($rawLine in (Get-Content -LiteralPath $blessingsFile)) { | ||
| $line = ($rawLine -split '#', 2)[0].Trim() |
There was a problem hiding this comment.
[Copilot speaking]
Publish a durable blessing-file schema and example
The supported fix-or-accept workflow depends on a user-authored .config/bench-blessings.toml, but public design documentation does not define the exact syntax. The parser accepts only [[blessing]] entries with required benchmark, commit, and reason fields, double-quoted single-line values without escapes, comments only on otherwise empty lines, and non-option-shaped benchmark and commit values. benchmark is interpreted as a prefix. The copyable recipe example also uses an abbreviated commit ID even though the file is a long-lived audit record.
Reproducible reasoning: Ordinary valid TOML forms such as single-quoted strings, escaped values, and inline comments are rejected by the deliberately narrow parser. Users must also know that one benchmark value may match a family. The recipe claims the subset is documented in an emitted file header, but cargo-anvil does not emit that file. Finally, git rev-parse --verify accepts an abbreviated object name only while the prefix remains unique; repository growth can make an unchanged committed entry ambiguous. These syntax and identifier rules are part of the user-authored configuration contract, not private parser details.
Consequence: An adopter can commit valid-looking TOML that fails only in the next scheduled run, misunderstand a family-wide blessing, or have a formerly valid abbreviated commit become ambiguous later.
Recommended action: Document the fixed path and supported grammar in benchmarks.md or a linked configuration page. Include a canonical [[blessing]] example, define the required fields and prefix semantics, list the supported quoting/comment restrictions and option-shaped-value rule, and recommend a full commit object ID for durable entries. Link the recipe header to that contract and remove its inaccurate emitted-header claim. Emitting a commented starter file is an alternative, but adds another generated artifact that adopters must own.
References:
- crates/cargo-anvil/docs/design/benchmarks.md, Accepting intentional changes: bless
- crates/cargo-anvil/docs/implementation-plans/0003.md, Phase 5
- Existing narrow-parser discussion
- Git rev-parse object-name disambiguation
Impacted locations:
crates/cargo-anvil/docs/design/benchmarks.md:156-169crates/cargo-anvil/templates/justfiles/anvil/checks/bench-history.just:154-185crates/cargo-anvil/templates/justfiles/anvil/checks/bench-history.just:278-283justfiles/anvil/checks/bench-history.just:154-185justfiles/anvil/checks/bench-history.just:278-283crates/cargo-anvil/tests/recipe_contracts.rs:1841-1886crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap:619-625
| # Process-scoped: two jobs sharing a machine (matrix legs on a self-hosted | ||
| # agent, or concurrent local runs) would otherwise race on one filename and | ||
| # read each other's listing. | ||
| $listJson = Join-Path $tmpDir "anvil-bench-blessings-$PID.json" |
There was a problem hiding this comment.
[Copilot speaking]
Make the process-scoping test concurrent and collision-sensitive
The test is presented as deterministic coverage for two blessing reconciliations sharing one temporary directory, and the resolved thread says it runs them concurrently. The implementation calls the blocking run_just helper in a for loop, so the first process exits before the second starts. Its fake list blessings --all responses also use the prefixes field, while production reconciliation reads only the concrete benchmark field from that command's window-shaped output.
Reproducible reasoning: Command::output() waits for each child, so no overlap reaches the listing write/read window. Reverting the $PID suffix to a fixed name still leaves sequential invocations isolated in time. Even with overlap, the current fixtures cannot make cross-consumption observable: $_.benchmark is null for both prefixes-only responses, so each invocation blesses regardless of which file it reads. A collision-sensitive setup needs alpha's own listing to contain concrete benchmark beta and beta's own listing to contain alpha; each should bless when it reads its own file, but skip if it reads the other's matching entry.
Consequence: The process-scoping suffix can regress to one shared filename while the test remains green, allowing overlapping jobs to read another process's blessing state and incorrectly skip or apply an acceptance entry.
Recommended action: Spawn both just children before waiting for either. Add a deterministic barrier in the fake list blessings path so both processes reach the shared write/read interval before either continues; do not rely on sleeps or timeouts. Use window-shaped benchmark fixtures arranged so swapping the files changes each process's decision, then assert each invocation applies only its own requested blessing and leaves one listing file per process.
Impacted locations:
crates/cargo-anvil/tests/recipe_contracts.rs:2220-2283
| # prefix test for the broader `foo`, so a committed blessing of | ||
| # `foo` would be skipped while still leaving the build red -- with | ||
| # a log line claiming it was already in effect. | ||
| $already = $applied | Where-Object { |
There was a problem hiding this comment.
[Copilot speaking]
Align persisted-store growth with the documented history horizon
The recipe appends immutable benchmark runs and blessing sidecars, and both workflow backends restore the previous store and upload the whole directory again. Artifact retention deletes older artifact snapshots but does not remove old objects copied into the newest snapshot. The design nevertheless calls retention a rolling history window, and the prefix-blessing discussion relies on that window to bound repeated sidecars.
Reproducible reasoning: collect --skip-existing adds a clean run for each new commit; blessing can add sidecars; no reviewed step invokes cargo bench-history prune or otherwise deletes aged objects. GitHub and Azure DevOps then publish the entire current store, carrying every old object into the newest artifact before older snapshots expire. The reconciliation queries deliberately scan back to 1970, consistent with an accumulating store. Consequently, transfer size, storage, and query work grow with the number of scheduled runs rather than with backend artifact retention.
Consequence: A long-lived adopter can spend increasing time and artifact quota downloading, scanning, and re-uploading history until the scheduled job exceeds service size or runtime limits, despite documentation suggesting bounded cost.
Recommended action: Make the persistence lifecycle and documented horizon agree. If a rolling window is intended, define a store-level age or sample policy and prune the current store before publication, using cargo-bench-history's guarded base-history pruning or an equivalent mechanism that also handles blessing sidecars; add a multi-run contract test showing the newest store stops growing past the policy. If unbounded long-lived history is intended, document and provision that growth, remove claims that artifact expiry bounds store contents, and revise the prefix-sidecar trade-off accordingly.
References:
- crates/cargo-anvil/docs/design/benchmarks.md, sections 4 and 8
- crates/cargo-anvil/templates/github/run-group-action.yml, benchmark artifact upload
- cargo-bench-history design: immutable storage and prune command
Impacted locations:
crates/cargo-anvil/templates/justfiles/anvil/checks/bench-history.just:96-106crates/cargo-anvil/templates/justfiles/anvil/checks/bench-history.just:252-266justfiles/anvil/checks/bench-history.just:96-106justfiles/anvil/checks/bench-history.just:252-266crates/cargo-anvil/templates/github/run-group-action.yml:214-222.github/actions/anvil-run-group/action.yml:214-222crates/cargo-anvil/templates/ado/scheduled-stages.yml:126-140crates/cargo-anvil/docs/design/benchmarks.md:88-110crates/cargo-anvil/docs/design/benchmarks.md:188-193
Summary
Makes automatic benchmark regression detection a first-class
cargo-anvilcapability by wrappingcargo-bench-history(cbh). One catalog, both backends (GitHub Actions + Azure DevOps): every consuming repo gets it.This PR contains both the design and the full implementation.
Design
docs/design/benchmarks.md— the subsystem design, part of the opinionated baseline (sibling tocontainers.md).docs/design/README.md,docs/design/checks.md— register the capability in the design index and check catalog.docs/design/github.md,docs/design/ado.md— per-backend history persistence (artifacts, not cache) and failure surfacing (§12 in each).docs/design/updates.md§2.1 — fork-migration rule for wrappers predating the new fields.docs/implementation-plans/0003.md— the phased build-out.Implementation
cargo-bench-history) inversions.just/tools.just.bench-historycheck recipe: collect → bless → analyze → gate.analyzenever fails on findings, so the recipe gates itself ondirection == "regression" && active.scheduled-benchmarkscheck group, wired on both backends, feedingpublish-failure.restored/cold-start, so a transient restore failure can never publish a truncated history or report a false clean.__PRE_STEPS__/__POST_STEPS__) so group-specific extras live in that group's own emitted template and the tier templates stay a plain list of groups.benches/catalog.rs— a criterion benchmark overCatalog::anvil()so anvil benches itself.Key decisions
{benchmark, commit, reason}→ the scheduled job idempotently applies it before analyze → green.Validation
tests/recipe_contracts.rs: gate scenarios, bless reconciliation, bless-parser rejection, both restore blocks executed against mocks, and process-scoping of the bless listing.actionlintover the emitted GitHub tree, and a guard asserting every per-group fragment key names a real member ofGROUPS.collectandanalyzeexit 0 with a well-formed report, so adopting repos without benchmarks do not go permanently red.Honest caveats (recorded in the design)
Hosted-runner machine-key density (may split series sparsely — to observe); coarse commit-range attribution under sparse benchmarking; ADO's coarser concurrent-regression signal while already red; cbh maturity (contained by a pinned tool version); durable-store portability (the rolling-window + opt-in-blob compromise).