Fail the Docker build when the lint stage's linter is not the pin
All checks were successful
check / check (push) Successful in 1m23s

The reordered COPY --from=lint did not make the two stages provably one
toolchain, as the Dockerfile comment, the previous commit message and
TODO.md all claimed. script/bootstrap compares its pin against whatever
PATH resolves, and $GOPATH/bin sits ahead of /usr/local/bin, so any
drift was absorbed: bootstrap rebuilt the pinned version from source,
verified that, and the build went green with the lint stage having
linted at one version and make check having run at another. Bumping the
lint stage image without touching the pin was enough to produce it.

New script/verify-linter-pin fails, naming both versions, unless a given
golangci-lint binary is exactly the version script/bootstrap pins. The
build stage runs it on the binary copied out of the lint stage,
immediately after the copy and before bootstrap, so no reinstall can
satisfy it. The pin is read out of script/bootstrap, which stays its
single source of truth; a pin that cannot be read is a hard failure
rather than a skip. The check takes no CHECK_EPOCH because its only
inputs are the copied binary and script/, so Docker invalidates the
layer exactly when a cached result would stop being true.

The linter version is pinned independently in the lint stage's image
digest and in GOLANGCI_LINT_VERSION, with nothing keeping them in sync;
a half-applied bump is now a build failure instead of a silent split.

ENV PATH keeps $GOPATH/bin, but its comment no longer claims a reinstall
is the reason: bootstrap must be able to run and verify what it
installs, and nothing in this image is shadowed by the entry.

Verified: with the lint stage's linter faked to 2.11.0 after the gates
had really run, the build fails at verify-linter-pin naming 2.11.0 and
2.12.2, with bootstrap and the check gate never reached; an unmodified
make docker is green with all three gates run on a fresh epoch and real
test results. A planted unused finding still fails at the lint stage
with gate check absent from the log; the image still fails
TestScanHardlinkRunFailsTogether under --user 0:0 and passes as uid
1000, both with the Go test cache disabled; and a second build serves
bootstrap, the verify layer and the dependency layers CACHED while the
gates go cold.
This commit is contained in:
clawbot
2026-08-09 15:29:13 +00:00
parent 3a183aa64b
commit 5ca68804ac
4 changed files with 177 additions and 27 deletions

42
TODO.md
View File

@@ -40,18 +40,36 @@
is gone. `COPY --from=lint /usr/bin/golangci-lint` stays, and moves
above the bootstrap layer. It is the only edge making this stage
depend on the lint stage, so deleting it as redundant would end
fail-fast linting silently; putting it first also means bootstrap's
version check now compares the lint stage's linter against the pin
on every build, which is what makes the two stages provably one
toolchain instead of two that happen to agree. Letting bootstrap
install its own linter here would have reintroduced the second
toolchain and paid for a from-source build of it. `$GOPATH/bin`
joins `PATH` so that if the copied binary ever stops matching the
pin, bootstrap's reinstall lands somewhere `PATH` resolves rather
than failing its own verification. Everything added sits above
`ARG CHECK_EPOCH`, and the `chown` and `USER builder` still precede
`make check`. Verified: bootstrap runs clean under Alpine's `sh` and
its `apk` branch, installing `git` and `make` and finding the copied
fail-fast linting silently. Letting bootstrap install its own linter
here would have reintroduced the second toolchain and paid for a
from-source build of it. What makes the two stages provably one
toolchain rather than two that happen to agree is a new
`script/verify-linter-pin`, run in the build stage on the binary
that arrives from the lint stage, before bootstrap: it fails the
build naming both versions unless that binary is the version
`script/bootstrap` pins. Bootstrap's own check could not serve that
purpose — it reinstalls its pin from source and then verifies
whatever `PATH` resolves, so drift self-heals silently and a lint
stage image bumped on its own would lint at the new version while
`make check` ran at the old one, green. The linter version is pinned
in two independent places (the lint stage image digest and
`GOLANGCI_LINT_VERSION`) and nothing else keeps them in sync, so a
half-applied bump is now a build failure. The pin is read out of
`script/bootstrap`, which stays the single source of truth; a pin
that cannot be read is a hard failure, not a skip. The check needs
no `CHECK_EPOCH`: its only inputs are the copied binary and
`script/`, so Docker invalidates the layer exactly when a cached
result would stop being true, and it is documented with the other
entrypoints in the README. `$GOPATH/bin` joins `PATH` because
that is where bootstrap's `go install` lands and bootstrap verifies
its installs against what `PATH` resolves — nothing in the image is
shadowed by it, the directory does not exist until bootstrap runs.
Everything added sits above `ARG CHECK_EPOCH`, and the `chown` and
`USER builder` still precede `make check`. Verified: the guard fails
the build with both versions named when the lint stage's linter is
faked to a different version, and an unmodified build still passes
it; bootstrap runs clean under Alpine's `sh` and its `apk` branch,
installing `git` and `make` and finding the copied
linter already at the pin; a second build served the bootstrap and
dependency layers `CACHED` while both gates ran with a fresh epoch;
a planted `unused` finding failed the build at the lint gate in