Run all linting in Docker via Dockerfile.lint + script/lint #46
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Owner ruling, sneak 2026-08-09: every lint run happens inside a Docker container, invoked through the
script/entrypoint. Docker is always available. Linting runs independently and does not need a cache. He has directed a PR for every repo not already set up this way.Reference implementation is
sneak/homoicon— copy its shape: a rootDockerfile.lintbuiltFROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240, which COPYs the repo in and runsgolangci-lint run --config .golangci.yml ./...as a build step, withscript/lintreduced to building it. Linting as a build step means a successful build IS a clean lint.This supersedes the bootstrap version-verification work scoped here. That fix existed to make the host linter match the pin; with nothing running on the host, the whole problem — including the
go installwriting to$GOPATH/binwhilecommand -vresolvesPATH— disappears. Remove the golangci-lint install fromscript/bootstraprather than hardening it.It also resolves the cross-worktree contamination observed here, where a run reported ten findings against paths under a worktree that had already been deleted.
Preserve the repo's non-root Dockerfile quirk where it applies to the main image; the lint image is separate and does not need it.
Two things to get right:
golangci-lint config verifyfetches its JSON schema over an unpinned live HTTPS call. Decide deliberately whether to include it.Definition of done
script/lintruns the linter only in Docker; no host golangci-lint path remains.script/lintruns on an unchanged tree both demonstrably execute the linter.make checkstill green.Canonical tracking issue: sneak/prompts#40
Implementation requirements (manager). This issue absorbs
#45, which scoped the same work
before the owner ruling fixed the shape; #45's acceptance criteria are
folded in below and it is being closed as superseded.
Shape
Dockerfile.lint, copyingsneak/homoicon's:FROMthedigest-pinned
golangci/golangci-lint:v2.12.2@sha256:5ccee...5240with the version/date comment REPO_POLICIES requires,
COPYgo.mod/go.sum,go mod download,COPY . ., then the linter asa build step. A successful build is a clean lint.
script/lintreduced to building it. No hostgolangci-lintpathmay remain anywhere.
script/bootstrap: delete thego install, the pin constants, theversion parser and
verify_golangci_lintoutright — do not hardenthem. Keep the
git/make/gopresence checks andgo mod download. Warn (do not fail) whendockeris absent, ashomoicon does. Update the header comment so it no longer describes
an install that does not happen.
The two traps
bitten twice (#32,
#39). Caching is waived by
ruling, so force the lint layers to execute: an
ARGreferencedinside each lint
RUN(the value must appear in the expandedcommand — BuildKit hashes that, not the declaration), with
script/lintpassing"$(date +%s)-$$". Homoicon has no suchguard; do not inherit that gap. Evidence required: two consecutive
script/lintruns on an unchanged tree, both showing the linteractually executing under
BUILDKIT_PROGRESS=plain, with timings.script/lintshells out todocker build,nothing inside an image build may call
make lintormake check.The main
Dockerfile's lint stage currently runs bothmake fmt-checkandmake lint, and its build stage runsmake check.Fix by following homoicon: inside the image build, invoke the gates
directly rather than through the aggregate — the build stage runs
the test and fmt-check entrypoints, never
script/check.Main Dockerfile
Keep its lint stage, invoking
golangci-lintdirectly instead ofmake lint, soscript/dockerandscript/cibuildremain completegates and lint still fails the build before compilation starts.
Consequences to handle:
with nothing syncing them is exactly what
#42 made a build failure;
add one guard that fails when the
FROMline in theDockerfilelint stage and the one in
Dockerfile.lintdiffer (image anddigest). One guard, not a third copy of the pin.
script/verify-linter-pinloses its subject: no linter binary iscopied between stages and bootstrap pins nothing. Retire it and its
README entry, replaced by the guard above — record that reasoning in
the commit and in
TODO.md, do not drop it silently.COPY --from=lintof the linter binary was the only edgeforcing BuildKit to finish linting before the build stage starts.
Removing it without replacing the edge ends fail-fast linting
silently and the build still exits 0. Keep an ordering edge
(homoicon uses
COPY --from=lint /src/go.sum /dev/null) and proveit holds.
ENV PATH=/home/builder/go/bin:$PATHand its comment exist becausebootstrap's
go installlanded there. Re-justify or remove.chownand drop to
builderbefore the tests run: as root,TestScanHardlinkRunFailsTogetherpasses when it should fail.Re-verify with
--user 0:0and the Go test cache disabled — acached
okfrom the build-time run reads as a false pass.CHECK_EPOCHstays on the remaining gateRUNs in both stages.golangci-lint config verifyHomoicon runs it; it fetches its JSON schema over an unpinned live
HTTPS call. Ruling: include it, and state the network requirement in
the README entry. If it turns out to fail closed without network, say
so on this issue rather than working around it.
Definition of done
make lintworks on a machine with Docker and nogolangci-lintinstalled — verify with the host binary masked off
PATH, and sayhow you masked it.
script/lintruns on an unchanged tree bothdemonstrably execute the linter; timings reported. Do not weaken the
no-cache rule to hit a time budget; report the cost instead.
script/lintfails with that specific finding and that
script/dockerfails atthe lint stage before the build stage starts, revert, confirm clean.
FROMlines disagree.make checkgreen;make dockergreen with every gatedemonstrably executing (no cached-gate green);
--user 0:0stillfails
TestScanHardlinkRunFailsTogetherwith the test cache off.script/lint,script/bootstrap, theretired
script/verify-linter-pin, and the Docker requirement forlinting.
TODO.mdentry at the top of Completed Steps, in the same commit.Implementation plan, per the spec in
#46 (comment). One
commit on
next(created fromorigin/main, which is where the branchdoes not yet exist).
Files
New root
Dockerfile.lint—FROMthe digest-pinnedgolangci/golangci-lint:v2.12.2@sha256:5ccee...5240with theversion/date comment,
WORKDIR /src,COPY go.mod go.sum ./,RUN go mod download,COPY . ., thenARG CHECK_EPOCHand thegates. Same ARG name as the main
Dockerfile— one concept, onename — with the value referenced inside each gate
RUN(echo "gate ..., epoch ${CHECK_EPOCH}" && ...), because BuildKit hashesthe expanded command and not the declaration. Gates: the drift
guard,
golangci-lint config verify,golangci-lint run.script/lint— reduced todocker build --build-arg CHECK_EPOCH="$(date +%s)-$$" -f Dockerfile.lint .. The-$$matters here specifically: twoscript/lintruns land inside the same second easily, anddate +%salone would cache the second one. No hostgolangci-lintinvocation remains anywhere.script/bootstrap— deleteGOLANGCI_LINT_VERSION,GOLANGCI_LINT_MODULE,GOLANGCI_LINT_REF,GOLANGCI_LINT_VERSION_TIMEOUT,golangci_lint_version(),verify_golangci_lint()and thego installoutright. Keep thegit/make/gopresence checks andgo mod download. Warn (notfail) when
dockeris absent. Header comment rewritten.Main
Dockerfile— lint stage keepsmake fmt-check(a gate,not the aggregate, and it shells out to no docker) and replaces
make lintwithgolangci-lint run --config .golangci.yml ./...plus the drift guard. Build stage:
COPY --from=lint /usr/bin/golangci-lintis replaced byCOPY --from=lint /src/go.sum /dev/null, which is what canonicalREPO_POLICIES.md:107prescribes as the ordering edge; thefail-fast property is then proven, not assumed.
RUN script/verify-linter-pinandENV PATH=/home/builder/go/bin:$PATHboth go, the latter because bootstrap no longer
go installsanything into it.
make checkbecomesmake testandmake fmt-check.CHECK_EPOCHstays on every remaining gateRUNin both stages, and the
chownplusUSER builderstill precedethe tests.
New
script/verify-lint-image-pin— the one drift guard. Readsthe
golangci/golangci-lintFROMreference out ofDockerfileand out of
Dockerfile.lintand fails naming both when theydiffer, image or digest. It restates neither pin, so it is a guard
and not a third copy. Invoked from the gates in both files, so
make lint,make checkandmake dockerall catch drift.script/verify-linter-pinretired — deleted, with its READMEentry. Its subject is gone: no linter binary crosses between
stages and bootstrap pins nothing. The reasoning is recorded in the
commit message and at the top of
TODO.md's Completed Steps ratherthan dropped silently.
README §Entrypoints —
script/bootstrap,script/lint,script/check,script/docker, the retiredscript/verify-linter-pin, the new guard, and the statement thatlinting (hence
make checkand the pre-commit hook) now requires aworking Docker plus network access for
config verify.Two intended deviations, flagged now
make testandmake fmt-checkratherthan
script/testandscript/fmt-checkdirectly. Both are thesame code — the targets are thin shims — but the
Makefileexport CGO_ENABLED = 0only applies when entry is throughmake, and today'smake checkgets it. Calling the scripts barewould silently change the test build's cgo setting. This still
satisfies "the gates directly, never the aggregate":
checkis theaggregate,
testandfmt-checkare not.REPO_POLICIES.md:92says all Dockerfiles must runmake check, and its worked example at line 123 showsRUN make lintin the lint stage. Both become impossible oncescript/lintshells out to
docker build. I am taking the owner ruling and thisissue's spec as superseding those two lines and am not editing the
vendored policy file; raising it here so the divergence is on the
record rather than discovered later.
Verification I will run and quote
Under
BUILDKIT_PROGRESS=plain, with wall-clock timings, and with noglobal prune of any kind (invalidation scoped to
CHECK_EPOCH):make lintwith the hostgolangci-lintmasked offPATH,stating the masking method.
script/lintruns on an untouched tree, quotingthe linter lines from both.
script/lintwiththat exact finding, and failing
script/dockerat the lint stagewith the build stage never starting; then reverted and clean.
FROMlines.make checkgreen;make dockergreen with every gate shownexecuting.
--user 0:0still failingTestScanHardlinkRunFailsTogetherwiththe Go test cache disabled.
golangci-lint config verifyfails closed without network,reported here either way.
Landed on
nextasbeb865ae6819d8ffd85939142dd835262197c4dd— "Run all linting in Dockervia Dockerfile.lint (closes #46)".
nextdid not exist onoriginandwas created from
origin/mainate6a91711;git fetch originimmediately before the commit and again before the push showed no
origin/next, so there was nothing to merge. No PR opened, perinstruction.
Files: new
Dockerfile.lintandscript/verify-lint-image-pin;rewritten
script/lintandscript/bootstrap;Dockerfile,README.md,TODO.mdupdated;script/verify-linter-pindeleted.All builds below ran under
BUILDKIT_PROGRESS=plain. No prune of anykind was run; invalidation was scoped to
CHECK_EPOCH.make lintwith no host golangci-lintMasking method: every
PATHentry containing an executablegolangci-lintwas dropped and the name was then shown not to resolve.Three directories had one:
That run exited 0. Every
make lintandmake checkquoted below wasrun through the same masking.
Two consecutive runs on an unchanged tree
Run 1, 42.2s wall:
Run 2, immediately after, no file touched, 41.8s wall. Note
#9 CACHED— that is the
COPY . .layer being served from cache, which isexactly the condition that produced the false greens in
#32 and
#39. The gates below it still
ran, under a different epoch:
27.7s and 28.7s of linter execution, not a sub-second cached exit. The
cost of the no-cache rule is therefore about 40s per
make lint; itwas not weakened to reduce that.
Negative control
Planted
var unusedIssue46Sentinel = 1at the end ofreport.go.make lintfailed in 45.3s with that specific finding:make dockerfailed in 50.4s at the lint stage, and the ordering edgeheld — the build stage never reached its
COPY --from=lint:Builder-stage steps that ran, complete list:
Step 4/12 is
COPY --from=lint /src/go.sum /dev/null. Occurrences inthat log of
COPY --from=lint,RUN script/bootstrap,gate testandmake build: 0, 0, 0, 0.Reverted;
make lintback to0 issues.in 32.0s wall.Drift guard
Three cases, all failing and naming both sides. Tag-only disagreement
(
v2.12.1againstv2.12.2, identical digest), run inside the realgate:
Digest-only disagreement (identical
v2.12.2tag, zeroed digest): exit1, same message. Unreadable reference (the
FROMline removedaltogether): exit 1 with
expected exactly one golangci/golangci-lint FROM reference, found 0— it fails rather than comparing two emptystrings and passing.
Restored, guard passes:
Dockerfile.lint and Dockerfile agree on golangci/golangci-lint:v2.12.2@sha256:5cceeef0...make checkandmake dockermake checkgreen, 51.4s wall, with the linter masked offPATH:ok sneak.berlin/go/sfdupes 1.877s coverage: 88.3% of statements, and0 issues.after 33.9s in the lint step.make dockergreen, 5m34.9s wall. All six gates executed under oneepoch, none cached:
The test gate genuinely ran rather than reporting a cached
ok:Bootstrap in the build stage behaves as intended and its warning path
fires (the builder image has no docker), and the
>/dev/nullredirections it uses throughout still work despite step 4/12 copying a
file to that path — the runtime remounts
/devperRUN:Non-root quirk
Built
--target builderand ran the test directly with the Go testcache disabled (
-count=1, plusGOFLAGS=-count=1), so no cachedokcould be mistaken for a pass:
--user 0:0: exit 1,--- FAIL: TestScanHardlinkRunFailsTogether (0.01s),scan_test.go:817: stats = {added:2 updated:0 removed:0 unchanged:0 skipped:0}, want both hardlink paths skippedbuilderuser (uid=1000), same command: exit 0,ok sneak.berlin/go/sfdupes 0.018sThe drop to the unprivileged user is still load-bearing.
golangci-lint config verify: it does not need networkIncluded per the ruling. The premise that it makes a live unpinned
HTTPS call turns out not to hold for the pinned image, so I measured it
rather than documenting a requirement that does not exist. Running the
pinned image directly, valid config:
--network none: exit 0And with a config containing
bogus-key-that-does-not-exist, to ruleout its silently skipping validation offline — identical output and
exit code both ways:
So the binary validates against a schema it embeds for its own version.
The README states that linting needs a working
dockerand nothingmore once the pinned image is local, rather than asserting a network
requirement. (A
--network=nonewhole-build test is not a valid probehere, incidentally: BuildKit includes the network mode in the layer
cache key, so it re-runs and fails at
go mod downloadbefore reachingthe gate.)
Decided differently from the spec
make testandmake fmt-check, notscript/testandscript/fmt-check. Same code — the targets arethin shims — but the
Makefile'sexport CGO_ENABLED = 0reachesonly what
makeinvokes, and today'smake checkgets it. Callingthe scripts bare would have silently changed the test build's cgo
setting as a side effect of this refactor. The spec's actual
requirement, never the
checkaggregate inside an image build, ismet.
config verify, because as measured above there is none. See thesection directly above.
ARGinDockerfile.lintis namedCHECK_EPOCH, matchingthe main
Dockerfilerather than introducing a second name for oneconcept.
script/lintpasses"$(date +%s)-$$"as specified;script/dockerandscript/cibuildkeep their baredate +%s,since a multi-minute image build cannot collide with itself inside
one second the way two 40-second lint runs can.
Two things noted, neither fixed here:
REPO_POLICIES.mdline 92 requires every Dockerfile torun
make check, and its worked example at line 115 showsRUN make lintin the lint stage. Both are now unreachable, sincemake lintshells out todocker buildand nothing inside animage build may do that. I took the owner ruling and this issue's
spec as superseding those two lines and left the vendored policy
file untouched; flagging it so the divergence is on the record.
golangci-lintstill warns that thegomodguardlinter isdeprecated in favour of
gomodguard_v2. Already tracked at#26, so no new issue filed
and nothing changed in
.golangci.ymlhere.