All checks were successful
check / check (pull_request) Successful in 2m58s
Every lint run now happens inside its own container, invoked through script/lint, and linting is a build step rather than a container command: a successful build of the new root Dockerfile.lint IS a clean lint. That shape also works where the docker daemon is remote and bind mounts are impossible. Its FROM line -- golangci/golangci-lint:v2.12.2, pinned by digest -- is now the only pin of the linter version in this repo. A container per run has its own lint cache and its own golangci-lint lock, both discarded with it, so neither cross-worktree contamination nor lock contention exists any more. The machinery that defended against them is therefore gone: the per-worktree cache directories, the lock-retry loop, and script/lint-audit, which existed to catch findings replayed from a cache that no longer exists. So is the host lint path in its entirety -- the native escape hatch, its version detection, and VAULTIK_LINT_IN_CONTAINER in both script/lint and the Dockerfile. Nothing lints on the host, at any version. A cached build lints nothing, so the CHECK_EPOCH mechanism the product Dockerfile already used is what makes a green mean something: ARG CHECK_EPOCH with no default, placed below the module layers so dependency caching survives, a `RUN [ -n "$CHECK_EPOCH" ] || exit 1` guard so a build that withholds the arg fails instead of replaying, and the value expanded into the lint command itself. script/lint computes `epoch="$(date +%s%N)$$"` as a bare assignment on its own line, because inline in the argument a failing substitution does not abort under `set -eu` and yields a constant empty epoch -- which is exactly the false green being prevented. The product Dockerfile loses its lint stage rather than gaining a second linter pin. That stage ran `make lint`, which is now `docker build`: docker-in-docker inside a BuildKit step with no daemon. Calling golangci-lint directly there instead would have meant two independently bumpable digests for one tool. `make fmt-check` moves beside `make test` in the builder stage, and script/cibuild now builds Dockerfile.lint and then Dockerfile, each with its own fresh epoch, failing on either. Consequence, stated in comments rather than left to be discovered: script/docker builds the product image only and no longer lints; script/check and script/cibuild are the gates. `golangci-lint config verify` runs as its own epoch-keyed layer, above the lint. It is not belt-and-braces: `golangci-lint run` rejects a config it cannot PARSE but silently IGNORES an unknown top-level KEY. Renaming .golangci.yml's `linters:` to `linterz:` -- one character -- discards `default: all`, the disable list and every threshold, leaves only the small default linter set running, and exits 0 reporting `0 issues.` on a tree the real config fails with an lll finding, in a run whose lint layer demonstrably executed. That is a set-but- ineffective config falling back to defaults instead of failing loudly, sitting in the gate's own configuration. `config verify` catches it and does so with the network genuinely off at this pin: under `docker run --network none` against the pinned digest it exits 0 on this repo's config and exits 3 on the `linterz:` variant. It is keyed on CHECK_EPOCH like the lint itself, because a cached validation validates nothing. script/lint-fix is kept, reimplemented as a bind-mounted docker run against the image parsed out of Dockerfile.lint -- a build step cannot write fixes back to the worktree -- and its header states outright that it is a developer convenience, never a gate, and needs a local daemon. cmd/vaultik/lintdocker_test.go parses both Dockerfiles and both scripts and fails if any part of the mechanism is dropped: the digest pin, the defaultless ARG below `go mod download`, the emptiness guard, the expansion of the epoch into each check command, the bare per-invocation epoch assignment in both scripts, cibuild building both files, the config verification running before the lint, and -- structurally, not by searching for one retired variable name -- that no script invokes golangci-lint except through docker. Every one of those losses is silent: the build still exits 0 and nothing is checked, which is why they are asserted rather than trusted. The scanner behind the last of those has its own test, because a structural check that goes blind passes on every tree, including a broken one. script/lint takes no arguments now, and says so instead of dropping them: a build step has no command line to pass linter flags to.
105 lines
5.4 KiB
Docker
105 lines
5.4 KiB
Docker
# 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 ./...
|