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.
120 lines
5.3 KiB
Docker
120 lines
5.3 KiB
Docker
# Lint stage — fast feedback on formatting and lint issues
|
|
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
|
|
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
|
|
WORKDIR /src
|
|
COPY go.mod go.sum ./
|
|
RUN go mod download
|
|
COPY . .
|
|
|
|
# Cache-buster for the gate layers, and only for them. Docker
|
|
# invalidates COPY only when the copied content changes, so on an
|
|
# unchanged tree the gates below would be served from cache and the
|
|
# build would exit 0 having run nothing. script/cibuild and
|
|
# script/docker pass a fresh CHECK_EPOCH on every invocation.
|
|
#
|
|
# Two properties this depends on. ARG is per-stage, so the build stage
|
|
# below declares it again; one declaration here would leave that
|
|
# stage's gate cacheable. And each gate RUN must reference the value,
|
|
# because BuildKit hashes the expanded command: a declared but
|
|
# unreferenced ARG invalidates nothing.
|
|
#
|
|
# It sits below the dependency layers deliberately. Everything above it
|
|
# (the pinned base image, go mod download) keeps its cache; only the
|
|
# gates go cold.
|
|
ARG CHECK_EPOCH
|
|
RUN echo "gate fmt-check, epoch ${CHECK_EPOCH}" && make fmt-check
|
|
RUN echo "gate lint, epoch ${CHECK_EPOCH}" && make lint
|
|
|
|
# Build stage
|
|
# golang:1.25-alpine, 2026-07-23
|
|
FROM golang@sha256:56961d79ea8129efddcc0b8643fd8a5416b4e6228cfd477e3fd61deb2672c587 AS builder
|
|
|
|
# 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. $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. 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 script/bootstrap
|
|
|
|
COPY . .
|
|
|
|
# Hand the sources and caches to the unprivileged user, then drop root
|
|
# before running any checks or builds.
|
|
RUN chown -R builder:builder /src /home/builder
|
|
USER builder
|
|
|
|
# Fail the build unless the branch is green. Runs as non-root so the
|
|
# permission-denied test paths are exercised legitimately (root would
|
|
# bypass the chmod(0) the tests rely on).
|
|
#
|
|
# Second per-stage declaration of the gate cache-buster; see the lint
|
|
# stage above for why one is not enough. It is placed after USER so the
|
|
# drop to the unprivileged user still happens before the checks run.
|
|
ARG CHECK_EPOCH
|
|
RUN echo "gate check, epoch ${CHECK_EPOCH}" && make check
|
|
|
|
RUN make build
|
|
|
|
# Runtime stage
|
|
# alpine:3.22, 2026-07-23
|
|
FROM alpine@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
|
|
|
|
COPY --from=builder /src/sfdupes /usr/local/bin/sfdupes
|
|
|
|
ENTRYPOINT ["sfdupes"]
|