# 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: on an unchanged # tree Docker would serve the gates below from cache and the build would # exit 0 having run nothing. script/cibuild and script/docker pass a # fresh CHECK_EPOCH; a build without one, such as a bare # `docker build .`, fails at the check right after the ARG. # # ARG is per-stage, so the markdown and build stages declare it again. # Each gate RUN must reference the value: BuildKit hashes the expanded # command, so a declared but unreferenced ARG invalidates nothing. Keep # it below the dependency layers so they stay cached. ARG CHECK_EPOCH RUN if [ -z "${CHECK_EPOCH}" ]; then \ echo "CHECK_EPOCH is unset; build via script/cibuild or script/docker" >&2; \ exit 1; \ fi # These gates call the tools directly, not through `make lint` or # `make fmt-check`: both run docker, which cannot run inside a docker # build. This step is the gofmt half of `make fmt-check`; the markdown # stage is its prettier half. gofmt's output is assigned to a variable # first so that its own exit status, as when it cannot parse a file, # still fails the step. RUN echo "gate gofmt, epoch ${CHECK_EPOCH}" && \ files="$(gofmt -s -l .)" && \ if [ -n "$files" ]; then \ echo "gofmt: files not formatted:" >&2; echo "$files" >&2; exit 1; \ fi # Fails the build when the FROM above and the one in Dockerfile.lint pin # different linter images. 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 ./... # Prettier stage: the prettier that formats this repository's Markdown, # never installed on a host. script/fmt and script/fmt-check build this # stage alone and run it with the repository mounted on /src. prettier # is installed in /tools so that the repository, mounted or copied onto # /src, cannot hide it. # node:22-alpine, 2026-02-22 FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS prettier WORKDIR /tools # yarn.lock pins prettier by hash, and --frozen-lockfile fails rather # than install anything yarn.lock does not name. COPY package.json yarn.lock ./ RUN yarn install --frozen-lockfile ENV PATH=/tools/node_modules/.bin:$PATH WORKDIR /src # Markdown stage: the Markdown half of `make fmt-check`, as a gate. FROM prettier AS markdown COPY . . # Second per-stage declaration of the gate cache-buster and its check; # see the lint stage above. ARG CHECK_EPOCH RUN if [ -z "${CHECK_EPOCH}" ]; then \ echo "CHECK_EPOCH is unset; build via script/cibuild or script/docker" >&2; \ exit 1; \ fi RUN echo "gate prettier, epoch ${CHECK_EPOCH}" && \ prettier --check '**/*.md' --tab-width 4 --prose-wrap always # 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 build cache at its home so go build and go test can write # it when we drop to it below. # # The module cache stays at the base image's default /go/pkg/mod and # belongs to root: script/bootstrap fills it as root. Do not move it # into the home and hand it over with `chown -R`: that walks every file # in it, which took from about 80 s to over ten minutes on a shared # host, depending on load. RUN adduser -D -u 1000 builder ENV HOME=/home/builder ENV GOPATH=/home/builder/go ENV GOMODCACHE=/go/pkg/mod ENV GOCACHE=/home/builder/.cache/go-build WORKDIR /src # No-op file copies whose only purpose is the build-graph edge: they # make this stage depend on the lint and markdown stages, so BuildKit # finishes those gates before compilation and tests start. Remove one # and the build silently stops gating on that stage and still exits 0. COPY --from=lint /src/go.sum /dev/null COPY --from=markdown /src/go.sum /dev/null # Install development prerequisites the same way a developer does. Only # script/ and the dependency manifests are copied first, so this layer # stays cached until they change. Bootstrap ends in `go mod download`. COPY script/ script/ COPY go.mod go.sum ./ RUN script/bootstrap # Hand builder only what it writes to, without walking the module cache. # This layer stays cached with bootstrap. # - /src itself: make build writes the binary into it, and git refuses # a repository whose top directory belongs to another user. # - the module cache's cache/download directory itself, not what is in # it: Go only reads the downloaded modules, but make build saves its # lookup of this module's own version from git there, in a new # directory named after the module path. # - builder's home: the go commands bootstrap ran as root left Go's # telemetry files there, a few small files. RUN chown builder:builder /src /go/pkg/mod/cache/download && \ chown -R builder:builder /home/builder # The sources are handed to builder as they are copied, so no layer has # to walk them. Then drop root before running any checks or builds. COPY --chown=builder:builder . . USER builder # Fail the build unless the branch is green. Runs as non-root: root # would bypass the chmod(0) the permission-denied tests rely on. # # The gate is `make test`, not `make check`, which runs docker; lint and # the format checks ran in the lint and markdown stages above. `make`, # not the script directly, because the Makefile's # `export CGO_ENABLED = 0` applies only to what it invokes. # # Third per-stage declaration of the gate cache-buster and its check; # see the lint stage above. It is placed after USER so the drop to the # unprivileged user still happens before the checks run. ARG CHECK_EPOCH RUN if [ -z "${CHECK_EPOCH}" ]; then \ echo "CHECK_EPOCH is unset; build via script/cibuild or script/docker" >&2; \ exit 1; \ fi RUN echo "gate test, epoch ${CHECK_EPOCH}" && make test # The version stamped into the binary: the VERSION build argument when # one is given, otherwise `git describe --tags --always` of the .git in # the build context (git is installed by script/bootstrap above). A # context that carries .git and still yields no version fails the build; # with neither, as from a source tarball, it is "dev". ARG VERSION RUN version="${VERSION:-$(git describe --tags --always || echo dev)}"; \ if [ -e .git ] && { [ -z "$version" ] || [ "$version" = dev ] || \ [ "$version" = unknown ]; }; then \ echo "no version could be derived although the build context carries .git" >&2; \ exit 1; \ fi; \ make build VERSION="$version" # Runtime stage # alpine:3.22, 2026-07-23 FROM alpine@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce COPY --from=builder /src/sfdupes /usr/local/bin/sfdupes ENTRYPOINT ["sfdupes"]