diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b4796f..340a7f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,6 +117,13 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: + # The toolchain is pinned, not floating. The coverage floor is a + # number about this compiler's instrumentation: rustc 1.100.0 + # measures the same source 4.9 points lower than 1.99.1 does + # (decision 0102). A floating `stable` moved that floor + # silently, and the GitForge image (rust:1.99) kept the old + # number, so the two pipelines disagreed. + toolchain: 1.99.1 components: rustfmt - name: Check formatting run: cargo fmt --all -- --check @@ -133,6 +140,11 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: + # Pinned to the compiler the coverage floor was measured with + # (decision 0102). `stable` is not a measurement basis: 1.100.0 + # reads the same source 4.9 points lower than 1.99.1 does, and + # the GitForge image stays on 1.99. + toolchain: 1.99.1 components: clippy, rustfmt - name: Install Aegis run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c1ffa82..783aa38 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,6 +79,11 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + # The release artifacts are built with the compiler the gate + # judged, not with whatever `stable` happens to be that week + # (decision 0102). + toolchain: 1.99.1 - name: Cache cargo uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 diff --git a/docs/GAPS.md b/docs/GAPS.md index 24b9cb8..7c97e0d 100644 --- a/docs/GAPS.md +++ b/docs/GAPS.md @@ -132,7 +132,7 @@ authoritative source that supersedes this table. | **Shipped** | v0.2.5 on GitHub (2026-11-03) | ✅ `v0.2.4` was released without a tag; see PROGRESS | | **CI** | GitHub Actions: `fmt` (fast) + `gate` (`xtask ci-no-fmt`); GitForge 7-job pipeline | ✅ | | **aegis** | Baseline clean; scan 0.17 s | `.aegisignore` excludes `target/`, `web/dist/`, `fuzz/`; baseline is content-keyed (decision 0074), which supersedes the rule-class and per-finding suppression of decision 0070 | -| **Coverage** | floors 81.1 % lines / 78.7 % funcs (`xtask/src/coverage.rs`) | Local `llvm-cov` fails on stale artifacts; CI works | +| **Coverage** | floors 81.1 % lines / 78.7 % funcs (`xtask/src/coverage.rs`), measured with rustc 1.99.1 | The toolchain is pinned (decision 0102): rustc 1.100.0 reads the same tree 4.9 points lower, so a floating `stable` moved the floor. Local `llvm-cov` fails on stale artifacts; CI works | | **Code** | 144,122 Rust lines | No `unwrap()`/`panic!()` outside `#[cfg(test)]`; no `todo!`/`unimplemented!` | | **Registry** | 253 commands | Every command executable from the GUI via the Ctrl+P palette (19.4); menu reachability is the remaining gap | | **Menu reachability** | 2 inert of 296 items | *live* — derived + gated (`menus::tests::inert_count_never_rises`) | diff --git a/docs/NEXT.md b/docs/NEXT.md index 97f2414..282c950 100644 --- a/docs/NEXT.md +++ b/docs/NEXT.md @@ -57,6 +57,11 @@ columns when re-blessing. The floors were raised 60% → 81.1% lines / 78.7% functions (measured baseline, 2026-10-26). The gate is rising-only from here. **2026-10-26: 81.1/78.8 measured, floors 81.1/78.7; 14.1 landed.** +**2026-11-12: the floor is a claim about a compiler too.** rustc 1.100.0 +measures a byte-identical tree at 77.0%/76.1% where 1.99.1 measures +81.9%/78.9%, so the CI toolchain is now pinned to 1.99.1 and the gate names +the compiler that measured it (decision 0102). A re-bless names its compiler +as well. - **14.1 Editor surface tests** — LANDED 2026-10-26 (`cbfe7eb`, 30 tests). The listed residuals are all covered since: `import_styles` diff --git a/docs/TASKS.md b/docs/TASKS.md index c17dbf5..972c88a 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -58,7 +58,7 @@ |---|---|---|---|---|---| | 5 | 19.6 inert-menu wiring campaign | [tasks/T5-inert-burndown.md](tasks/T5-inert-burndown.md) | `DONE — 2026-11-11` | UI/verb lane | Batches 1–17 are complete. The current pinned census is 2 inert rows of 296; eight other rows remain explicitly feature/evidence-gated and are not part of an active wiring campaign (`menus::tests::inert_count_never_rises`, `menus::tests::menu_item_total_is_pinned`). | | 194 | Aegis gate: content-keyed baseline, so a doc edit no longer fails the gate (decision 0074) | [decisions/0074-aegis-content-keyed-baseline.md](decisions/0074-aegis-content-keyed-baseline.md) | `IMPLEMENTED` — the content-keyed gate and its decision record are in the tree; the gate has been exercised on this branch's tree | CI/tooling lane | **landed in `main` (#18)** and merged here; attach a run id before claiming the fleet-wide state | -| — | Release workflow hardening: every action pinned to a commit SHA, a fail-closed pin check in CI (decision 0099), a classified release mode with a fail-closed aggregate gate (decision 0100) | [decisions/0099-workflow-actions-are-pinned.md](decisions/0099-workflow-actions-are-pinned.md) · [decisions/0100-release-mode-classification-and-a-tested-release-gate.md](decisions/0100-release-mode-classification-and-a-tested-release-gate.md) | `IMPLEMENTED` — the 19 action references are pinned; the aggregate gate is a tested script that refuses a missing, empty, extra, mismatched or platform-less artifact set; a rehearsal is a `TEST`-titled prerelease on a disposable tag; the validator and the three release self-tests pass locally; the CI receipt is pending | CI/tooling lane | land the branch on both receipts; the checks run in the backup CI's `changes` job, and mirroring them into the GitForge pipeline waits for the `:2` pipeline shape (PR #123, PR #129); then rehearse the release path end to end through `workflow_dispatch` | +| — | Release workflow hardening: every action pinned to a commit SHA, a fail-closed pin check in CI (decision 0099), a classified release mode with a fail-closed aggregate gate (decision 0100), a pinned measuring toolchain (decision 0102) | [decisions/0099-workflow-actions-are-pinned.md](decisions/0099-workflow-actions-are-pinned.md) · [decisions/0100-release-mode-classification-and-a-tested-release-gate.md](decisions/0100-release-mode-classification-and-a-tested-release-gate.md) · [decisions/0102-coverage-floor-is-measured-with-a-pinned-toolchain.md](decisions/0102-coverage-floor-is-measured-with-a-pinned-toolchain.md) | `IMPLEMENTED` — the 19 action references are pinned; the aggregate gate is a tested script that refuses a missing, empty, extra, mismatched or platform-less artifact set; a rehearsal is a `TEST`-titled prerelease on a disposable tag; the validator and the three release self-tests pass locally; the CI receipt is pending | CI/tooling lane | land the branch on both receipts; the checks run in the backup CI's `changes` job, and mirroring them into the GitForge pipeline waits for the `:2` pipeline shape (PR #123, PR #129); then rehearse the release path end to end through `workflow_dispatch` | | 27 | Cross-platform release engineering: Windows, macOS, Linux package matrix, Android feasibility, source, SBOM/provenance, signing, CI aggregation and certification | [tasks/T27-cross-platform-release.md](tasks/T27-cross-platform-release.md) | `IN PROGRESS` — implementation is ahead of verification; **no target is `CERTIFIED`** and nothing is signed | release-workflow lane | land an end-to-end tag run of the repaired workflow and attach the run id as the RP-07 receipt; consolidate the duplicated `dist --source`/`--sbom` verbs (B-3) | | 26 | Refresh user-visible CS6 parity matrix | [tasks/T26-parity-matrix.md](tasks/T26-parity-matrix.md) | `IMPLEMENTED` — matrix published | parity lane | every CS6 menu, tool, panel, inspector field set, workflow, import/export surface, canvas interaction, keymap area, and selection state is classified with an evidence citation and an owning ticket in [`CS6-PARITY.md`](CS6-PARITY.md); the browser-sampling check the ticket named was performed on 2026-11-04 against a fresh optimized bundle and is recorded in T22 | | 22 | Phase 22: CS6 shell fidelity and screenshot audit | [tasks/T22-screenshot-ui.md](tasks/T22-screenshot-ui.md) | `IN PROGRESS` | UI lane | screenshot acceptance is complete; 22.1 panel inventory and 22.2 tool strip landed on `main` with the shell-lineage train (#107); 22.5 chrome remains partial, and 22.4 still has evidence-gated bitmap transform/fill-stroke work. [Phase 22](NEXT.md#phase-22--cs6-shell-fidelity-p0-the-looks-and-acts-like-fireworks-phase) | diff --git a/docs/decisions/0102-coverage-floor-is-measured-with-a-pinned-toolchain.md b/docs/decisions/0102-coverage-floor-is-measured-with-a-pinned-toolchain.md new file mode 100644 index 0000000..beb9970 --- /dev/null +++ b/docs/decisions/0102-coverage-floor-is-measured-with-a-pinned-toolchain.md @@ -0,0 +1,94 @@ +# 0102 — The coverage floor is measured with a pinned toolchain + +Date: 2026-11-12 · Tickets: [T27-57](../tasks/T27-57-workflow-hardening.md) · +Status: accepted + +## Context + +On 2026-11-12 the GitHub `gate` job went red on two pull requests whose Rust +source was byte-identical to `main`'s. + +PR #134's tree differs from `main` only in `docs/` and `packaging/`. The Rust +diff is empty: + +``` +git diff --shortstat origin/main origin/chore/windows-recipe-audit \ + -- crates xtask Cargo.toml Cargo.lock +``` + +Yet #134 measured **77.0 % lines / 76.1 % functions** and failed the +81.1 / 78.7 floor, while `main` measured **81.9 / 78.9** and passed. + +The differentiator is the compiler, not the code: + +| GitHub run | tree | rustc | measurement | +|---|---|---|---| +| `43157085372` | `main` @ `bc8abfb` | 1.99.1 | 81.9 / 78.9 ✓ | +| `43169461346` | #130 @ `002eb56e` | 1.99.1 | 81.9 / 78.8 ✓ | +| `43284817650` | #134 @ `f570c86` | **1.100.0** | 77.0 / 76.1 ✗ | +| `43284861290` | #134 @ `f570c86` | **1.100.0** | 77.0 / 76.1 ✗ | +| `43287370012` | #134 @ `545e7a5` | **1.100.0** | 77.0 / 76.1 ✗ | + +The workflow installs `toolchain: stable`. rustc 1.100.0 was released on +2026-11-09 and reached the runners during 2026-11-12. The GitForge pipeline +did not move: its image is `rust:1.99-slim-bookworm` (rustc 1.99.1), which is +why the two pipelines disagreed about the same commit — GitForge's +`clippy-test` job passed the floor while GitHub's `gate` job failed it. + +The build cache is not the explanation. The failing runs restored the same +Cargo.lock-keyed build cache as the passing ones, and the last failing run +(`43287370012`) restored a warm cache and still read 77.0. + +So the floor was never a property of the source tree alone. It was a property +of the source tree *and* the compiler that instrumented it — and the workflow +said `stable`. + +## Decision + +- **The GitHub workflows pin the toolchain.** `ci.yml` (the `fmt` and `gate` + jobs) and `release.yml` install `toolchain: 1.99.1` — the compiler the + floors were measured with. A release is built with the compiler the gate + judged, not with whatever `stable` happened to be that week. +- **The CI image is pinned to the patch.** `rust:1.99-slim-bookworm` becomes + `rust:1.99.1-slim-bookworm`. The two tags resolve to the same image digest + today (`sha256:392f2219…`), so the runner's image does not change; the + explicit patch stops a future `rust:1.99` from moving under the floor. +- **The gate names the compiler that measured it.** The coverage output + carries `measured with rustc `, and says so plainly when that is + not the version the floors were measured with. The gate warns; it does not + fail, because a developer's local compiler is not the CI compiler and a + local `xtask ci` must not become impossible. +- **The floor is not re-blessed.** 81.1 / 78.7 stands. The measurement moved, + not the code, and lowering the floor to meet a moving measurement is the + weakening the gate exists to prevent. + +## Evidence + +- The run table above: five runs, two toolchains, one Rust tree. +- `rust:1.99.1-slim-bookworm` and `rust:1.99-slim-bookworm` are the same + digest on Docker Hub (`sha256:392f221964669402bf4ae133d2a46c3ae270f64734133f4b2703d40019235c1a`), + so the image pin changes no artifact. +- `xtask/src/coverage.rs` names the measuring compiler and carries unit tests + for the version parser. +- PR #133 (`feat/play-animation`) also failed the floor, at 73.3 / 72.8 — + and its diagnostic run was on 1.100.0 as well. Its deficit is therefore + partly the toolchain and partly real: the animation code it adds is not + covered. That is a separate, content-level shortfall, and this decision + does not excuse it. + +## Consequences + +- The GitHub and GitForge pipelines judge the same rustc again, which is what + `infrastructure/docker/ci-rust.Dockerfile` already asked for ("this pin + matches backend-ci-rust:1 so both pipelines judge the same rustc"). +- A toolchain bump is now an explicit change with a measured consequence, + instead of an event that silently moves the floor under every open pull + request. +- The floor's basis is written down: 81.1 / 78.7, measured with rustc + 1.99.1. A future re-bless names the compiler it was measured with. +- The local toolchain (1.99.0 here) is one patch behind the pinned one; the + gate warns when it measures with anything other than 1.99.1. + +See also decision [0099](../decisions/0099-workflow-actions-are-pinned.md) +— the same class of drift, one layer down: an unpinned *action* versus an +unpinned *compiler*. diff --git a/docs/release/EXECUTION-BOARD.md b/docs/release/EXECUTION-BOARD.md index 7d4d4d3..37a6502 100644 --- a/docs/release/EXECUTION-BOARD.md +++ b/docs/release/EXECUTION-BOARD.md @@ -234,7 +234,7 @@ condition/verification. **RP** is the release-plan work package the row belongs | T27-54 | Generate and verify a machine-readable release manifest | P0 | IMPLEMENTED | T27-53 | `release-manifest.json` carries version, tag, source SHA, target triple, format, size, SHA-256, run URL and verification status; missing and corrupted assets are rejected | `jq -r '.version,.artifacts[0].sha256' dist/release-manifest.json` | Implemented on `feat/release-manifest` ([decision 0089](../decisions/0089-release-manifest-shape.md)): `dist --manifest` writes `dist/release-manifest.json` for the eight published assets, `dist --verify-manifest` re-checks it against the files, and `release.yml` publishes it as a ninth asset and validates it against the downloads. Unit tests cover the shape, the determinism and every rejection path. **No CI receipt and no published manifest yet** | — | — | | T27-55 | Regenerate and validate the CycloneDX SBOM | P0 | NOT STARTED | T27-06 | The SBOM regenerates from the locked graph, validates against the CycloneDX schema, and is compared component-by-component against `Cargo.lock`; it is published and the download is verified | `cargo run -p xtask -- dist --sbom && jq .metadata.component dist/sbom/firecraft-0.2.5-sbom.cdx.json` | `00fdd61` marks the updated SBOM verification pending; the earlier artifact was JSON-validated only | — | — | | T27-56 | Generate and independently verify build provenance | P1 | NOT STARTED | T27-53 | A provenance format is defined; each attestation binds an artifact digest to the source SHA, workflow and runner; attestations verify independently | `gh attestation verify --repo aliasfoxkde/FireCraft` | Not implemented | — | — | -| T27-57 | Harden the release workflows | P1 | IN PROGRESS | T27-18 | Third-party actions are pinned to full commit SHAs; permissions are least-privilege; build, sign and publish are separate; all artifacts bind to one tag SHA; the gate fails on missing outputs, version mismatch, checksum mismatch, unexpected extra artifacts and a skipped required platform; a safe test-release mode exists | `python3 scripts/validate_workflow_pins.py .github/workflows`; `bash scripts/validate_workflow_pins-selftest.sh`; `bash scripts/classify_release_mode-selftest.sh`; `bash scripts/validate_release_aggregate-selftest.sh`; `bash tests/release-assets-selftest.sh` | **Pinning implemented 2026-11-11** (decision [0099](../decisions/0099-workflow-actions-are-pinned.md)): all 19 action references in `ci.yml`, `release.yml` and `deploy-site.yml` name a 40-hex commit SHA with the version in a trailing comment; `scripts/validate_workflow_pins.py` fails the CI on an unpinned reference, and `scripts/validate_workflow_pins-selftest.sh` demonstrates each failure mode with one deliberately broken input. Least-privilege permissions were already declared (`contents: read` at workflow level, `contents: write` only for the draft-release job) and the aggregate gate already fails closed on a missing, empty, misnamed or digest-mismatched artifact. The deploy trigger no longer includes the workflow's own file, so a workflow edit is not a production publication. **Both remaining criteria implemented 2026-11-12** (decision [0100](../decisions/0100-release-mode-classification-and-a-tested-release-gate.md)): the aggregate gate moved out of inline YAML into `scripts/validate_release_aggregate.sh`, which now also rejects an artifact the release does not publish and requires every required platform (`x86_64-unknown-linux-gnu`, `wasm32-unknown-unknown`) to be represented in the release manifest; `scripts/validate_release_aggregate-selftest.sh` breaks a complete artifact set one thing at a time — missing, empty, extra artifact, checksum mismatch, manifest version mismatch, manifest digest mismatch, an unlisted asset, an unknown schema, a skipped platform, and a platform the manifest does not represent. A safe test-release mode exists: `scripts/classify_release_mode.sh` maps `vX.Y.Z` to production and `vX.Y.Z-dryrun.N` to a rehearsal and refuses every other shape; a manual `workflow_dispatch` run may only rehearse; a rehearsal release is a prerelease titled `TEST ` whose notes carry a do-not-publish banner, and its disposable tag is created at the commit that built the artifacts. `scripts/classify_release_mode-selftest.sh` demonstrates each refused shape, and `tests/release-assets-selftest.sh` drives the `gh` argument vector against a stub. **Outstanding**: the rehearsal path has not been run end to end through `workflow_dispatch` (it needs this workflow on `main`); the manual rehearsal in issue #99 predates the `TEST` title and the prerelease flag | — | 2026-11-12: both remaining criteria implemented and locally exercised; the CI receipt is pending | +| T27-57 | Harden the release workflows | P1 | IN PROGRESS | T27-18 | Third-party actions are pinned to full commit SHAs; permissions are least-privilege; build, sign and publish are separate; all artifacts bind to one tag SHA; the gate fails on missing outputs, version mismatch, checksum mismatch, unexpected extra artifacts and a skipped required platform; a safe test-release mode exists; the toolchain is pinned to the compiler the gate's floors were measured with | `python3 scripts/validate_workflow_pins.py .github/workflows`; `bash scripts/validate_workflow_pins-selftest.sh`; `bash scripts/classify_release_mode-selftest.sh`; `bash scripts/validate_release_aggregate-selftest.sh`; `bash tests/release-assets-selftest.sh` | **Pinning implemented 2026-11-11** (decision [0099](../decisions/0099-workflow-actions-are-pinned.md)): all 19 action references in `ci.yml`, `release.yml` and `deploy-site.yml` name a 40-hex commit SHA with the version in a trailing comment; `scripts/validate_workflow_pins.py` fails the CI on an unpinned reference, and `scripts/validate_workflow_pins-selftest.sh` demonstrates each failure mode with one deliberately broken input. Least-privilege permissions were already declared (`contents: read` at workflow level, `contents: write` only for the draft-release job) and the aggregate gate already fails closed on a missing, empty, misnamed or digest-mismatched artifact. The deploy trigger no longer includes the workflow's own file, so a workflow edit is not a production publication. **Both remaining criteria implemented 2026-11-12** (decision [0100](../decisions/0100-release-mode-classification-and-a-tested-release-gate.md)): the aggregate gate moved out of inline YAML into `scripts/validate_release_aggregate.sh`, which now also rejects an artifact the release does not publish and requires every required platform (`x86_64-unknown-linux-gnu`, `wasm32-unknown-unknown`) to be represented in the release manifest; `scripts/validate_release_aggregate-selftest.sh` breaks a complete artifact set one thing at a time — missing, empty, extra artifact, checksum mismatch, manifest version mismatch, manifest digest mismatch, an unlisted asset, an unknown schema, a skipped platform, and a platform the manifest does not represent. A safe test-release mode exists: `scripts/classify_release_mode.sh` maps `vX.Y.Z` to production and `vX.Y.Z-dryrun.N` to a rehearsal and refuses every other shape; a manual `workflow_dispatch` run may only rehearse; a rehearsal release is a prerelease titled `TEST ` whose notes carry a do-not-publish banner, and its disposable tag is created at the commit that built the artifacts. `scripts/classify_release_mode-selftest.sh` demonstrates each refused shape, and `tests/release-assets-selftest.sh` drives the `gh` argument vector against a stub. **Toolchain pinned 2026-11-12** (decision [0102](../decisions/0102-coverage-floor-is-measured-with-a-pinned-toolchain.md)): the workflows install `toolchain: 1.99.1` and the CI image is `rust:1.99.1-slim-bookworm`, because the unpinned `stable` moved the coverage floor silently — rustc 1.100.0 measures a byte-identical Rust tree at 77.0%/76.1% where 1.99.1 measures 81.9%/78.9%, while the GitForge image stayed on 1.99. The gate now names the compiler that measured it. **Outstanding**: the rehearsal path has not been run end to end through `workflow_dispatch` (it needs this workflow on `main`); the manual rehearsal in issue #99 predates the `TEST` title and the prerelease flag | — | 2026-11-12: both remaining criteria implemented and locally exercised; the CI receipt is pending; the toolchain pin landed the same day | ### RP-07 — Release aggregation and publication diff --git a/docs/tasks/T27-57-workflow-hardening.md b/docs/tasks/T27-57-workflow-hardening.md index b7ea4a2..543ee05 100644 --- a/docs/tasks/T27-57-workflow-hardening.md +++ b/docs/tasks/T27-57-workflow-hardening.md @@ -17,7 +17,8 @@ that all artifacts bind to one tag SHA and that the gate fails closed. permissions are least-privilege; build, sign and publish are separate jobs; all artifacts bind to one tag SHA; the gate fails on missing outputs, a version mismatch, a checksum mismatch, unexpected extra artifacts and a skipped required platform; a -safe test-release mode exists. +safe test-release mode exists; the toolchain is pinned to the compiler the gate's +floors were measured with, and the gate names the compiler that measured it. **Verification:** @@ -78,6 +79,21 @@ self-test that runs in the same CI step: notes file, an empty asset list, an unknown mode, an empty source tree, a stale destination and a duplicate basename. +**Evidence (2026-11-12, second half of the day):** decision +[0102](../decisions/0102-coverage-floor-is-measured-with-a-pinned-toolchain.md) — +the toolchain is pinned: + +* *The toolchain is pinned, not floating.* `ci.yml` (`fmt` and `gate`) and + `release.yml` install `toolchain: 1.99.1`; `infrastructure/docker/ci-rust.Dockerfile` + builds `rust:1.99.1-slim-bookworm`. The unpinned `stable` moved the coverage + floor silently: rustc 1.100.0 measures a byte-identical Rust tree at + 77.0 % lines / 76.1 % functions where 1.99.1 measures 81.9 / 78.9 — the same + commit, and the same tree, on either side of the floor. The GitForge image + stayed on 1.99, so the two pipelines disagreed about the same commit. +* *The gate names the compiler that measured it.* `xtask/src/coverage.rs` + prints `measured with rustc ` and warns when that is not the + version the floors were measured with. Unit tests cover the version parser. + **Already satisfied before this change:** least-privilege permissions (`contents: read` at workflow level, `contents: write` only for the draft-release job); build and publish are separate jobs; the artifacts bind to the tag SHA through the checkout; diff --git a/infrastructure/docker/ci-rust.Dockerfile b/infrastructure/docker/ci-rust.Dockerfile index 147f6d0..ae0d024 100644 --- a/infrastructure/docker/ci-rust.Dockerfile +++ b/infrastructure/docker/ci-rust.Dockerfile @@ -52,10 +52,16 @@ # wasm-bindgen-cli pin MUST equal the wasm-bindgen version in # Cargo.lock — a mismatched smoke runner rejects the generated bindings. # +# The Rust image is pinned to the patch, not to the minor: the coverage +# floor is a number about this compiler's instrumentation, and the +# GitHub workflows pin rustc 1.99.1 to match. `rust:1.99` and +# `rust:1.99.1-slim-bookworm` are the same image today; the explicit +# patch keeps a future `rust:1.99` from moving under the floor. +# # Do NOT rebuild merely because a lockfile moved. The baked registry is a # fast floor, not a wall — see the CARGO_NET_OFFLINE note at the bottom # for why, and for the failure it replaces. -FROM rust:1.99-slim-bookworm AS fetch +FROM rust:1.99.1-slim-bookworm AS fetch # WORKDIR must precede the COPYs: with the default cwd of /, "./" lands # the manifests at / and cargo fetch resolves the workspace from # /Cargo.toml, where no crates/ tree exists. @@ -71,7 +77,7 @@ COPY fuzz/ ./fuzz/ RUN cargo fetch --locked \ && cargo fetch --locked --manifest-path fuzz/Cargo.toml -FROM rust:1.99-slim-bookworm +FROM rust:1.99.1-slim-bookworm # No nodejs here: bookworm's nodejs is 18, and the wasm-bindgen 0.2.129 # test runtime traps in its V8 before running a single smoke test diff --git a/xtask/src/coverage.rs b/xtask/src/coverage.rs index d9ab42e..32856f1 100644 --- a/xtask/src/coverage.rs +++ b/xtask/src/coverage.rs @@ -28,6 +28,16 @@ const LINE_THRESHOLD: f64 = 81.1; /// (2026-10-30, unchanged from 2026-10-26). const FUNCTION_THRESHOLD: f64 = 78.7; +/// The rustc the floors were measured with. +/// +/// The floor is a claim about *this* compiler's instrumentation, not +/// about the source tree alone: the same tree measured by rustc 1.100.0 +/// reads 77.0%/76.1% where 1.99.1 reads 81.9%/78.9% (decision 0102). +/// The CI workflows pin the toolchain to this version, so the number the +/// gate compares is the number the floor was measured with; the gate +/// warns — it does not fail — when it is measured with anything else. +const MEASURED_WITH: &str = "1.99.1"; + /// Render a failed `cargo llvm-cov` invocation for the operator. /// /// cargo writes the per-test report — the panic message, the `left` @@ -50,6 +60,38 @@ fn failure_context(output: &std::process::Output) -> String { text } +/// The rustc that will do the measuring, as `rustc --version` reports it. +/// +/// `None` when `rustc` cannot be run at all — the gate then measures +/// without naming its compiler, which is worth saying out loud. +fn measuring_rustc() -> Option { + let output = Command::new("rustc").arg("--version").output().ok()?; + rustc_version(&String::from_utf8_lossy(&output.stdout)).map(str::to_string) +} + +/// The version token in a `rustc --version` line. +/// +/// `rustc 1.99.1 (58cf8f9c3 2026-10-13)` yields `1.99.1`. +fn rustc_version(line: &str) -> Option<&str> { + let rest = line.trim().strip_prefix("rustc ")?; + rest.split_whitespace().next().filter(|v| !v.is_empty()) +} + +/// Name the compiler that produced the measurement. +/// +/// The measurement belongs to the compiler as much as to the source, so +/// the log says which compiler measured it — and says so plainly when +/// that is not the compiler the floors were measured with. +fn report_measuring_toolchain() { + match measuring_rustc() { + Some(version) if version != MEASURED_WITH => eprintln!( + "[coverage] measured with rustc {version}, not the rustc {MEASURED_WITH} the floors were measured with" + ), + Some(version) => eprintln!("[coverage] measured with rustc {version}"), + None => eprintln!("[coverage] the measuring rustc could not be identified"), + } +} + /// Run the coverage gate. Exits 0 if all thresholds are met or exceeded. pub fn run() -> Result<(), String> { let output = Command::new("cargo") @@ -64,6 +106,8 @@ pub fn run() -> Result<(), String> { let (lines, funcs, _regions) = parse_lcov(&workspace_root().join("lcov.info"))?; + report_measuring_toolchain(); + let pass_lines = lines >= LINE_THRESHOLD; let pass_funcs = funcs >= FUNCTION_THRESHOLD; @@ -106,6 +150,8 @@ pub fn measure() -> Result<(), String> { let (lines, funcs, _regions) = parse_lcov(&workspace_root().join("lcov.info"))?; + report_measuring_toolchain(); + eprintln!( "[coverage --measure] lines={:.1}%, functions={:.1}%", lines, funcs @@ -205,3 +251,38 @@ fn parse_lcov(path: &std::path::Path) -> Result<(f64, f64, f64), String> { Ok((lines_pct, funcs_pct, regions_pct)) } + +#[cfg(test)] +mod tests { + use super::{MEASURED_WITH, rustc_version}; + + #[test] + fn a_version_line_yields_its_version() { + assert_eq!( + rustc_version("rustc 1.99.1 (58cf8f9c3 2026-10-13)"), + Some("1.99.1") + ); + } + + #[test] + fn a_version_line_with_a_nightly_suffix_still_yields_a_version() { + assert_eq!( + rustc_version("rustc 1.100.0-nightly (0ef1bc386 2026-11-09)"), + Some("1.100.0-nightly") + ); + } + + #[test] + fn a_line_that_is_not_a_version_line_yields_nothing() { + assert_eq!(rustc_version(""), None); + assert_eq!(rustc_version("cargo 1.99.0 (5f94df478 2026-08-27)"), None); + assert_eq!(rustc_version("rustc"), None); + } + + #[test] + fn the_pinned_toolchain_is_the_one_the_floors_were_measured_with() { + // The floors are a claim about this compiler's instrumentation; + // the workflows pin to the same version. + assert_eq!(MEASURED_WITH, "1.99.1"); + } +}