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

script/lint runs the linter directly when it is already inside a
container and otherwise builds Dockerfile.lint, so the linter never runs
on a developer host. That closes three host-only mechanisms: the result
cache golangci-lint keys on file content rather than location, which
produced a confirmed false green and findings reported against other
checkouts; the host-global $TMPDIR/golangci-lint.lock, which fails a run
in a way no caller can distinguish from findings; and host/container
version skew, which hid thirteen findings on one repo.

Detection is on LINT_IN_CONTAINER=1, set by every Dockerfile, and on
nothing else. The two directions are not symmetric: a false negative
inside a container attempts a nested docker build, finds no daemon and
fails loudly, while a false positive on a host silently lints there,
which is the defect this issue exists to kill. /.dockerenv is therefore
rejected even as a fallback -- measured absent inside BuildKit RUN steps
and present on any host that is itself a container, so it fails in both
directions and one of them is the dangerous one.

Nothing else changes shape. The Dockerfile still runs make check,
script/check still runs test, lint and fmt-check, script/cibuild is
still a single docker build with CHECK_EPOCH and VERSION, and the Go
multistage lint stage and its COPY --from=lint ordering dependency
survive with ENV LINT_IN_CONTAINER=1 added. Dockerfile.lint is the
standalone developer-host path and carries the same CHECK_EPOCH guard,
with the ARG below the dependency layer so only the lint re-runs.

The script/bootstrap golangci-lint install and the per-checkout
GOLANGCI_LINT_CACHE/TMPDIR wrapper are deleted as superseded. Neither
has a caller left. A JS repo's yarn install stays: the rule is that no
lint verdict may come from a host invocation, not that no linter binary
may exist there, and in a repo whose formatter is its linter the
formatter necessarily runs on the host.

golangci-lint config verify is kept, on measurement. Under the pinned
v2.12.2 a bogus top-level key and a bogus key 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. It needs no network: every case reproduced
byte-identically under `docker run --network none`, in a container where
`getent hosts golangci-lint.run` exits 2.

Comment blocks were cut hard across every file this unit touches.
.dockerignore drops from 67 comment lines to 28, script/cibuild from 17
to 12, script/docker from 18 to 12, and prompts/REPO_POLICIES.md from
1182 lines to 907. What remains says why a line is load-bearing; the
discovery narratives are gone.
This commit is contained in:
2026-08-10 12:49:34 +00:00
parent 0620416869
commit 1e21653f44
12 changed files with 491 additions and 702 deletions

21
TODO.md
View File

@@ -21,6 +21,27 @@ fmt-check, and commit.
# Completed Steps
- 2026-08-10: Moved every lint run into a container. `script/lint` now runs the
linter directly when `LINT_IN_CONTAINER=1` and otherwise builds
`Dockerfile.lint`, so the linter never runs on a developer host — closing the
content-keyed result cache that produced a confirmed false green, the
host-global `$TMPDIR/golangci-lint.lock`, and host/container version skew.
Detection is on that marker alone: a false negative inside a container fails
loudly on the missing daemon, while a false positive on a host would silently
restore host linting, so `/.dockerenv` is rejected outright — measured absent
inside BuildKit `RUN` steps and present on hosts that are themselves
containers. Everything else keeps its existing shape: `make check` still runs
in the image, `script/cibuild` is still one build, and the Go multistage lint
stage survives with `ENV LINT_IN_CONTAINER=1`. `Dockerfile.lint` carries the
same `CHECK_EPOCH` guard, with the `ARG` below the dependency layer so only
the lint re-runs. The `script/bootstrap` golangci-lint install and the
per-checkout cache/lock/`.lint-cache` wrapper are deleted as superseded; a JS
repo's `yarn install` stays, since the rule is about where a verdict comes
from, not about which binaries exist. `golangci-lint config verify` was kept
on measurement: a bogus config key passes `golangci-lint run` with `0 issues`
and fails `config verify`, and every case reproduced byte-identically under
`--network none`, so the schema is embedded and the line costs no network.
Comment blocks across the touched files were cut hard in the same pass.
- 2026-08-09: Made a golangci-lint result belong to the tree that asked for it.
REPO_POLICIES.md now carries the canonical Go `script/lint`, which gives the
linter per-checkout `GOLANGCI_LINT_CACHE` and per-checkout `TMPDIR`. The two