Run every lint in a container via Dockerfile.lint (closes #40)
All checks were successful
check / check (push) Successful in 29s

The linter is no longer installed on the host and no longer invoked
there. script/lint is now `docker build -f Dockerfile.lint .` and
nothing else, with the linter running as a build step, so a successful
build of that file is a clean lint — and it works unchanged where the
docker daemon is remote and bind mounts are impossible.

That removes three host-only failure mechanisms rather than mitigating
them: the result cache keyed on file content rather than location, which
produced a confirmed false green and a string of findings reported
against other checkouts; the host-global $TMPDIR/golangci-lint.lock,
which fails a run with `parallel golangci-lint is running` in a way no
caller can distinguish from findings; and host/container version skew,
which hid thirteen findings on one repo. A container per run has its own
cache, its own lock and a binary pinned by digest.

Resolving the recursion this creates. script/lint is a docker build, so
a Dockerfile that runs `make check` would nest a build inside a build
step where there is no daemon. Fixed by direction, not detection: the
main Dockerfile runs script/test and script/fmt-check individually, with
a comment saying why `make check` must not come back, and script/cibuild
runs script/lint first for fail-fast feedback. script/check still runs
all three, so developers and the pre-commit hook are unaffected.

Dockerfile.lint carries the same CHECK_EPOCH guard as the main image,
with the ARG placed below the dependency layer so only the lint steps
re-run. Blanket --no-cache was rejected: it re-runs the dependency
install on every lint and makes linting network-dependent.

golangci-lint config verify is kept, on measurement rather than
preference. Under the pinned v2.12.2, a bogus top-level key and a bogus
key nested under linters.settings.lll both pass `golangci-lint run` with
exit 0 and `0 issues` while config verify exits 3 and names them; an
unknown linter name fails run and passes config verify. The two catch
disjoint classes, and `run` alone silently ignores the class where a
threshold reads as configured and is not applied. The concern that
config verify fetches its JSON schema over live HTTPS does not hold for
this version: every case reproduced byte-identically under
`docker run --network none`, in a container where `getent hosts
golangci-lint.run` exits 2. The schema is embedded in the pinned binary.

Two canonical forms are superseded and deleted rather than left standing
beside the new one, because consuming repos read these documents
literally and two contradictory canonical script/lint forms is worse
than either. The script/bootstrap golangci-lint install landed for
#28 is removed: nothing invokes
a host linter now, so it can only reintroduce the skew it was written to
close. Its version-enforcement principle — compare version not presence,
re-resolve through PATH after installing, let a mis-parse fall through
to reinstall, and call it — stays documented for any other pinned host
tool. The per-checkout GOLANGCI_LINT_CACHE/TMPDIR wrapper is removed
with it; its entire subject was making a host run trustworthy. Adopting
repos delete .lint-cache/ from .gitignore and .dockerignore too. The Go
multistage lint stage and its COPY --from=lint ordering trick go the
same way: that stage ran `make lint`, which is now a docker build.

Corrected everywhere the claim that a successful docker build implies
lint passed — REPO_POLICIES.md, both repo checklists, the Go styleguide
and the README. The guarantee now belongs to script/cibuild, which runs
both container builds; a bare `docker build .` never lints at all.

Verified in this repo, not only documented: two consecutive script/lint
runs on a byte-identical tree both executed prettier (4.556s and 3.738s,
lint layers DONE with a fresh epoch printed, dependency layers CACHED as
intended); a planted violation failed the build naming the file, and
reverting it went green; a bare `docker build -f Dockerfile.lint .`
failed on the guard; make check, script/docker and script/cibuild all
green with the check layers demonstrably executing; and the main image
build completed without attempting a nested build.
This commit is contained in:
2026-08-10 12:49:34 +00:00
parent 0620416869
commit 12e8db8b0e
11 changed files with 560 additions and 664 deletions

View File

@@ -1,6 +1,13 @@
#!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own
# extension to scripts-to-rule-them-all. Must not modify any files.
#
# script/lint is a docker build (see Dockerfile.lint), so this script
# requires a docker daemon. That is deliberate: it is the only way a
# developer and the pre-commit hook get the same linter CI gets. It also
# means this script must never be run from inside a build stage — see
# the comment in Dockerfile, which runs the individual non-lint checks
# for exactly that reason.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"

View File

@@ -1,14 +1,21 @@
#!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs script/check, but
# that only proves anything because CHECK_EPOCH is a fresh nonce on every
# invocation: without it Docker serves the check layer from cache on an
# unchanged tree and the build exits 0 without running the suite.
# script/cibuild: run the CI build. Two container builds, in order:
# script/lint (Dockerfile.lint) and then the main image, which runs the
# non-lint checks. Both only prove anything because each passes its own
# fresh CHECK_EPOCH nonce: without it Docker serves the check layers
# from cache on an unchanged tree and the build exits 0 without running
# anything.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
# Lint first, for fail-fast feedback: it is its own container build
# and computes its own CHECK_EPOCH. It runs here rather than inside
# the main image because a docker build cannot run a docker build.
"$SCRIPT_DIR/lint"
# Assign on its own line: a failing command substitution inside an
# argument does not trip `set -e`, which would silently degrade the
# nonce to an empty constant. `$$` is required because busybox `date`

View File

@@ -1,13 +1,27 @@
#!/bin/sh
# script/lint: run the linter.
# script/lint: run the linter. The linter is never installed on the host
# and never invoked there — it runs in a container, one way, everywhere,
# so a run cannot inherit another checkout's cache, another process's
# lock, or a host toolchain that differs from the pinned one. Linting
# happens as a build step (see Dockerfile.lint), so a successful build
# is a clean lint, and it works where the docker daemon is remote and
# bind mounts are impossible.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
echo "Linting markdown files..."
yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always
# Assign on its own line: a failing command substitution inside an
# argument does not trip `set -e`, which would silently degrade the
# nonce to an empty constant. `$$` is required because busybox `date`
# drops %N without erroring. Without a fresh nonce the lint layer is
# served from cache and this script exits 0 having linted nothing.
epoch="$(date +%s%N)$$"
docker build \
--build-arg CHECK_EPOCH="$epoch" \
-f Dockerfile.lint \
.
}
main "$@"