Run all linting in Docker via Dockerfile.lint + script/lint #113
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, and it works even where the docker daemon is remote and bind mounts are impossible.This supersedes the per-worktree cache-isolation approach previously scoped here. The controlled experiment run in this repo showed isolation fixes contamination but NOT lock contention — two concurrent runs with entirely separate cache directories still collided. A container per run has its own cache and its own lock, so the whole class goes away. Caching is explicitly waived by the owner.
Two things to get right, both of which would otherwise ship a false green:
docker build -f Dockerfile.lint .on an unchanged tree returns success in well under a second having run no linter. Force the lint layers to execute.golangci-lint config verifyfetches its JSON schema over an unpinned live HTTPS call, making lint network-dependent and breaking hash-pinning. Decide deliberately whether to include it.Also remove the golangci-lint install path from
script/bootstrapand the native escape hatch inscript/lint— nothing runs on the host any more, which also closes the context-gating problem raised earlier.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. Work lands on
next; anext->mainPR is opened from the first commit of this cycle.Shape
New root
Dockerfile.lint,FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240(the Debian-based tag, exactly as the owner's ruling names it — not the-alpinevariant currently inDockerfile). It COPYs the repo in and runsgolangci-lint run --config .golangci.yml ./...as a build step.script/lintreduces to building it.Four things this repo has to get right that the reference does not cover
1. A cached build lints nothing. The homoicon reference has no cache-busting; on an unchanged tree its
docker buildexits 0 in under a second having run no linter. Do not copy that. Reuse the mechanism this repo already established for exactly this inDockerfileandscript/cibuild:ARG CHECK_EPOCHimmediately above the lintRUNs, aRUN [ -n "$CHECK_EPOCH" ] || exit 1guard, the value expanded into the check command itself, no default value, andscript/lintcomputingepoch="$(date +%s%N)$$"as a bare assignment on its own line (inline in the argument, a failing substitution does not abort underset -euand yields a constant empty epoch — the exact false green being prevented). TheARGgoes belowCOPY go.mod go.sum/go mod downloadso dependency layers still cache.2. The
Dockerfilelint stage cannot survive as-is. It runsmake lint, which will now shell out todocker build— docker-in-docker, inside a BuildKit step, with no daemon. Recommended resolution: delete the lint stage fromDockerfileentirely (along withENV VAULTIK_LINT_IN_CONTAINER=1and the builder'sCOPY --from=lint /src/go.sum /dev/null), movemake fmt-checknext tomake testin the builder stage, and havescript/cibuildbuildDockerfile.lintfirst with a fresh epoch and thenDockerfilewith a fresh epoch, failing on either. Rationale:Dockerfile.lintthen becomes the single source of truth for the linter version. The alternative — keeping a lint stage that callsgolangci-lintdirectly — reintroduces two independently-bumpable digest pins, which is the drift issue #78 was filed over. If you take the alternative, say why in the PR body. Note the consequence either way:script/dockerbuilds only the product image and so no longer lints;script/cibuildis the gate andscript/checkrunsscript/lint. Say that plainly in a comment rather than leaving it to be discovered.3.
golangci-lint config verifyfetches its JSON schema over an unpinned live HTTPS call. Recommendation: omit it, and say inDockerfile.lintwhy. It makes the lint gate network-dependent on an unpinned remote resource, against the hash-pinning policy, and an upstream outage or an egress-less runner then produces a red that is not a lint verdict — the false-red class this repo has spent several issues eliminating. Include it only on evidence that it runs fully offline at this pinned version; if you test that, test it with the network actually off (docker run --network none) and record the output.4.
script/lint-fixcannot delegate to a build step — a build cannot write fixes back to the host tree. Recommendation: keep it, reimplemented as a bind-mounteddocker runagainst the image reference parsed out ofDockerfile.lint, with a comment stating outright that it is a developer convenience, never a gate, and that it needs a local daemon. It consults no exit code that any gate reads. Deleting it andmake lint-fixis acceptable if you prefer —lint-fixis not in the canonical scripts-to-rule-them-all set — but then say so in the PR body.Deletions
Everything on the host lint path goes: the native escape hatch and its version detection in
script/lint,VAULTIK_LINT_IN_CONTAINERin both files, the per-worktree cache directory machinery (cache_home,cache_dir,path_digest,prepare_cache,prune_dead_caches), the lock-retry loop, andscript/lint-auditwith itsrun_capture/audit_outputplumbing — a container per run has its own cache and its own lock, so neither contamination nor contention exists to audit for. Updatescript/bootstrap's docker-requirement text, which names "the Dockerfile's lint stage" throughout.Definition of done
Everything in the issue body, plus:
script/lintruns on an unchanged tree,--progress=plain, showing the lint layer executing (notCACHED) on both.--build-argcounterfactual: a baredocker build -f Dockerfile.lint .fails on the guard rather than exiting 0.make checkandscript/cibuildboth green, evidence recorded on the PR.TODO.mdupdated in the same commit; markdown formatted withmake fmt.Do not run
docker builder prune— the build cache is shared with other work on this host. Scope any invalidation with--no-cache-filter=<stage>.Commit title ends
(closes #113). Issue #88 and issue #103 describe defects in code this removes; leave them out of the commit message and they will be closed against this work separately.Implementation plan, working on
nextin a private clone.New
Dockerfile.lintat the repo root,FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240(the Debian tag named by the ruling; digest confirmed pullable on this host). Layout:WORKDIR /src,COPY go.mod go.sum ./,RUN go mod download,COPY . ., thenARG CHECK_EPOCH,RUN [ -n "$CHECK_EPOCH" ] || exit 1,RUN echo "check epoch: ${CHECK_EPOCH}" && golangci-lint run --config .golangci.yml ./.... No default on theARG; it sits below the module layers so dependency caching survives, and the value is expanded into the lint command itself rather than left to BuildKit's unreferenced-ARGhandling.script/lintreduces toepoch="$(date +%s%N)$$"as a bare assignment on its own line, thendocker build --build-arg CHECK_EPOCH="$epoch" -f Dockerfile.lint ., plus a docker-availability check that fails with a readable message. It will reject arguments rather than silently ignore them (a build step cannot take linter flags);BUILDKIT_PROGRESS=plainis the supported way to see the layers execute, so no flag plumbing is needed.Point 3 (
config verify): omitting it, with the reason written intoDockerfile.lint— it is a live unpinned HTTPS fetch of a JSON schema, so it makes the gate network-dependent and converts an upstream outage or an egress-less runner into a red that is not a lint verdict.Point 4 (
script/lint-fix): keeping it, reimplemented as a bind-mounteddocker runagainst the image reference parsed out ofDockerfile.lint'sFROMline, with a header stating outright that it is a developer convenience, never a gate, and that it needs a local daemon.Point 2 (
Dockerfilelint stage): taking the recommended resolution. Delete the lint stage,ENV VAULTIK_LINT_IN_CONTAINER=1andCOPY --from=lint /src/go.sum /dev/null; movemake fmt-checkbesidemake testin the builder stage under the existing epoch guard;script/cibuildbuildsDockerfile.lintwith a fresh epoch and thenDockerfilewith a second fresh epoch, failing on either.Dockerfile.lintbecomes the single source of truth for the linter version. The consequence —script/dockerbuilds the product image only and no longer lints,script/cibuildis the gate,script/checkrunsscript/lint— gets stated in a comment inscript/dockerand inREADME.md, not left to be discovered.Deletions: the native escape hatch and its version detection,
VAULTIK_LINT_IN_CONTAINERin both files,cache_home/cache_dir/path_digest/prepare_cache/prune_dead_caches, the lock-retry loop, andscript/lint-auditwith itsrun_capture/audit_outputplumbing.script/bootstrap's docker-requirement prose, theMakefiledepscomment and theREADME.mdentrypoint text all stop naming "the Dockerfile's lint stage".Tests: a parse-based guard beside the existing
cmd/vaultik/makefile_test.go, asserting the invariants whose loss is silent —ARG CHECK_EPOCHpresent inDockerfile.lintwith no default and belowgo mod download, the non-empty guard present, the value expanded into the lint command,script/lintandscript/cibuildcomputing the epoch as a bare assignment and passing it as--build-arg, and noVAULTIK_LINT_IN_CONTAINERor host-lint path left anywhere.Evidence to be recorded on the PR: two consecutive
script/lintruns on an unchanged tree with plain progress, both showing the lint layer executing rather thanCACHED; a negative control with a deliberate violation, then reverted; a baredocker build -f Dockerfile.lint .failing on the guard instead of exiting 0;make checkandscript/cibuildgreen with output showing the layers ran. Nodocker builder prune— invalidation is scoped by the epoch alone.One note for the record rather than a new issue:
REPO_POLICIES.mdin this repo still mandates the in-Dockerfilelint stage withCOPY --from=lint /src/go.sum /dev/null. The ruling supersedes it here; the policy text is org-wide and out of scope for this change, soDockerfile.lintwill carry a comment saying so.Landed on
nextas commit739de1e, inpull request #114 (the
next->mainPR for this cycle, opened from this commit as it isthe first since the last merge to
main). Full verification output isin the PR body.
What was built
New root
Dockerfile.lint,FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240,COPYing the repo in and running
golangci-lint run --config .golangci.yml ./...as a build step.script/lintreduces to building it. ThatFROMline is now the onlypin of the linter version anywhere in the repo.
Trap 1 (a cached build lints nothing):
ARG CHECK_EPOCHwith nodefault, below
COPY go.mod go.sum/go mod downloadso dependencylayers still cache,
RUN [ -n "$CHECK_EPOCH" ] || exit 1, and the valueexpanded into the lint command.
script/lintcomputesepoch="$(date +%s%N)$$"as a bare assignment on its own line.Trap 2 (the
Dockerfilelint stage): took the recommended resolution.The lint stage,
ENV VAULTIK_LINT_IN_CONTAINER=1andCOPY --from=lint /src/go.sum /dev/nullare gone;make fmt-checkmoved beside
make testin the builder stage;script/cibuildbuildsDockerfile.lintthenDockerfile, each with its own fresh epoch,failing on either. The consequence is stated in comments in
script/docker, in theDockerfileheader and inREADME.md:script/dockerbuilds the product image only and does not lint, soscript/checkandscript/cibuildare the gates.Trap 3 (
golangci-lint config verify): omitted, reason written intoDockerfile.lint. It fetches its JSON schema over an unpinned liveHTTPS call, which puts the gate's verdict at the mercy of a remote
resource outside this repo's hash-pinning discipline and turns an
upstream outage or an egress-less runner into a red that is not a lint
verdict. I did not test it offline, so I am not claiming it fails
offline — I am declining to add a network dependency to a gate. Note
that the config is not unvalidated in practice:
golangci-lint runrejects an unparseable or unknown-key config itself, at the gating
version.
Trap 4 (
script/lint-fix): kept, reimplemented as a bind-mounteddocker runagainst the image reference parsed out ofDockerfile.lint'sFROMline. Its header states outright that it is adeveloper convenience, never a gate, that no gate reads its exit status,
and that it needs a local daemon because a remote one cannot see these
files.
Deletions: the native escape hatch and
installed_version/pinned_version/in_lint_container,VAULTIK_LINT_IN_CONTAINERinboth files,
cache_home/cache_dir/path_digest/prepare_cache/prune_dead_caches, the lock-retry loop, andscript/lint-auditwith itsrun_capture/audit_outputplumbing.script/bootstrap's docker-requirement prose, theMakefiledepscomment and
README.mdno longer name "the Dockerfile's lint stage".script/lintnow takes no arguments and says so, rather than droppingflags a build step cannot accept.
cmd/vaultik/lintdocker_test.goparses both Dockerfiles and bothscripts and fails if any part of the mechanism is dropped — the digest
pin, the defaultless
ARGbelowgo mod download, the guard, theexpansion into each check command, the bare epoch assignment in both
scripts,
cibuildbuilding both files, and the absence of any host-lintescape hatch. Every one of those losses is silent.
How it was verified
script/lintruns on a byte-identical tree(
git status --shortcaptured before and after and compared), plainprogress: dependency layers
CACHEDin both, lint layer executing inboth with different epochs and ~55s of analysis each.
repoRoot;the build failed citing
cmd/vaultik/lintdocker_test.go:328:9: ... (noinlineerr)andscript/lintexited 1. Reverted, re-ran,0 issues., exit 0. Thatfinding was a real one this gate caught while the test file was being
written, not a staged one.
--build-arg: baredocker build -f Dockerfile.lint .failsat
RUN [ -n "$CHECK_EPOCH" ] || exit 1, and does so on both of twoconsecutive attempts (failed steps are never cached), rather than
exiting 0.
ARG CHECK_EPOCHtoARG CHECK_EPOCH=constantmakesTestLintDockerfileCannotBeCachedGreenfail.make checkgreen in 1m20s,script/cibuildgreen in 3m0s, both withthe check layers visibly executing and no
(cached)test lines.script/lint-fixsmoke-tested:0 issues., exit 0, tree unchanged.No
docker builder pruneat any point; every invalidation was scoped byCHECK_EPOCH.Two things flagged rather than fixed:
REPO_POLICIES.mdstill mandatesthe in-
Dockerfilelint stage, which this supersedes for this repo (thepolicy text is org-wide, so it was left alone, with a comment in
Dockerfile.lintpointing at the divergence), andgolangci-lintprints a
gomodguarddeprecation warning on every run, which is a.golangci.ymlmatter and out of scope here.Correction to my earlier comment on this issue, after review failed
pull request #114.
nextis now at
d257f8f(the same commit, amended).I wrote that
golangci-lint config verifywas omitted because itfetches its JSON schema over an unpinned live HTTPS call, and that
golangci-lint runrejects an unknown-key config itself. Both claimswere false at the pinned version, and I did not test either before
writing them. The point-3 note in this issue asked for the offline
test as the condition for including it; I did not run it, and declining
on an untested premise was the error.
Reproduced at
golangci/golangci-lint:v2.12.2@sha256:5cceeef0..., network genuinelyoff:
The schema is embedded; it validates offline and rejects the typo
offline. And
golangci-lint runcatches YAML it cannot parse butsilently ignores an unknown top-level key, so the omission left a live
false green in the gate's own config: with one planted over-length
comment line and nothing else changed,
script/lintexited 1 naming arevivefinding underlinters:and exited 0 with0 issues.underlinterz:, in a run whose lint layer executed for 49s.default: all,the disable list and every threshold were silently discarded.
config verifynow runs as its ownCHECK_EPOCH-keyed layer above thelint, and the comment block in
Dockerfile.lintrecords what wasmeasured rather than the retracted rationale. The other rework item was
the host-lint guard test, which asserted the absence of one retired
variable name and so could never fire again; it now asserts the property
structurally — no script invokes
golangci-lintexcept throughdocker— and was mutation-proved against a renamed escape hatch.Full verbatim evidence for both is on
pull request #114.
make checkandscript/cibuildare green with the check layersdemonstrably executing.