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
This commit is contained in:
xarmian
2026-09-08 14:00:39 -04:00
committed by GitHub
parent 6a5eb3dee0
commit 49d1bfcd85
6 changed files with 714 additions and 5 deletions
+10
View File
@@ -62,6 +62,16 @@ jobs:
- name: Run go vet
run: go vet ./...
# The Nix vendorHash bump parser (TASK-2954). Its own gate lives HERE, in
# the Go job, and not in nix.yml: over there the script only does anything
# when a build has ALREADY failed on a hash mismatch, so an ordinary PR
# would exercise none of its logic and a change that broke it would land
# green and surface on the next dependency bump. The load-bearing case in
# the suite is negative — an npm fetchurl FOD mismatch must NOT be written
# into vendorHash.
- name: Test the Nix vendorHash bump parser
run: nix/bump-vendor-hash_test.sh
- name: Run golangci-lint
# only-new-issues: false means CI fails on ANY linter finding,
# not just findings on PR-changed lines. The IDEA-732 cleanup
+171 -2
View File
@@ -7,8 +7,23 @@ on:
branches: [main]
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
# PR runs share a group per ref and supersede each other — a newer push to the
# same PR makes the older run's answer worthless.
#
# PUSH runs get a group of their OWN, one per commit, and this is deliberate
# (TASK-2954). A run on `main` may owe main a vendorHash heal, and losing it
# loses the fix SILENTLY: the next push's run recomputes correctly, but its
# go.mod/go.sum gate sees only its own commits, so nothing ever retries the
# skipped heal. `cancel-in-progress: false` is not enough to prevent that —
# GitHub holds only ONE pending run per group, so a third push evicts the
# queued second, and the eviction looks exactly like a run that decided there
# was nothing to do. A per-commit group cannot be evicted by anything.
#
# What that trades for: two main runs can reach the push step at once, and the
# loser gets a non-fast-forward rejection and goes red. That is the right
# direction — a red job is read, and the next merge's run heals anyway.
group: nix-${{ github.event_name == 'push' && github.sha || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
@@ -21,11 +36,62 @@ jobs:
nix:
name: Nix build & check
runs-on: ubuntu-latest
# NO write permission here, deliberately — see `push-vendor-hash` below.
# This job builds; it never pushes.
outputs:
vendor_hash_bumped: ${{ steps.vendorhash.outputs.bumped }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22
# THE RED-BASELINE FIX (TASK-2954). `vendorHash` pins the Go module set by
# content hash; Dependabot updates go.mod/go.sum and has no idea this file
# exists, so EVERY Go bump PR failed the steps below on a hash mismatch,
# structurally and forever. A check that is always red is not a check —
# a bump that genuinely breaks the build looked identical, at a glance, to
# one that only moved the hash.
#
# This recomputes the hash in the WORKING TREE, so the steps below judge
# the build with this ref's actual module set. Nothing is pushed from here
# and no token is used: a Dependabot PR 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, which would hand fork PRs write
# tokens too. The push therefore happens on `push: main` instead, in the
# job below, where the run is not Dependabot's.
#
# It runs on every run, not just Dependabot's: a human's own go.mod change
# moves the hash the same way, and a check that is honest only for one
# author is the shape this item exists to remove.
#
# It runs BEFORE `nix flake check`, which builds the package too and would
# hit the same mismatch first. It is deliberately not a gate: if the build
# fails for any reason other than a go-modules hash mismatch, the script
# exits non-zero, this step still succeeds, and the real steps below report
# the real failure. The gate stays exactly where it was.
- name: Recompute vendorHash before judging the build
id: vendorhash
run: |
set -o pipefail
if nix build .#default --print-build-logs 2>&1 | tee /tmp/nix-build.log; then
echo "Build is green; vendorHash is current."
exit 0
fi
if ! new_hash="$(nix/bump-vendor-hash.sh /tmp/nix-build.log)"; then
echo "Not a go-modules hash mismatch — leaving it to the build steps below."
exit 0
fi
echo "vendorHash -> $new_hash"
echo "bumped=true" >> "$GITHUB_OUTPUT"
- name: Carry the recomputed package.nix to the push job
if: steps.vendorhash.outputs.bumped == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: recomputed-package-nix
path: nix/package.nix
retention-days: 1
- name: nix flake check
run: nix flake check --print-build-logs
@@ -62,3 +128,106 @@ jobs:
run: |
GOTOOLCHAIN=auto go install golang.org/x/vuln/cmd/govulncheck@v1.2.0
GOVULNCHECK="$(go env GOPATH)/bin/govulncheck" nix/vulnscan.sh result/bin/pad
# THE ONLY JOB WITH A WRITE TOKEN, and it exists as a separate job precisely so
# that the token is never minted in a context an untrusted change could reach.
#
# An `if:` on a STEP is not a security boundary — the job would still hold a
# write-capable GITHUB_TOKEN, and `actions/checkout` persists it in the
# workspace for every later command, so any change to this file inside a PR
# could use it. An `if:` on a JOB is different in kind: the job does not start,
# and no write token is issued for that run at all.
#
# WHY ON `push: main` AND NOT ON THE PR (ruled on TASK-2954's trail). A
# Dependabot PR run gets a read-only token whatever `permissions` says. The
# merge, though, is authored by a human, so the `push` run that follows is an
# ordinary run with an ordinary token. `main` self-heals one commit after the
# merge — usually one commit after it; see the non-fast-forward note below for
# the case where it takes the next merge instead — and the PR's own check was
# already honest because the job above recomputes in the working tree. The two rejected alternatives were a PAT in
# the Dependabot secret store (a standing credential for a once-a-week fix) and
# enabling write tokens for pull-request workflows (which would give them to
# FORK PRs on a public repo — the surface CONVE-2438 exists to keep closed).
#
# THE LOOP GUARD IS THE COMMIT'S OWN CONTENTS. This job pushes a commit that
# touches `nix/package.nix` and nothing else. That push triggers this workflow
# again, the first gate below asks whether go.mod or go.sum moved across the
# push's range, and for the bot's own commit the answer is no — so the second
# run stops there. The gate is not a heuristic about who pushed; it is the fact
# that the fix cannot invalidate the hash it just wrote.
#
# If another merge lands while this job runs, the push is REJECTED as a
# non-fast-forward and this job goes red rather than overwriting anything. The
# heal is not lost: that merge's own run recomputes and heals. The window is
# the length of a nix build, and the failure mode is loud rather than silent —
# which is the same trade the per-commit concurrency group above makes.
#
# `needs: nix` without `if: always()` is the second half: this runs only when
# the build job SUCCEEDED, so a module set whose corrected tree still fails
# `nix flake check`, the smoke test or the vulnerability scan is never
# committed to main.
push-vendor-hash:
name: Heal main's vendorHash
needs: nix
if: >-
github.event_name == 'push' &&
github.ref == 'refs/heads/main' &&
needs.nix.outputs.vendor_hash_bumped == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Full history: the gate below asks what THIS PUSH changed, which is
# `before..after`, not `HEAD~1..HEAD`. A direct push of several commits
# can carry the go.sum change anywhere in the range, and a two-commit
# clone would see only the last one and skip a heal that was owed.
fetch-depth: 0
- name: Did this push touch the Go module set?
id: gate
env:
BEFORE: ${{ github.event.before }}
AFTER: ${{ github.sha }}
run: |
# A branch created by this push, or a `before` the clone cannot resolve
# (a force push whose old tip is gone), leaves the range unanswerable.
# Fall through to the hash comparison rather than skipping: the second
# gate is the one that decides, and this one only avoids pointless work.
if [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ] \
|| ! git rev-parse --verify -q "$BEFORE^{commit}" >/dev/null; then
echo "Push range unresolvable; deferring to the hash comparison."
echo "touched=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if git diff --name-only "$BEFORE" "$AFTER" | command grep -qE '^go\.(mod|sum)$'; then
echo "touched=true" >> "$GITHUB_OUTPUT"
else
echo "go.mod/go.sum unchanged across $BEFORE..$AFTER — nothing here could have moved the hash."
echo "touched=false" >> "$GITHUB_OUTPUT"
fi
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
if: steps.gate.outputs.touched == 'true'
with:
name: recomputed-package-nix
path: nix/
- name: Commit and push
if: steps.gate.outputs.touched == 'true'
run: |
# The second gate. The artifact only exists because the build job found
# a mismatch, but comparing here keeps the job honest about the tree it
# is actually looking at rather than about what another job reported.
if git diff --quiet -- nix/package.nix; then
echo "package.nix already matches the recomputed hash; nothing to push."
exit 0
fi
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add nix/package.nix
git commit -m "chore(nix): heal vendorHash for the module set in ${SHA} (TASK-2954)"
git push
env:
SHA: ${{ github.sha }}
+7 -1
View File
@@ -1,4 +1,4 @@
.PHONY: build test test-pg test-pg-down test-pg-project dev clean web dev-web serve restart lint install check vuln web-check web-test web-audit
.PHONY: build test test-nix-hash test-pg test-pg-down test-pg-project dev clean web dev-web serve restart lint install check vuln web-check web-test web-audit
BINARY=pad
BUILD_DIR=./cmd/pad
@@ -55,6 +55,12 @@ install: build
test:
go test -timeout=45m ./... -v
# The vendorHash bump parser (TASK-2954). Pure bash, no toolchain, ~1s — its own
# target rather than part of `test`, which means `go test`. CI runs this same
# script in the Go job so a change to the parser is gated rather than trusted.
test-nix-hash:
@nix/bump-vendor-hash_test.sh
# Run tests against PostgreSQL (starts a container automatically).
#
# THE HOST PORT IS EPHEMERAL (TASK-2708). It was hardcoded to 5445, which let
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env bash
#
# Rewrite `vendorHash` in nix/package.nix from a failed `nix build` log.
#
# WHY THIS EXISTS (TASK-2954). `vendorHash` pins the Go module set by content
# hash, so any go.mod/go.sum change invalidates it. Dependabot updates go.mod
# and go.sum and has no idea this file exists, so every Go-dependency bump PR
# failed `Nix build & check` on a hash mismatch — structurally, forever. A
# permanently-red check is not a check: a bump that genuinely breaks the build
# looked identical, at a glance, to one that only moved the hash.
#
# WHAT IT IS SCOPED TO, AND WHY THAT IS THE WHOLE CORRECTNESS ARGUMENT. `nix
# build` prints "hash mismatch in fixed-output derivation" for ANY FOD, and this
# build has many: the Go module set is one, and every per-package `fetchurl`
# that `importNpmLock` generates from web/package-lock.json is another. Taking
# "the `got:` hash" from such a log would happily write an npm tarball's hash
# into `vendorHash` — a green-looking rewrite that pins the wrong thing. So the
# match is anchored to the derivation whose name ends in `-go-modules.drv`, and
# a log with no such block is NOT an error this script can fix: it exits 1
# having changed nothing, and the caller leaves the build red.
#
# Usage: bump-vendor-hash.sh <build-log> [package.nix]
# Exit: 0 = rewritten (new hash on stdout)
# 1 = no go-modules hash mismatch in the log; nothing written
# 2 = usage / the file does not look like what we expect
set -euo pipefail
log="${1:-}"
pkg="${2:-nix/package.nix}"
if [[ -z "$log" || ! -r "$log" ]]; then
echo "usage: $0 <build-log> [package.nix]" >&2
exit 2
fi
if [[ ! -w "$pkg" ]]; then
echo "$0: $pkg is not writable" >&2
exit 2
fi
# Find the `got:` line belonging to the go-modules mismatch block.
#
# The block nix prints is:
#
# error: hash mismatch in fixed-output derivation '/nix/store/…-pad-…-go-modules.drv':
# specified: sha256-…
# got: sha256-…
#
# and in a CI log every line carries a runner timestamp in front of it. The
# timestamp is stripped SEPARATELY from the indentation, because a log captured
# locally (`nix build 2>&1 | tee`) has the indentation and no timestamp — the
# first version folded the two and worked only on CI logs, which the suite's
# exit-code assertion is what caught. (The
# timestamp pattern avoids interval quantifiers like `{4}`, which older `mawk` —
# the default `awk` on the ubuntu runners — does not accept; the suite re-runs
# itself under every awk on the box for exactly this reason). So each
# line is stripped of a leading ISO timestamp and then matched WHOLE — anchored
# at both ends — rather than searched for a substring. Substring matching is what
# lets ordinary build output impersonate a nix diagnostic: a `warning:` carrying
# the same phrase, a path that merely CONTAINS `-go-modules.drv`, or a log line
# that happens to say `got:` next to a hash would each be enough.
#
# Four things the header must satisfy, all of them narrowing:
# - `error:`, not `warning:` or any other severity;
# - `/nix/store/` followed by a single path SEGMENT — no further `/`, which is
# what makes it a store path rather than any path ending in the right
# characters;
# - that segment ends `-go-modules.drv':` with nothing at all after it;
# - the segment begins with a 32-character store hash and then `-pad-`, which
# is derivation IDENTITY rather than a resemblance: `-pad-` matched anywhere
# in the name would also accept `…-other-pad-tool-…-go-modules.drv`, whose
# hash is not ours. If this project's pname ever changes, this stops matching
# and the script exits 1 — red, and a human reads the log. That is the
# intended direction to fail in.
#
# The hashes are matched at their CANONICAL LENGTH (43 base64 characters plus
# the `=`), not as `sha256-<anything>`. A truncated or hand-typed value in a log
# is then not something this can write into the build.
#
# The `got:` line is accepted only as the line IMMEDIATELY AFTER a `specified:`
# line inside an armed block (`ready == NR - 1`), because after the timestamp
# strip a `got: sha256-…` line from any other producer is byte-indistinguishable
# from nix's. Sequence alone was not enough — a valid header, a valid
# `specified:`, and then somebody else's `got:` twenty lines later would have
# been accepted. Adjacency is the binding: nix prints the two together.
#
# Arming disarms on the next `error:` line, so a mismatch whose `got:` never
# arrives (interleaved output) yields nothing rather than the next FOD's hash.
new_hash="$(awk '
{
body = $0
sub(/^[0-9][0-9-]*T[0-9][0-9:.]*Z/, "", body)
sub(/^[ \t]+/, "", body)
sub(/[\r]+$/, "", body)
}
body ~ /^error: hash mismatch in fixed-output derivation \x27\/nix\/store\/[0-9a-z]{32}-pad-[^\x27\/]*-go-modules\.drv\x27:$/ {
armed = 1
ready = 0
next
}
armed && body ~ /^specified:[ \t]+sha256-[A-Za-z0-9+\/]{43}=$/ {
ready = NR
next
}
armed && ready == NR - 1 && body ~ /^got:[ \t]+sha256-[A-Za-z0-9+\/]{43}=$/ {
# The line is already matched WHOLE above, so the hash is what is left
# after the label. Extracting with a second pattern would put the
# canonical-length rule in two places, and a mutation run showed what
# that costs: dropping it from the condition alone changed nothing,
# because the extractor still enforced it. One rule, one place.
hash = body
sub(/^got:[ \t]+/, "", hash)
print hash
exit
}
body ~ /^error:/ { armed = 0 }
' "$log")"
if [[ -z "$new_hash" ]]; then
echo "$0: no go-modules hash mismatch in $log — leaving $pkg alone" >&2
exit 1
fi
if ! grep -qE '^[[:space:]]*vendorHash[[:space:]]*=[[:space:]]*"sha256-[A-Za-z0-9+/]+=*";[[:space:]]*$' "$pkg"; then
echo "$0: no vendorHash line to rewrite in $pkg" >&2
exit 2
fi
# The replacement is anchored on the whole line, and the hash is injected via an
# awk variable rather than interpolated into a sed script: a base64 hash contains
# `/` and `+`, which are a sed delimiter and a regex metacharacter respectively.
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
awk -v h="$new_hash" '
/^[[:space:]]*vendorHash[[:space:]]*=[[:space:]]*"sha256-/ {
match($0, /^[[:space:]]*/)
printf "%svendorHash = \"%s\";\n", substr($0, 1, RLENGTH), h
next
}
{ print }
' "$pkg" > "$tmp"
cat "$tmp" > "$pkg"
echo "$new_hash"
+367
View File
@@ -0,0 +1,367 @@
#!/usr/bin/env bash
#
# Tests for bump-vendor-hash.sh (TASK-2954).
#
# The load-bearing case is NEGATIVE: this build's npm dependencies are
# per-package `fetchurl` derivations, so a log can carry a "hash mismatch in
# fixed-output derivation" block that has nothing to do with `vendorHash`.
# A parser that takes "the got: hash" would write an npm tarball's hash into
# vendorHash and the rewrite would look like it worked. Cases 3, 4 and 5 are
# there to fail if the anchoring on `-go-modules.drv` is ever loosened.
#
# The two positive logs are the REAL text from the failed runs on PR #1274 and
# #1275, timestamps included, because the timestamps are what break a
# fixed-offset parser.
set -uo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
script="$here/bump-vendor-hash.sh"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
pass=0
fail=0
ORIG='sha256-8L7gH7Yy5+Fig3wK2SPLYSJjcY9nF/jumQ7PATJ3RIE='
# A package.nix stub carrying the real vendorHash line at its real indentation.
make_pkg() {
cat > "$1" <<EOF
{
pname = "pad";
# Update alongside go.sum.
vendorHash = "$ORIG";
subPackages = [ "cmd/pad" ];
}
EOF
}
check() {
local name="$1" want="$2" got="$3"
if [[ "$want" == "$got" ]]; then
pass=$((pass + 1))
else
fail=$((fail + 1))
echo "FAIL: $name"
echo " want: $want"
echo " got: $got"
fi
}
hash_in() { grep -oE 'sha256-[A-Za-z0-9+/]+=*' "$1" | head -1; }
# ---------------------------------------------------------------- case 1
# The real log from PR #1274 (go-minor-and-patch, 4 updates).
log="$work/1274.log"
cat > "$log" <<'EOF'
2026-09-07T13:11:02.5087484Z error: hash mismatch in fixed-output derivation '/nix/store/ghbb18gfh6pl8vqf1fzqrfcsjk4ikh8z-pad-0.15.0-go-modules.drv':
2026-09-07T13:11:02.5093617Z specified: sha256-8L7gH7Yy5+Fig3wK2SPLYSJjcY9nF/jumQ7PATJ3RIE=
2026-09-07T13:11:02.5094211Z got: sha256-2hHhUx/J9CgDGU4fE5EE2Y/h6nRTA5vZ7GzuQ2oajkk=
2026-09-07T13:11:03.5316739Z ##[error]To correct the hash mismatch for pad-0.15.0-go-modules, use "sha256-2hHhUx/J9CgDGU4fE5EE2Y/h6nRTA5vZ7GzuQ2oajkk="
EOF
pkg="$work/p1.nix"; make_pkg "$pkg"
out="$("$script" "$log" "$pkg" 2>/dev/null)"; rc=$?
check "1274: exit 0" "0" "$rc"
check "1274: prints the new hash" "sha256-2hHhUx/J9CgDGU4fE5EE2Y/h6nRTA5vZ7GzuQ2oajkk=" "$out"
check "1274: rewrites the file" "sha256-2hHhUx/J9CgDGU4fE5EE2Y/h6nRTA5vZ7GzuQ2oajkk=" "$(hash_in "$pkg")"
check "1274: keeps the indentation" ' vendorHash = "sha256-2hHhUx/J9CgDGU4fE5EE2Y/h6nRTA5vZ7GzuQ2oajkk=";' "$(grep vendorHash "$pkg")"
# ---------------------------------------------------------------- case 2
# The real log from PR #1275 (mcp-go 0.58.0 -> 1.0.0).
log="$work/1275.log"
cat > "$log" <<'EOF'
2026-09-07T13:12:44.1000000Z error: hash mismatch in fixed-output derivation '/nix/store/zzzz1qv8w0k3n7d2m5x9c4b6f8h0j2l4-pad-0.15.0-go-modules.drv':
2026-09-07T13:12:44.1000001Z specified: sha256-8L7gH7Yy5+Fig3wK2SPLYSJjcY9nF/jumQ7PATJ3RIE=
2026-09-07T13:12:44.1000002Z got: sha256-6xXfuNrBAJZ3KPXPFlwt3i8cRs9w+wpAWtEnbTj0Ebc=
EOF
pkg="$work/p2.nix"; make_pkg "$pkg"
out="$("$script" "$log" "$pkg" 2>/dev/null)"
check "1275: rewrites to that bump's hash" "sha256-6xXfuNrBAJZ3KPXPFlwt3i8cRs9w+wpAWtEnbTj0Ebc=" "$out"
# ---------------------------------------------------------------- case 3
# THE ONE THAT MATTERS. An npm fetchurl FOD mismatch and NOTHING else. A parser
# that greps for `got:` rewrites vendorHash to a tarball hash here.
log="$work/npm.log"
cat > "$log" <<'EOF'
2026-09-07T13:20:00.0000000Z error: hash mismatch in fixed-output derivation '/nix/store/aaaa-vitest-4.1.11.tgz.drv':
2026-09-07T13:20:00.0000001Z specified: sha512-AAAA=
2026-09-07T13:20:00.0000002Z got: sha256-NPMnpmNPMnpmNPMnpmNPMnpmNPMnpmNPMnpmNPMnpmA=
EOF
pkg="$work/p3.nix"; make_pkg "$pkg"
out="$("$script" "$log" "$pkg" 2>/dev/null)"; rc=$?
check "npm-only: exits 1" "1" "$rc"
check "npm-only: prints nothing on stdout" "" "$out"
check "npm-only: leaves vendorHash alone" "$ORIG" "$(hash_in "$pkg")"
# ---------------------------------------------------------------- case 4
# npm mismatch FIRST, go-modules second: the go-modules hash must win.
log="$work/both.log"
cat "$work/npm.log" "$work/1274.log" > "$log"
pkg="$work/p4.nix"; make_pkg "$pkg"
out="$("$script" "$log" "$pkg" 2>/dev/null)"
check "npm-then-go: takes the go-modules hash" "sha256-2hHhUx/J9CgDGU4fE5EE2Y/h6nRTA5vZ7GzuQ2oajkk=" "$out"
# ---------------------------------------------------------------- case 5
# go-modules mismatch FIRST, an unrelated FOD after it: the later block must not
# be attributed to the armed one.
log="$work/both2.log"
cat "$work/1274.log" "$work/npm.log" > "$log"
pkg="$work/p5.nix"; make_pkg "$pkg"
out="$("$script" "$log" "$pkg" 2>/dev/null)"
check "go-then-npm: still the go-modules hash" "sha256-2hHhUx/J9CgDGU4fE5EE2Y/h6nRTA5vZ7GzuQ2oajkk=" "$out"
# ---------------------------------------------------------------- case 6
# A build that failed for a REAL reason. Nothing to rewrite, and the caller
# must be able to tell, because this is the case where red is correct.
log="$work/real.log"
cat > "$log" <<'EOF'
2026-09-07T13:30:00.0000000Z internal/store/items.go:412:2: undefined: ErrNope
2026-09-07T13:30:00.0000001Z error: builder for '/nix/store/bbbb-pad-0.15.0.drv' failed with exit code 1
EOF
pkg="$work/p6.nix"; make_pkg "$pkg"
"$script" "$log" "$pkg" >/dev/null 2>&1; rc=$?
check "real failure: exits 1" "1" "$rc"
check "real failure: leaves vendorHash alone" "$ORIG" "$(hash_in "$pkg")"
# ---------------------------------------------------------------- case 7
# Idempotence: a log whose got: equals what is already pinned rewrites to the
# same bytes, so a re-run of the fixing workflow pushes nothing.
log="$work/same.log"
cat > "$log" <<EOF
error: hash mismatch in fixed-output derivation '/nix/store/cccc1qv8w0k3n7d2m5x9c4b6f8h0j2l4-pad-0.15.0-go-modules.drv':
specified: $ORIG
got: $ORIG
EOF
pkg="$work/p7.nix"; make_pkg "$pkg"
before="$(cat "$pkg")"
"$script" "$log" "$pkg" >/dev/null 2>&1
check "idempotent: file is byte-identical" "$before" "$(cat "$pkg")"
# ---------------------------------------------------------------- case 8
# A package.nix with no vendorHash line is a usage error (2), not a silent pass.
log="$work/1274.log"
pkg="$work/p8.nix"; echo '{ pname = "pad"; }' > "$pkg"
"$script" "$log" "$pkg" >/dev/null 2>&1; rc=$?
check "no vendorHash line: exits 2" "2" "$rc"
# ---------------------------------------------------------------- case 9
# An unrelated `error:` line lands BETWEEN the go-modules header and its `got:`
# — CI interleaves output, so this is a real log shape. The armed state must be
# dropped rather than carried across it: carrying it would attribute the NEXT
# FOD's `got:` to vendorHash, and a wrong hash written confidently is worse than
# a build left red. Red is the correct outcome here.
log="$work/interleaved.log"
cat > "$log" <<'EOF'
2026-09-07T13:40:00.0000000Z error: hash mismatch in fixed-output derivation '/nix/store/dddd1qv8w0k3n7d2m5x9c4b6f8h0j2l4-pad-0.15.0-go-modules.drv':
2026-09-07T13:40:00.0000001Z specified: sha256-8L7gH7Yy5+Fig3wK2SPLYSJjcY9nF/jumQ7PATJ3RIE=
2026-09-07T13:40:00.0000002Z error: unrelated interleaved failure from another build
2026-09-07T13:40:00.0000003Z got: sha256-NPMnpmNPMnpmNPMnpmNPMnpmNPMnpmNPMnpmNPMnpmA=
EOF
pkg="$work/p9.nix"; make_pkg "$pkg"
"$script" "$log" "$pkg" >/dev/null 2>&1; rc=$?
check "interleaved error: exits 1 rather than guessing" "1" "$rc"
check "interleaved error: leaves vendorHash alone" "$ORIG" "$(hash_in "$pkg")"
# --------------------------------------------------------------- case 10
# Two go-modules mismatch blocks in one log. FIRST wins, and exactly one hash is
# printed — the caller reads stdout as a single value, so a second line would be
# a silent corruption of whatever consumes it.
log="$work/twice.log"
cat "$work/1274.log" "$work/1275.log" > "$log"
pkg="$work/p10.nix"; make_pkg "$pkg"
out="$("$script" "$log" "$pkg" 2>/dev/null)"
check "two blocks: first wins" "sha256-2hHhUx/J9CgDGU4fE5EE2Y/h6nRTA5vZ7GzuQ2oajkk=" "$out"
check "two blocks: exactly one line on stdout" "1" "$(printf '%s\n' "$out" | wc -l | tr -d ' ')"
# --------------------------------------------------------------- case 11
# IMPERSONATION SET. Each of these carries the shape of a nix diagnostic without
# being one, and each must leave vendorHash alone. They exist because the first
# draft of this parser matched substrings, and every line below defeats that
# draft while looking, to a reader, like the real thing.
impersonation_case() {
local name="$1" text="$2"
local l="$work/imp.log" k="$work/imp.nix"
printf '%s\n' "$text" > "$l"
make_pkg "$k"
"$script" "$l" "$k" >/dev/null 2>&1
check "impersonation ($name): vendorHash untouched" "$ORIG" "$(hash_in "$k")"
}
impersonation_case "warning, not error" \
"warning: hash mismatch in fixed-output derivation '/nix/store/eeee1qv8w0k3n7d2m5x9c4b6f8h0j2l4-pad-0.15.0-go-modules.drv':
specified: $ORIG
got: sha256-IMPimpIMPimpIMPimpIMPimpIMPimpIMPimpIMPimpA="
impersonation_case "not a store path" \
"error: hash mismatch in fixed-output derivation '/tmp/fake-pad-0.15.0-go-modules.drv':
specified: $ORIG
got: sha256-IMPimpIMPimpIMPimpIMPimpIMPimpIMPimpIMPimpA="
impersonation_case "a name that merely CONTAINS -pad-" \
"error: hash mismatch in fixed-output derivation '/nix/store/oooo1qv8w0k3n7d2m5x9c4b6f8h0j2l4-other-pad-tool-1.0-go-modules.drv':
specified: $ORIG
got: sha256-IMPimpIMPimpIMPimpIMPimpIMPimpIMPimpIMPimpA="
impersonation_case "another package's go-modules FOD" \
"error: hash mismatch in fixed-output derivation '/nix/store/ffff1qv8w0k3n7d2m5x9c4b6f8h0j2l4-othertool-1.2.3-go-modules.drv':
specified: $ORIG
got: sha256-IMPimpIMPimpIMPimpIMPimpIMPimpIMPimpIMPimpA="
impersonation_case "trailing text after the drv path" \
"error: hash mismatch in fixed-output derivation '/nix/store/gggg1qv8w0k3n7d2m5x9c4b6f8h0j2l4-pad-0.15.0-go-modules.drv': while evaluating something
specified: $ORIG
got: sha256-IMPimpIMPimpIMPimpIMPimpIMPimpIMPimpIMPimpA="
impersonation_case "a bare got: line with no header at all" \
"some build tool says: got: sha256-IMPimpIMPimpIMPimpIMPimpIMPimpIMPimpIMPimpA="
# --------------------------------------------------------------- case 12
# A stray `got:` inside the armed block, BEFORE the real specified/got pair.
# After the timestamp strip such a line is byte-identical to nix's, so the only
# thing that separates them is the sequence.
log="$work/strayget.log"
cat > "$log" <<EOF
2026-09-07T14:00:00.0000000Z error: hash mismatch in fixed-output derivation '/nix/store/hhhh1qv8w0k3n7d2m5x9c4b6f8h0j2l4-pad-0.15.0-go-modules.drv':
2026-09-07T14:00:00.0000001Z got: sha256-STRAYstrayHASHstrayHASHstrayHASHstrayHASHstrayA=
2026-09-07T14:00:00.0000002Z specified: $ORIG
2026-09-07T14:00:00.0000003Z got: sha256-2hHhUx/J9CgDGU4fE5EE2Y/h6nRTA5vZ7GzuQ2oajkk=
EOF
pkg="$work/p12.nix"; make_pkg "$pkg"
out="$("$script" "$log" "$pkg" 2>/dev/null)"
check "stray got: before the pair is ignored" "sha256-2hHhUx/J9CgDGU4fE5EE2Y/h6nRTA5vZ7GzuQ2oajkk=" "$out"
# --------------------------------------------------------------- case 13
# Formatting variants of the vendorHash line. The rewrite must still land — an
# `= ` with extra spaces is a nix-fmt away and would otherwise silently return
# the whole class of PR to permanently red.
log="$work/1274.log"
for variant in 'vendorHash = "PLACEHOLDER";' ' vendorHash = "PLACEHOLDER";' 'vendorHash="PLACEHOLDER";'; do
pkg="$work/pv.nix"
printf '{\n%s\n}\n' "${variant/PLACEHOLDER/$ORIG}" > "$pkg"
"$script" "$log" "$pkg" >/dev/null 2>&1
check "spacing variant [$variant]" "sha256-2hHhUx/J9CgDGU4fE5EE2Y/h6nRTA5vZ7GzuQ2oajkk=" "$(hash_in "$pkg")"
done
# --------------------------------------------------------------- case 14
# Exit codes on the positive paths, and on the idempotent one. Asserting only
# the file contents lets a script that fails AFTER writing pass as success.
log="$work/1274.log"
pkg="$work/p14.nix"; make_pkg "$pkg"
"$script" "$log" "$pkg" >/dev/null 2>&1
check "positive path: exit 0" "0" "$?"
log="$work/same.log"
pkg="$work/p14b.nix"; make_pkg "$pkg"
"$script" "$log" "$pkg" >/dev/null 2>&1
check "idempotent path: exit 0" "0" "$?"
# --------------------------------------------------------------- case 15
# A log captured LOCALLY, with nix's real indentation and no runner timestamps.
# This is the shape a human gets from `nix build 2>&1 | tee`, which is what
# nix/package.nix's comment tells them to run. The first version of the parser
# folded the timestamp and the indentation into one strip and silently handled
# only the CI shape; nothing caught it until a case asserted the EXIT CODE
# rather than just that the file was unchanged — for a no-op input, "did not
# write" and "could not parse" leave identical files.
log="$work/local.log"
cat > "$log" <<EOF
error: hash mismatch in fixed-output derivation '/nix/store/iiii1qv8w0k3n7d2m5x9c4b6f8h0j2l4-pad-0.15.0-go-modules.drv':
specified: $ORIG
got: sha256-2hHhUx/J9CgDGU4fE5EE2Y/h6nRTA5vZ7GzuQ2oajkk=
EOF
pkg="$work/p15.nix"; make_pkg "$pkg"
out="$("$script" "$log" "$pkg" 2>/dev/null)"; rc=$?
check "local log (no timestamps): exit 0" "0" "$rc"
check "local log (no timestamps): rewrites" "sha256-2hHhUx/J9CgDGU4fE5EE2Y/h6nRTA5vZ7GzuQ2oajkk=" "$out"
# --------------------------------------------------------------- case 16
# Round-2 impersonations. Each one satisfied an EARLIER version of this parser.
impersonation_case "nested path under /nix/store" \
"error: hash mismatch in fixed-output derivation '/nix/store/fake/not-a-store-pad-x-go-modules.drv':
specified: $ORIG
got: sha256-IMPimpIMPimpIMPimpIMPimpIMPimpIMPimpIMPimpA="
impersonation_case "got: adjacent to nothing — three lines after specified:" \
"error: hash mismatch in fixed-output derivation '/nix/store/jjjj1qv8w0k3n7d2m5x9c4b6f8h0j2l4-pad-0.15.0-go-modules.drv':
specified: $ORIG
building '/nix/store/kkkk-something-else.drv'
copying path '/nix/store/llll-another' from 'https://cache.nixos.org'
got: sha256-IMPimpIMPimpIMPimpIMPimpIMPimpIMPimpIMPimpA="
impersonation_case "non-canonical (short) hash on both lines" \
"error: hash mismatch in fixed-output derivation '/nix/store/mmmm1qv8w0k3n7d2m5x9c4b6f8h0j2l4-pad-0.15.0-go-modules.drv':
specified: sha256-A=
got: sha256-B="
# Canonical `specified:`, short `got:`. Without this the length check on the got
# line is never exercised on its own — the specified line's check refuses the
# case above before the got line is reached, and a mutant that drops only the
# got-side length survives. Found by mutating, not by reading.
impersonation_case "non-canonical hash on the got: line only" \
"error: hash mismatch in fixed-output derivation '/nix/store/nnnn1qv8w0k3n7d2m5x9c4b6f8h0j2l4-pad-0.15.0-go-modules.drv':
specified: $ORIG
got: sha256-B="
# --------------------------------------------------------------- case 17
# ROUND-3 MUTATION HOLES. Each of these was added because a faithful mutant
# SURVIVED the suite: the case that should have caught it was being refused for
# a second reason, so it discriminated nothing about the rule it was written for.
H32='aaaa1qv8w0k3n7d2m5x9c4b6f8h0j2l4'
# Ours, but not the module set: another FOD of the same package. Only the
# `-go-modules` part of the anchor separates it.
impersonation_case "our package, a different FOD" \
"error: hash mismatch in fixed-output derivation '/nix/store/$H32-pad-0.15.0-source.drv':
specified: $ORIG
got: sha256-IMPimpIMPimpIMPimpIMPimpIMPimpIMPimpIMPimpA="
# A store path whose ONLY defect is a slash — everything else, including the
# 32-character store hash, is well formed. Without the single-segment rule this
# is accepted.
impersonation_case "a slash inside the derivation name" \
"error: hash mismatch in fixed-output derivation '/nix/store/$H32-pad-0.15.0/x-go-modules.drv':
specified: $ORIG
got: sha256-IMPimpIMPimpIMPimpIMPimpIMPimpIMPimpIMPimpA="
# Short `specified:`, CANONICAL `got:`. The mirror of case 16's last entry: with
# both short, the got-side rule refuses it first and the specified-side rule is
# never exercised.
impersonation_case "non-canonical hash on the specified: line only" \
"error: hash mismatch in fixed-output derivation '/nix/store/$H32-pad-0.15.0-go-modules.drv':
specified: sha256-A=
got: sha256-2hHhUx/J9CgDGU4fE5EE2Y/h6nRTA5vZ7GzuQ2oajkk="
# Our header, then a DIFFERENT FOD's complete mismatch block. The adjacency rule
# alone cannot refuse this — the npm block's specified/got ARE adjacent — so
# what refuses it is dropping the armed state at the second `error:`.
log="$work/twoblocks.log"
cat > "$log" <<EOF
error: hash mismatch in fixed-output derivation '/nix/store/$H32-pad-0.15.0-go-modules.drv':
error: hash mismatch in fixed-output derivation '/nix/store/${H32}bb-vitest-4.1.11.tgz.drv':
specified: $ORIG
got: sha256-IMPimpIMPimpIMPimpIMPimpIMPimpIMPimpIMPimpA=
EOF
pkg="$work/p17.nix"; make_pkg "$pkg"
"$script" "$log" "$pkg" >/dev/null 2>&1
check "a second error: block does not inherit our arming" "$ORIG" "$(hash_in "$pkg")"
echo "bump-vendor-hash: $pass passed, $fail failed${BVH_AWK:+ (awk: $BVH_AWK)}"
[[ "$fail" -eq 0 ]] || exit 1
# PORTABILITY LEG. The script is awk, and `awk` is a different program depending
# on where it runs: gawk on this workstation, mawk on the ubuntu-24.04 runners
# where the Dependabot path actually executes. A regex feature one accepts and
# the other rejects would pass every case above and still fail the only run that
# matters. So the suite re-runs itself once per awk implementation present,
# through a PATH shim, and is green only if all of them are.
if [[ -z "${BVH_SUBRUN:-}" ]]; then
rc=0
for impl in mawk gawk busybox-awk; do
case "$impl" in
busybox-awk) command -v busybox >/dev/null || continue; target="$(command -v busybox)" ;;
*) command -v "$impl" >/dev/null || continue; target="$(command -v "$impl")" ;;
esac
shim="$(mktemp -d)"
if [[ "$impl" == busybox-awk ]]; then
printf '#!/bin/sh\nexec %s awk "$@"\n' "$target" > "$shim/awk"
chmod +x "$shim/awk"
else
ln -s "$target" "$shim/awk"
fi
BVH_SUBRUN=1 BVH_AWK="$impl" PATH="$shim:$PATH" "$0" || rc=1
rm -rf "$shim"
done
exit "$rc"
fi
+16 -2
View File
@@ -59,8 +59,22 @@ buildGoModule {
go = go_1_26;
# Update alongside go.sum. Regenerate via:
# nix build .#default 2>&1 | grep -A2 'got:'
# Update alongside go.sum — though CI no longer depends on you remembering
# (TASK-2954). Every Nix run IN CI recomputes this in its working tree before
# judging the build — a local `nix build` does not; it just fails the way it
# always did, and the line below tells you how to fix it — so a PR's check is
# green exactly when the build passes
# with that PR's module set; and the `push: main` run commits the corrected
# value back, so main heals one commit after a merge. It exists because a hash
# Dependabot could not update made every Go bump PR permanently red, and a
# check that is always red is not a check.
#
# Updating it by hand still works and is still the faster loop locally:
# nix build .#default 2>&1 | tee /tmp/b.log; nix/bump-vendor-hash.sh /tmp/b.log
# Use that script rather than reading the hash out of the log by eye: this
# build has other fixed-output derivations (every npm tarball importNpmLock
# fetches is one), so `grep got:` can hand you a hash that belongs to
# something else entirely. The script anchors on the go-modules derivation.
vendorHash = "sha256-8L7gH7Yy5+Fig3wK2SPLYSJjcY9nF/jumQ7PATJ3RIE=";
subPackages = [ "cmd/pad" ];