diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2cecab6f..a298ed63 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 86c4fd86..9022f851 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -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 }} diff --git a/Makefile b/Makefile index 43e2adbe..811b905d 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/nix/bump-vendor-hash.sh b/nix/bump-vendor-hash.sh new file mode 100755 index 00000000..d6018011 --- /dev/null +++ b/nix/bump-vendor-hash.sh @@ -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 [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 [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-`. 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" diff --git a/nix/bump-vendor-hash_test.sh b/nix/bump-vendor-hash_test.sh new file mode 100755 index 00000000..fe5d39a7 --- /dev/null +++ b/nix/bump-vendor-hash_test.sh @@ -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" < "$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" </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" </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" </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" </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 diff --git a/nix/package.nix b/nix/package.nix index 84f02317..d0a6944d 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -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" ];