# Lint image. # # Every lint run in this repo happens inside this image, invoked through # script/lint, and linting is a BUILD STEP rather than a container # command: a successful build of this file IS a clean lint. That shape # also works where the docker daemon is remote and bind mounts are # impossible, which `docker run` against a mounted worktree does not. # # This FROM line is the single source of truth for the linter version in # this repo. Nothing else pins golangci-lint: the product Dockerfile has # no lint stage, deliberately, so there is no second digest to bump and # no pair of pins that can drift apart. Bump the tag AND the digest here # and nowhere else. # # Note for readers coming from REPO_POLICIES.md: that document still # describes the older pattern, a lint stage inside the product # Dockerfile wired up with `COPY --from=lint /src/go.sum /dev/null`. # That pattern is superseded here by the owner's ruling recorded in # https://git.eeqj.de/sneak/vaultik/issues/113 -- lint runs in its own # image, per run, with its own cache and its own lock, which is what # makes concurrent runs on one host safe. The policy text is org-wide # and is being amended separately; this file is what this repo does. # # golangci/golangci-lint:v2.12.2, 2026-08-10 FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 WORKDIR /src # Copy the dependency manifests first so the module download layer stays # cached until they change. Everything above the ARG below is cacheable # on purpose; a cold module download on every lint would make the inner # loop unusable and buys nothing, because it is not what the gate is # asserting. COPY go.mod go.sum ./ RUN go mod download COPY . . # Force the check layers to execute on every invocation. # # CHECK_EPOCH must stay immediately above the RUNs below. Those layers # are keyed on its value, so they are cache-eligible only for a value # already built against this same tree; script/lint and script/cibuild # each pass a fresh value on every invocation, which is what makes their # green mean the linter really ran. Without it, `docker build -f # Dockerfile.lint .` on an unchanged tree exits 0 in well under a second # having linted nothing. # # The value is expanded into each check command itself rather than left # to a bare declaration, so the cache miss does not depend on BuildKit's # unreferenced-ARG handling staying as it is. It also puts the epoch in # the build log, where a reader can see the layer was keyed fresh. # # The guard is what makes a build that omits --build-arg fail instead of # lie. An unset ARG is an empty string, and an empty string is a # perfectly stable cache key: without the guard the first such build # lints and every one after it on an unchanged tree replays this layer, # executes nothing, and still exits 0. Failed steps are never cached, so # the guard fails on EVERY invocation rather than once. Do not give # CHECK_EPOCH a default value; a default would satisfy the guard with a # constant and restore the hole. ARG CHECK_EPOCH RUN [ -n "$CHECK_EPOCH" ] || exit 1 # Validate .golangci.yml before linting with it. # # This is not belt-and-braces; it closes a hole that `golangci-lint run` # leaves wide open. `run` rejects YAML it cannot PARSE, but it silently # IGNORES an unknown top-level KEY. Renaming `linters:` to `linterz:` -- # one character -- discards `default: all`, the whole disable list and # every threshold, leaves only golangci-lint's small default linter set # running, and exits 0 reporting `0 issues.` on a tree the real config # fails. Demonstrated on this repo at this pin, recorded on # https://git.eeqj.de/sneak/vaultik/pulls/114: with a planted # over-length line, `script/lint` exits 1 naming the `lll` finding with # `linters:` and exits 0 with `linterz:`. A set-but-ineffective config # quietly falling back to defaults is precisely the false-green class # this gate exists to eliminate, so it must not sit in the gate's own # configuration. # # `config verify` catches it, and it does so OFFLINE at this pinned # version -- verified, not assumed. Under `docker run --network none` # against the pinned digest it exits 0 on this repo's config and exits 3 # on the `linterz:` variant with `additional properties 'linterz' not # allowed`. An earlier revision of this file asserted the opposite, that # the schema is fetched over live HTTPS from an unpinned URL, and used # that to justify omitting this line. That claim was false at v2.12.2; # the schema is embedded. If a future bump reintroduces a network fetch # the failure is loud and this comment is where to record it. # # It is keyed on CHECK_EPOCH, like the lint run below, so it executes on # every invocation. Content-addressing alone would arguably be enough -- # .golangci.yml arrives through `COPY . .`, so a cache hit here implies # a byte-identical config was validated when the layer really ran. That # argument is exactly the one that would also excuse caching the lint # layer, and this repo has ruled it insufficient: a cached check layer # checks nothing, and the cost of being wrong is silent. Forcing it costs # milliseconds and puts the epoch in the log, where a reader can see that # this validation ran rather than being replayed. RUN echo "check epoch: ${CHECK_EPOCH}" && \ golangci-lint config verify --config .golangci.yml RUN echo "check epoch: ${CHECK_EPOCH}" && \ golangci-lint run --config .golangci.yml ./...