All checks were successful
check / check (push) Successful in 1m38s
Per the owner ruling the linter runs in a container invoked through the script/ entrypoint, never installed on a host. Dockerfile.lint COPYs the repo into the digest-pinned golangci/golangci-lint:v2.12.2 image and runs `golangci-lint config verify` and `golangci-lint run` as build steps, so a successful build IS a clean lint. script/lint is reduced to building it, and works with a remote docker daemon, where bind mounts are impossible. script/bootstrap loses the `go install`, the pin constants, the version parser and verify_golangci_lint: with nothing linting on the host, the $GOPATH/bin versus PATH shadowing they diagnosed has no subject. It keeps the git/make/go presence checks and `go mod download`, and warns rather than fails when docker is absent. Traps for anyone changing this. A lint build on an unchanged tree exits 0 in under a second having run no linter -- #32 and #39 again. Caching is waived by ruling: Dockerfile.lint carries ARG CHECK_EPOCH referenced inside every gate RUN, because BuildKit hashes the expanded command and a declared but unreferenced ARG invalidates nothing. script/lint passes "$(date +%s)-$$"; the PID is there because two runs land in the same second easily and a bare epoch would cache the second. Nothing inside an image build may shell out to docker. The main Dockerfile's lint stage therefore invokes golangci-lint directly rather than `make lint`, and its build stage runs `make test` and `make fmt-check` rather than the `make check` aggregate, which reaches script/lint. Both stay `make` invocations rather than bare scripts because the Makefile's `export CGO_ENABLED = 0` only reaches what it invokes. COPY --from=lint /usr/bin/golangci-lint becomes COPY --from=lint /src/go.sum /dev/null. The copied binary was the only edge forcing BuildKit to finish linting before the build stage starts; dropping it without replacing the edge would have ended fail-fast linting silently under a still-green build. That no-op copy is the ordering edge canonical REPO_POLICIES.md prescribes. Nothing in the build stage runs the linter now, so ENV PATH=/home/builder/go/bin:$PATH goes with the `go install` that justified it. script/verify-linter-pin is retired with its README entry: it compared a linter binary against GOLANGCI_LINT_VERSION in script/bootstrap and neither subject still exists. The drift moved rather than went away -- the linter is pinned twice, as the FROM line of Dockerfile.lint and the FROM line of the Dockerfile lint stage, which is what #42 made a build failure. script/verify-lint-image-pin compares those two references to each other and restates neither pin; a hardcoded digest would be a third copy and the same drift one file further out. It runs as a gate in both files, and an unreadable reference is a hard failure rather than a vacuous pass. `golangci-lint config verify` is included per the ruling, and its unpinned live HTTPS schema fetch was measured rather than assumed: under --network none the pinned binary passes a valid config and rejects an invalid one with the jsonschema error, so it validates against a schema it embeds. That holds for the gate steps, none of which makes a network call, but not for the build around them -- Dockerfile.lint runs `go mod download` above the gates, so a cold cache needs the network and only a warm one lints offline, until go.mod or go.sum changes. Verified. `make lint` green with every PATH directory containing a golangci-lint removed and `command -v golangci-lint` empty. Two consecutive script/lint runs on an untouched tree both executed the linter, 27.7s and 28.7s under distinct epochs with the COPY layer CACHED above them. A planted unused variable failed script/lint with that finding, and failed `make docker` at the lint stage with the build stage stopped before its COPY --from=lint; reverted clean. The drift guard fails on tag-only, digest-only and unreadable-reference cases, naming both sides. `make check` green; `make docker` green in 5m35s with all six gates executing and the test gate reporting real coverage rather than a cached ok. In the builder image with the Go test cache off, --user 0:0 still fails TestScanHardlinkRunFailsTogether where the unprivileged user passes, so the non-root quirk is intact.
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
|
|
|
|
# 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"]
|