# Lint stage — fast feedback on formatting and lint issues # golangci/golangci-lint:v2.12.2, 2026-08-07 FROM golangci/golangci-lint@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 # The linter is invoked directly here, not through `make lint`. That # target now runs `docker build -f Dockerfile.lint`, and a docker build # cannot run a docker build: routing the gate through make would mean # nesting docker inside this image. Same reason `make check` is gone # from the build stage below. `make fmt-check` stays as it is — it is a # gate, not the aggregate, and it shells out to nothing. RUN echo "gate fmt-check, epoch ${CHECK_EPOCH}" && make fmt-check # The FROM above and the one in Dockerfile.lint pin the same linter # twice, and nothing else keeps them in sync; this fails the build when # they disagree. See the script for why it restates neither pin. RUN echo "gate lint-image-pin, epoch ${CHECK_EPOCH}" && \ script/verify-lint-image-pin # Same config-schema check Dockerfile.lint runs, kept here so this build # gates on exactly what script/lint gates on. It validates against a # schema the pinned binary embeds, so it needs no network. RUN echo "gate config verify, epoch ${CHECK_EPOCH}" && \ golangci-lint config verify --config .golangci.yml RUN echo "gate lint, epoch ${CHECK_EPOCH}" && \ golangci-lint run --config .golangci.yml ./... # 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 and go test can write # their caches when we drop to it below. $GOPATH/bin is deliberately not # on PATH: script/bootstrap no longer `go install`s anything (the linter # runs from a pinned image, never from a host install), so nothing lands # there and adding it would only widen what this image resolves. RUN adduser -D -u 1000 builder ENV HOME=/home/builder ENV GOPATH=/home/builder/go ENV GOCACHE=/home/builder/.cache/go-build WORKDIR /src # No-op file copy whose only purpose is the build-graph edge: it is what # makes this stage depend on the lint stage, and so what forces BuildKit # to finish fmt-check, the pin guard 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. It replaces a copy of # the linter binary itself, which is no longer wanted here: nothing in # this stage runs the linter, because `make lint` is now a docker build # and a docker build cannot run inside one. COPY --from=lint /src/go.sum /dev/null # Install development prerequisites the same way a developer does, # rather than duplicating the installs inline. Only script/ 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 script/ script/ 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). # # The gates are the individual targets, not `make check`: that aggregate # runs `script/lint`, which is now a docker build, and nothing inside an # image build may shell out to docker. Lint is not skipped by this — it # ran in the lint stage above, which this stage's COPY --from makes a # prerequisite. `make`, not the scripts directly, because the Makefile's # `export CGO_ENABLED = 0` applies only to what it invokes. # # 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 test, epoch ${CHECK_EPOCH}" && make test RUN echo "gate fmt-check, epoch ${CHECK_EPOCH}" && make fmt-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"]