# Lint-only image: this is how the linter runs, everywhere. The repo is # COPYed into the pinned golangci-lint image and the linter runs as a # build step, so a successful build IS a clean lint. golangci-lint is # never installed on a host — one toolchain, pinned by digest, identical # on a laptop and in CI — and this works even when the docker daemon is # remote and bind mounts are impossible. # # script/lint builds this file. It is a separate image from the lint # stage of the main Dockerfile because script/lint must not depend on # the rest of that build; the two FROM lines are kept identical by # script/verify-lint-image-pin, run as a gate below. # golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07 FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 WORKDIR /src # Dependency layers first, so they stay cached across lint runs. COPY go.mod go.sum ./ RUN go mod download COPY . . # Cache-buster for the gate layers, and only for them. Caching of the # lint run is waived by ruling: COPY is invalidated only by changed # content, so on an unchanged tree the gates below would be served from # cache and this build would exit 0 in under a second having run no # linter at all. That exact false green has bitten this repo twice # already (#32, #39). script/lint passes a fresh value on every # invocation. # # Each gate RUN must reference the value, because BuildKit hashes the # expanded command and not the ARG declaration: a declared but # unreferenced ARG invalidates nothing. The ARG sits below the # dependency layers deliberately — everything above it keeps its cache, # only the gates go cold. ARG CHECK_EPOCH # The linter version is pinned in two places, here and in the main # Dockerfile's lint stage. Nothing else keeps them in sync, so a # half-applied bump is a build failure; see the script. RUN echo "gate lint-image-pin, epoch ${CHECK_EPOCH}" && \ script/verify-lint-image-pin # Validates .golangci.yml against golangci-lint's JSON schema. The # concern about this step was that it fetches that schema over a live, # unpinned HTTPS call; measured on the pinned image, it does not. The # binary carries the schema for its own version, so under # `--network none` this both passes on a valid config and still rejects # an invalid one with the jsonschema error. That holds for the gate # steps generally — none of them makes a network call — but not for # this build as a whole: `go mod download` above needs the network on a # cold cache, and under `--network none` a first build fails there # before reaching any gate. That layer stays cached, so only a warm # cache lints offline, until go.mod or go.sum changes. 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 ./...