Files
pad/.github
xarmian 49d1bfcd85 ci(nix): a Go bump's Nix check is green when the build passes, and main heals its own vendorHash (TASK-2954) (#1292)
`vendorHash` pins the Go module set by content hash. Dependabot updates go.mod
and go.sum and has no idea `nix/package.nix` exists, so every Go-dependency bump
PR failed `Nix build & check` on a fixed-output hash mismatch — structurally,
and forever. Both open Go bumps (#1274, #1275) are red that way today; the
npm-side bumps (#1276, #1277) are green, which is the control that isolates the
cause. A permanently-red check is not a check: a bump that genuinely breaks the
build looks identical, at a glance, to one that only moved the hash.

Two halves, and they are deliberately in different places.

THE CHECK IS MADE HONEST WITHOUT A TOKEN. Every Nix run recomputes the hash in
its working tree before the build steps judge it, so green means the build
passed with that ref's actual module set. This runs for every author, not just
Dependabot: a human's own go.mod change moves the hash the same way, and a check
that is honest for one author only is the shape this removes.

MAIN HEALS ITSELF ONE COMMIT AFTER A MERGE. The corrected file cannot be pushed
from a Dependabot PR run: such a run gets a read-only GITHUB_TOKEN — it runs as
if from a fork — and the `permissions` key does NOT lift that. Only a
repository-wide setting does, and that setting would hand fork PRs write tokens
on a public repo, which is the surface CONVE-2438 exists to keep closed. The
merge, however, is authored by a human, so the `push: main` run that follows is
ordinary. A separate job with the only `contents: write` in the file commits the
recomputed value there, gated on the JOB (a step-level `if` is not a boundary —
the job would still hold the token and checkout persists it), on the build
having passed, and on this push having touched go.mod or go.sum.

The loop guard is the commit's own contents: the bot commit touches
nix/package.nix and nothing else, so the run it triggers finds go.mod and go.sum
unchanged and stops at the first gate. Not a heuristic about who pushed — the
fix cannot invalidate the hash it just wrote.

Three ways that gate could have lost a heal, all closed. A cancelled main run's
heal is never retried — the next push's run recomputes correctly but its gate
sees only its OWN commits — so `cancel-in-progress` is now `pull_request`-only;
and that alone is not enough, because GitHub holds only ONE pending run per
concurrency group and a third push evicts the queued second, which looks exactly
like a run that found nothing to do — so push runs get a per-commit group that
nothing can evict. The gate also compares `before..after` on a full clone rather than `HEAD~1..HEAD`
on a two-commit one, because a direct push of several commits can carry the
go.sum change anywhere in the range. A concurrent merge makes the push a
non-fast-forward: the job goes red rather than overwriting, and that merge's own
run heals.

WHAT THE PARSER REFUSES is the whole correctness argument. This build has many
fixed-output derivations: every npm tarball `importNpmLock` fetches is one, and
a mismatch in any of them prints the same block with a `got:` line. Taking "the
got: hash" writes a tarball's hash into `vendorHash` and looks like it worked —
which is what package.nix's old comment told a human to do by eye. So each line
is stripped of its runner timestamp and indentation SEPARATELY and matched
whole, and a header counts only if it says `error:`, names a single store path
segment that STARTS with a 32-character store hash then `-pad-` and ends
`-go-modules.drv':` with nothing after it — identity, not resemblance, since
`-pad-` anywhere in the name also matches `…-other-pad-tool-…-go-modules.drv`;
the hash is then taken only from a canonical-length `got:` on the line
IMMEDIATELY after a canonical-length `specified:`. Anything else exits 1 having
written nothing, and the build stays red.

40 assertions in nix/bump-vendor-hash_test.sh, wired into the CI Go job and
`make test-nix-hash` — in ci.yml rather than nix.yml because otherwise nothing
on an ordinary PR would run it, and a break would surface on the next bump.
14 mutants, all killed — but five of them survived the first suite that claimed
to cover them, each because the case written for the rule was ALSO refused for a
second reason and so discriminated nothing about it: short hashes on both lines
never exercised either length rule on its own, and a nested path that also had a
malformed store hash never exercised the single-segment rule. A sixth, dropping
the canonical length from the `got:` condition, survived because the extractor
re-stated the rule; the fix was to state it once. Portability is checked, not assumed: `awk` is
gawk here and mawk on the runners, so the suite re-runs itself under mawk, gawk
and busybox and is green only if all agree. The parser also had a real defect —
it worked only on timestamped CI logs, not the local log package.nix tells a
human to produce — found by asserting an exit code rather than file contents,
because for a no-op input "did not write" and "could not parse" leave identical
files.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-08 14:00:39 -04:00
..
2026-03-26 01:52:36 +00:00