Merge branch 'dockerfile-bootstrap': bootstrap the builder stage, guard the linter pin (closes #42)
All checks were successful
check / check (push) Successful in 1m19s
All checks were successful
check / check (push) Successful in 1m19s
The build stage installed prerequisites inline instead of running script/bootstrap, which canonical REPO_POLICIES forbids. It now copies script/ and the manifests and runs bootstrap. The first attempt claimed that reordering COPY --from=lint made the build fail loudly on linter drift. It did the opposite: bootstrap reinstalled to GOPATH/bin, which ENV PATH placed ahead of the copied binary, so verification passed by construction and lint and build stages could silently run different linters. Rather than soften the claim, the guarantee is now implemented. script/verify-linter-pin runs against the copied binary before bootstrap and fails, naming both versions, unless it matches the pin read out of script/bootstrap. Reviewed independently. The reviewer reproduced the negative control in both directions, including one the PR did not claim - bumping the pin alone also fails the build - and established that no version output or mangled pin file yields a silent pass.
This commit was merged in pull request #44.
This commit is contained in:
56
Dockerfile
56
Dockerfile
@@ -29,24 +29,68 @@ RUN echo "gate lint, epoch ${CHECK_EPOCH}" && make lint
|
||||
# golang:1.25-alpine, 2026-07-23
|
||||
FROM golang@sha256:56961d79ea8129efddcc0b8643fd8a5416b4e6228cfd477e3fd61deb2672c587 AS builder
|
||||
|
||||
RUN apk add --no-cache make
|
||||
|
||||
# We never build or run as root. Create an unprivileged user and point
|
||||
# HOME and the Go caches at its home so go build/test and golangci-lint
|
||||
# can write their caches when we drop to it below.
|
||||
# can write their caches when we drop to it below. $GOPATH/bin is on
|
||||
# PATH because that is where script/bootstrap's `go install` lands: a
|
||||
# tool bootstrap installs must be runnable afterwards, and bootstrap
|
||||
# verifies its own installs against what PATH resolves, so leaving that
|
||||
# directory unsearched would make any install it performs both unusable
|
||||
# and self-reported as shadowed. Nothing in this image is shadowed by
|
||||
# it: the directory does not exist until bootstrap runs.
|
||||
RUN adduser -D -u 1000 builder
|
||||
ENV HOME=/home/builder
|
||||
ENV GOPATH=/home/builder/go
|
||||
ENV GOCACHE=/home/builder/.cache/go-build
|
||||
ENV PATH=/home/builder/go/bin:$PATH
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Reuse the linter binary from the lint stage; the copy also forces
|
||||
# BuildKit to complete linting before this stage proceeds.
|
||||
# Reuse the linter binary from the lint stage. This copy is load-bearing
|
||||
# twice over and must not be deleted as redundant now that bootstrap
|
||||
# below can install a linter of its own:
|
||||
#
|
||||
# - It is the only thing making this stage depend on the lint stage,
|
||||
# so it is what forces BuildKit to finish fmt-check and lint before
|
||||
# compilation and tests start. Remove it and the fail-fast design
|
||||
# dies silently: the build stops gating on lint and still exits 0.
|
||||
# - Together with the check below it is what keeps the two stages on
|
||||
# one toolchain: `make check` here runs the very binary the lint
|
||||
# stage ran, not a second one that happens to agree. Bootstrap
|
||||
# installing its own linter here instead would restore exactly the
|
||||
# two-independent-toolchains problem the copy prevents (and cost a
|
||||
# from-source build of the linter).
|
||||
COPY --from=lint /usr/bin/golangci-lint /usr/local/bin/golangci-lint
|
||||
|
||||
# Fail the build, naming both versions, unless the binary that just
|
||||
# arrived from the lint stage is the version script/bootstrap pins.
|
||||
#
|
||||
# Nothing else enforces that. The linter version is pinned in two
|
||||
# independent places — the lint stage's image digest above and
|
||||
# GOLANGCI_LINT_VERSION in script/bootstrap — and bumping one alone is
|
||||
# an easy mistake. Without this check that mistake is invisible:
|
||||
# bootstrap below would see a version that is not its pin, quietly
|
||||
# rebuild the pinned one from source into a directory that is on PATH,
|
||||
# verify that, and exit 0. The build would go green with the lint stage
|
||||
# having linted at one version and `make check` at another, which is
|
||||
# precisely the divergence the copy above exists to prevent.
|
||||
#
|
||||
# It runs here, before bootstrap, so that a reinstall cannot satisfy it,
|
||||
# and it needs no CHECK_EPOCH: its only inputs are the copied binary and
|
||||
# script/, so Docker invalidates this layer exactly when a cached result
|
||||
# would stop being true.
|
||||
COPY script/ script/
|
||||
RUN script/verify-linter-pin /usr/local/bin/golangci-lint
|
||||
|
||||
# Install development prerequisites the same way a developer does,
|
||||
# rather than duplicating the installs inline. Only script/ (copied
|
||||
# above) and the dependency manifests are copied first, nothing else, so
|
||||
# this layer stays cached until the scripts or the dependencies change —
|
||||
# bootstrap ends in `go mod download`, which is why there is no separate
|
||||
# invocation of it here.
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
RUN script/bootstrap
|
||||
|
||||
COPY . .
|
||||
|
||||
# Hand the sources and caches to the unprivileged user, then drop root
|
||||
|
||||
10
README.md
10
README.md
@@ -493,6 +493,16 @@ and may be invoked directly. The provided entrypoints are:
|
||||
- `script/install-precommit` — install the git pre-commit hook that
|
||||
runs `script/precommit`. The hook is written to the common git
|
||||
directory, so the main checkout and every worktree share it.
|
||||
- `script/verify-linter-pin` — fail unless a `golangci-lint` binary
|
||||
(given as its argument, default whatever `PATH` resolves) is
|
||||
exactly the version `script/bootstrap` pins, naming both versions
|
||||
if not. The `Dockerfile` build stage runs it on the linter it
|
||||
copies out of the lint stage: the version is pinned independently
|
||||
in the lint stage's image digest and in `script/bootstrap`, and
|
||||
bumping one alone would otherwise be absorbed silently by
|
||||
bootstrap rebuilding its pin from source, leaving the two stages
|
||||
on different linters under a green build. The pin is read from
|
||||
`script/bootstrap`, which stays its single source of truth.
|
||||
|
||||
`script/docker` and `script/cibuild` both pass a freshly computed
|
||||
`CHECK_EPOCH` build argument, and the `Dockerfile`'s gate steps
|
||||
|
||||
59
TODO.md
59
TODO.md
@@ -29,6 +29,65 @@
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- install the Docker build stage's prerequisites by running
|
||||
`script/bootstrap` instead of `apk add --no-cache make` inline
|
||||
(2026-08-09, branch `dockerfile-bootstrap`, closes #42): canonical
|
||||
`REPO_POLICIES.md:97` requires it, and the inline install left the
|
||||
build stage maintaining its own notion of the toolchain — exactly
|
||||
the divergence #24 exists to close, one layer down. The stage now
|
||||
copies `script/` plus `go.mod`/`go.sum` and runs `script/bootstrap`,
|
||||
which ends in `go mod download`, so the separate invocation of that
|
||||
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. 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
|
||||
48.9s with the build stage's `make check` never starting; and the
|
||||
suite run in the image as `--user 0:0` fails
|
||||
`TestScanHardlinkRunFailsTogether`, so the drop to the unprivileged
|
||||
user is still load-bearing. That last check needs the Go test cache
|
||||
disabled — the first attempt reported `ok ... (cached)` as root,
|
||||
reusing the result the build-time run had left in the shared cache,
|
||||
which would have read as a pass. Build wall time, on a shared host
|
||||
running many concurrent builds and so noisy: 2m13s on an unchanged
|
||||
tree, 2m17s and 4m29s for two builds after a source change, 5m14s
|
||||
cold. Only the cold one breaches the policy ceiling, and not because
|
||||
of this change — `chown -R builder:builder /src /home/builder` walks
|
||||
the module cache and re-runs on every source change, and it alone
|
||||
varied between 77s and 210s across those four builds, which is also
|
||||
the whole spread in the totals. The same cold measurement against
|
||||
`main` is 5m03s with a 209s `chown`. Filed as #43
|
||||
- bust the Docker layer cache for the gate steps, so `script/cibuild`
|
||||
and `script/docker` cannot report a green they did not earn
|
||||
(2026-08-09, branch `cibuild-cache-bust`, closes #32): both scripts
|
||||
|
||||
102
script/verify-linter-pin
Executable file
102
script/verify-linter-pin
Executable file
@@ -0,0 +1,102 @@
|
||||
#!/bin/sh
|
||||
# script/verify-linter-pin: fail unless a golangci-lint binary is exactly
|
||||
# the version script/bootstrap pins. Takes the binary to check as its
|
||||
# argument, defaulting to whatever PATH resolves. Our own extension to
|
||||
# scripts-to-rule-them-all, not one of its entrypoints.
|
||||
#
|
||||
# The Dockerfile build stage runs this on the linter it copies out of the
|
||||
# lint stage, before anything else runs there. Without it, drift between
|
||||
# the two stages is silently absorbed: script/bootstrap reinstalls its
|
||||
# pinned version from source, verifies that, and the build goes green
|
||||
# with the lint stage having linted at one version and `make check`
|
||||
# having run at another. Bumping the lint stage image alone is enough to
|
||||
# produce that, and this is the check that turns it into a build failure
|
||||
# naming both versions.
|
||||
#
|
||||
# The pin is read out of script/bootstrap rather than restated here.
|
||||
# script/bootstrap is the single source of truth for the linter version,
|
||||
# and a second hardcoded copy of it is exactly the drift this script
|
||||
# exists to catch. A pin that cannot be read is therefore a hard failure
|
||||
# and not a skip: silently comparing against an empty string would turn
|
||||
# this check into the kind of unearned green it was written to stop.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
# Seconds to allow `golangci-lint --version` to run, so a wedged binary
|
||||
# stops the build instead of hanging it. Bounded by timeout(1) where that
|
||||
# exists; stock macOS has none, and there the call runs unbounded.
|
||||
VERSION_TIMEOUT="30"
|
||||
|
||||
version_output() {
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
timeout "$VERSION_TIMEOUT" "$1" --version
|
||||
else
|
||||
"$1" --version
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
# Resolve the argument before changing directory, so a relative path
|
||||
# means what the caller meant by it.
|
||||
bin="${1:-golangci-lint}"
|
||||
resolved="$(command -v "$bin" 2>/dev/null || true)"
|
||||
|
||||
cd "$ROOT"
|
||||
|
||||
pin="$(
|
||||
sed -n 's/^GOLANGCI_LINT_VERSION="\([^"]*\)".*/\1/p' script/bootstrap
|
||||
)"
|
||||
if [ -z "$pin" ]; then
|
||||
echo "verify-linter-pin: no GOLANGCI_LINT_VERSION assignment found" \
|
||||
"in script/bootstrap; that file is the single source of truth" \
|
||||
"for the linter version and this check cannot run without it" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$resolved" ]; then
|
||||
echo "verify-linter-pin: $bin: not found (pin is $pin)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Same output shape script/bootstrap parses:
|
||||
# golangci-lint has version X.Y.Z built with go1.26.5 from abc1234
|
||||
# so the version is the field after the literal word "version", with
|
||||
# any leading "v" stripped. stderr is left connected so a binary that
|
||||
# cannot execute (wrong architecture, missing shared library) says why
|
||||
# rather than being reported as merely unparseable.
|
||||
if ! out="$(version_output "$resolved")"; then
|
||||
echo "verify-linter-pin: $resolved --version failed; the binary" \
|
||||
"cannot be executed or timed out (pin is $pin)" >&2
|
||||
exit 1
|
||||
fi
|
||||
found="$(
|
||||
echo "$out" | awk '
|
||||
{
|
||||
for (i = 1; i < NF; i++) {
|
||||
if ($i == "version") {
|
||||
v = $(i + 1)
|
||||
sub(/^v/, "", v)
|
||||
print v
|
||||
exit
|
||||
}
|
||||
}
|
||||
}
|
||||
'
|
||||
)"
|
||||
|
||||
if [ "$found" != "$pin" ]; then
|
||||
echo "verify-linter-pin: $resolved reports" \
|
||||
"${found:-no parseable version}, but script/bootstrap pins" \
|
||||
"$pin" >&2
|
||||
echo "verify-linter-pin: these must be the same version — bump the" \
|
||||
"Dockerfile lint stage image and GOLANGCI_LINT_VERSION in" \
|
||||
"script/bootstrap together" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "verify-linter-pin: $resolved is $found, matching the" \
|
||||
"script/bootstrap pin"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user