The guard test in cmd/vaultik/lintdocker_test.go tried to prove that no script runs the linter outside the container by parsing shell scripts with a hand-written scanner. Four reviews each found another spelling it missed; such a parser cannot be complete, and nobody could follow it in one reading. The scanner, its helpers and their tests are deleted. The plain Dockerfile.lint assertions stay: the linter image is pinned by digest, config verify runs before run, and the per-run value reaches both steps. TODO.md no longer claims a test proves the property; script/lint is the only lint entry point, and keeping it so is a review matter. Judgement call: this drops a guard two reviewers asked to harden. model: claude-opus-4-8 (implementation, review); claude-fable-5-1 (decision, merge)
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 `revive` 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 ./...
|