Run script/bootstrap in the Docker build stage (closes #42) #44
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