All linting must run in Docker: canonicalise homoicon's Dockerfile.lint + script/lint pattern #40
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:
He also directed that PRs go out to every repo not already set up this way.
Reference implementation —
sneak/homoicon, already doing exactly thisDockerfile.lint:script/lint:Note the design property worth preserving: linting happens as a build step, so a successful build is a clean lint, and it works even where the docker daemon is remote and bind mounts are impossible.
Why this matters beyond tidiness
It closes an entire family of defects found across the fleet on 2026-08-09, all of which were host-run artifacts:
0 issueson a branch that was genuinely red with agoconstfinding, because the shared~/.cache/golangci-lintis keyed on file content and served another tree's clean result.../wt82-lint/...,../agent-<other-id>/..., and a worktree that had already been deleted.parallel golangci-lint is running, exit 2, which a caller cannot distinguish from real findings. Proven NOT fixed by per-cache isolation: two concurrent runs with entirely separate cache directories still collided.A container per run has its own cache and its own lock, so none of it applies. Discarding the cache is the point, not a cost — the owner said so explicitly.
One trap this MUST NOT propagate
docker build -f Dockerfile.lint .on an unchanged tree returns a cached success in well under a second, having linted nothing — the same defect as #26, arriving through the new pattern. Given "linting always runs independently, we don't need a cache", the canonical form should force the lint layers to execute. Either--no-cacheon this build (acceptable here precisely because the owner has waived caching, and the image is pulled by digest so only the lint steps re-run), or theCHECK_EPOCHtreatment from #26. Whichever is chosen, the DoD below must prove it.Second caution, from
netwatch:golangci-lint config verifyresolves its JSON schema over an unpinned live HTTPS fetch. Inside a build step that makes the lint network-dependent and breaks hash-pinning. Decide deliberately whether to keep that line; an offline sha256 drift check is the alternative.Definition of done
script/lintandDockerfile.lintadded to this repo, with the lint image pinned by digest.script/lintruns on an unchanged tree BOTH demonstrably execute the linter — not a sub-second cached success.script/lintfails with that specific finding, revert, confirm clean.script/bootstrapno longer installs golangci-lint at all (see #28 — that guard is moot once nothing runs on the host).Implementation requirements (manager brief)
Scope for THIS repo, which is the canonical standards repo and is itself a
markdown/prettier repo with no Go code. The unit must therefore deliver both
(a) the canonical text other repos vendor, and (b) this repo's own working
containerised lint, since a canonical form that is not exercised here is not
evidence.
Deliverables
Dockerfile.lintin this repo, digest-pinned, running this repo's ownlinter (prettier over markdown) as build steps.
script/lintreduced to building that file. No host linter invocationremains anywhere.
Dockerfile.lint+script/lintinprompts/REPO_POLICIES.md, and the generic non-Go form (eslint, ruff,prettier) stated as the same pattern around a different linter.
prompts/NEW_REPO_CHECKLIST.mdandprompts/EXISTING_REPO_CHECKLIST.mditems updated to match. Check
prompts/CODE_STYLEGUIDE_GO.mdtoo — itcarries lint text.
script/bootstrap: remove the golangci-lint install entirely, and removethe canonical Go bootstrap snippet in
prompts/REPO_POLICIES.mdthatinstalls it. Nothing runs the linter on the host any more, so a pinned
host install is dead weight that can only reintroduce version skew. This
supersedes the mechanism landed for
#28; say so in the commit
body, and leave the version-enforcement principle documented for any
other pinned host tool.
TODO.mdentry in the same commit.Trap 1 — docker-in-docker recursion. This is the hard part.
Today
Dockerfilerunsmake check,script/checkrunsscript/lint, andscript/lintis about to becomedocker build. As written that recurses:the main image build would try to run a docker build inside a build step.
There is no docker daemon there, so it fails — or worse, on some runners it
does not fail in the way you expect.
Recommended resolution (implement this unless you can show it is wrong, and
disclose which you chose and why):
script/lint=docker build --build-arg CHECK_EPOCH="$epoch" -f Dockerfile.lint .script/checkstays test + lint + fmt-check on the host, so a developerand the pre-commit hook still get all three.
Dockerfileruns the NON-lint checks only (script/test,script/fmt-check), with a comment stating that lint is deliberatelyabsent because it runs in its own container, and that re-adding
make checkthere reintroduces the recursion.script/cibuildrunsscript/lintFIRST (fail-fast), then the maindocker build. Both builds pass their ownCHECK_EPOCH.make check" and"a successful build implies all checks pass" is now wrong in the same way
#26 found it wrong. Fix
every place it appears — policy, both checklists, Go styleguide, README —
not just the first.
The rejected alternative, for the record: having
script/lintdetect it isinside a container and run the linter natively. That keeps
make checkwhole but requires the linter installed in the app image, which is the host
install this issue removes, wearing a different hat.
Related: the existing policy rule "Dockerfiles must use a separate lint
stage for fail-fast feedback", with the
COPY --from=lint /src/go.sum /dev/nullordering trick, is now redundant withDockerfile.lintfor Gorepos. Reconcile it — either the stage goes and
Dockerfile.lintreplacesit, or both survive with a stated reason. Do not leave two canonical
patterns that contradict each other; consuming repos read this literally.
Trap 2 — a cached lint build is a lint that never ran
docker build -f Dockerfile.lint .on an unchanged tree returns asub-second cached success having linted nothing. Use the
CHECK_EPOCHpattern already canonical here:
ARG CHECK_EPOCHin every stage with alint
RUN,RUN [ -n "$CHECK_EPOCH" ] || exit 1, value expanded into thelint command, and
epoch="$(date +%s%N)$$"assigned on its own line inscript/lint. Place theARGAFTER the dependency-install layer so thedependency layer stays cached and only the lint steps re-run. Blanket
--no-cacheis not acceptable: it re-runsgo mod download/yarn installon every lint and makes linting network-dependent.Trap 3 —
golangci-lint config verifyfetches its JSON schema over live HTTPSInside a build step that makes lint network-dependent and defeats hash
pinning. Decide empirically, do not guess: plant a bogus key and an
invalid value in a
.golangci.ymland check whethergolangci-lint runalone fails on them under the pinned v2.12.2. If it does, drop the
config verifyline from the canonicalDockerfile.lintand say why inthe comment. If it does not, keep it and record the network dependency as
a disclosed cost. Either way, state the evidence.
Definition of done — proof required, not assertion
script/lintruns on a byte-identical tree BOTH executethe linter. Post wall times and the relevant build-log lines. A
sub-second run or
CACHEDon a lint layer is a failure.script/lintfails naming that specific finding, revert, show clean.docker build -f Dockerfile.lint .with no
--build-argfails on the guard.script/check,script/cibuildandscript/dockerall run green from aclean clone, and
script/cibuilddemonstrably executes rather thanreturning a warm-cache green.
attempting a nested build.
docker buildimplies lint passed, and for surviving host-lint instructions. Report the
grep, not just the conclusion.
Process
Pull
nextbefore starting; pull and resolvenextagain immediatelybefore committing and pushing, and re-run
make checkafter any conflictresolution — a clean textual merge can still break the build.
next, message ending(closes #40).docker builder pruneor any unscoped prune; this host's buildcache is shared with other sessions. Scope invalidation with
--no-cacheor
--no-cache-filter=<stage>on your own image only.make fmtbefore committing; markdown must be prettier-clean.repo does to adopt it, in order, including what it deletes.
Implementation plan
Working in a fresh clone on
next, one commit ending(closes #40), joiningthe open PR https://git.eeqj.de/sneak/prompts/pulls/34.
Trap 3 settled first, empirically, before writing anything
Ran the pinned image
golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240(
golangci-lint has version 2.12.2 built with go1.26.2 from c0d3ddc9) against ascratch Go module carrying this repo's canonical
.golangci.yml, one defect ata time:
golangci-lint runalonegolangci-lint config verifybogus-top-level-key: true)linters.settings.lll.bogus-nested-key)line-length: "not-a-number")- nosuchlinter)The two commands catch disjoint classes.
runalone silently ignoresunknown keys — which is precisely the mode where a threshold reads as configured
and is not applied. So
config verifyearns its place.And the netwatch caution does not apply to v2.12.2: every
config verifyabovewas re-run under
docker run --network noneand produced byte-identicaldiagnostics and exit statuses. Control for the control: in that same
--network nonecontainergetent hosts golangci-lint.runexits 2 (noresolution), while with the network it resolves. The schema is embedded in the
pinned binary; there is no live HTTPS fetch to defeat hash-pinning.
Decision: keep
config verifyin the canonicalDockerfile.lint, with theoffline evidence recorded in the comment, and require re-testing it on any
version bump rather than treating "embedded" as permanent.
Trap 1 — docker-in-docker
Implementing the recommended resolution:
script/lintbecomesdocker build --build-arg CHECK_EPOCH="$epoch" -f Dockerfile.lint .script/checkkeeps running test + lint + fmt-check, so developers and thepre-commit hook still get all three
Dockerfilerunsscript/testandscript/fmt-checkonly, with acomment stating lint is deliberately absent and that re-adding
make checkreintroduces the recursion
script/cibuildrunsscript/lintfirst (fail-fast), then the main build,each with its own epoch
Forced consequence worth naming up front: the canonical Go multistage lint
stage cannot survive as-is regardless of preference — it runs
make lint,which is now a
docker build. It is replaced byDockerfile.lint, and theCOPY --from=lint /src/go.sum /dev/nullordering trick goes with it.Trap 2
ARG CHECK_EPOCHplaced after the dependency-install layer, guardRUN [ -n "$CHECK_EPOCH" ] || exit 1, value expanded into the lint command,epoch="$(date +%s%N)$$"on its own line. No blanket--no-cache.Files
Dockerfile.lint(new),script/lint,script/check,script/cibuild,Dockerfile,README.md,TODO.md, and the canonical documents:prompts/REPO_POLICIES.md,prompts/NEW_REPO_CHECKLIST.md,prompts/EXISTING_REPO_CHECKLIST.md,prompts/CODE_STYLEGUIDE_GO.md.Two canonical sections are superseded rather than edited around, because leaving
them would give consuming repos two contradictory canonical
script/lintforms:script/bootstrapgolangci-lint install fromhttps://git.eeqj.de/sneak/prompts/issues/28
— removed; nothing runs the linter on the host any more, so a pinned host
install can only reintroduce version skew. The version-enforcement
principle stays documented for any other pinned host tool.
GOLANGCI_LINT_CACHE/TMPDIRwrapper fromhttps://git.eeqj.de/sneak/prompts/issues/30
— its entire subject is host-run state, which no longer exists. The findings
that motivated it are kept as the rationale for containerising, since they are
the evidence for this rule; the wrapper code and the
.lint-cache/entries go.Verification to be posted
Two consecutive
script/lintruns with wall times and the lint-layer log lines;negative control with a planted violation; guard control with a bare
docker build -f Dockerfile.lint .;script/check,script/dockerandscript/cibuildgreen with wall times proving execution; proof the main imagebuild attempts no nested build; and the full repo grep for surviving
"successful build implies lint passed" and host-lint claims.
Finding from implementing this in
sneak/dnswatcher(sneak/dnswatcher#134, landed via sneak/dnswatcher#136), because it affects the canonical shape every repo will copy.golangci-lint config verifyis a real tradeoff in both directions, and the reference implementationsneak/homoiconruns it..golangci.ymlare silently ignored. Verified during review — a bogus top-level key was appended andmake lintreturned exit 0,0 issues.A config typo therefore passes silently and the setting it was meant to apply simply never applies.That second failure mode is not hypothetical.
dnswatcher's.golangci.ymlcarries a comment at lines 3-5 that exists precisely because a v1-schemalinters-settingsblock was silently not applied under the v2 schema.dnswatcher dropped
config verifyand recorded the residual risk in a comment, on the grounds that hash-pinning is the stronger policy. Flagging it here so the org-wideDockerfile.lintshape settles this deliberately rather than by whichever repo was copied first — and sohomoiconanddnswatcherdo not silently diverge on it.A third option, if the silent-typo risk is judged unacceptable: vendor the schema into the repo and point
config verifyat the local copy, which keeps the check without the live fetch.clawbot referenced this issue2026-08-10 14:51:19 +02:00
Implemented and pushed
Commit
12e8db8b0e633e6b54114f5a2ab8c0af769fb07connext, joininghttps://git.eeqj.de/sneak/prompts/pulls/34
(section 6 of that PR body carries the full adoption instructions).
What was built
script/lintis nowdocker build -f Dockerfile.lint .and nothing else. Thelinter is not installed on the host and not invoked there. Linting runs as a
build step, so a successful build of that file is a clean lint, and it works
where the docker daemon is remote and bind mounts are impossible. This repo is
markdown + prettier, so it exercises the non-Go form of the pattern rather
than only documenting the Go one.
Trap 1, docker-in-docker — resolved as recommended, by direction rather than
detection. The main
Dockerfilerunsscript/testandscript/fmt-checkindividually instead of
make check, with a comment above thoseRUNlinesnaming what re-adding
make checkwould reintroduce;script/cibuildrunsscript/lintfirst for fail-fast feedback, then the main build, each with itsown epoch;
script/checkstill runs all three, so developers and the pre-commithook are unaffected.
One consequence was forced rather than chosen and is worth flagging: the
canonical Go multistage lint stage could not survive in any form, because it
ran
make lint, which is now a docker build. It and itsCOPY --from=lint /src/go.sum /dev/nullordering trick are deleted, and thewarm-cache re-proof that trick required goes with them — the ordering is now
sequential in the shell.
Trap 2 —
ARG CHECK_EPOCHplaced after the dependency layer, guard belowit, value expanded into the lint command,
epoch="$(date +%s%N)$$"on its ownline. Blanket
--no-cacherejected.Trap 3 — decided on measurement;
config verifyis kept. Under the pinnedv2.12.2, one planted defect at a time:
golangci-lint runconfig verifylinters.settings.lllDisjoint classes.
runalone silently ignores an unknown key — the mode where athreshold reads as configured and is not applied. The live-HTTPS-schema concern
does not hold for this version: every case re-ran under
docker run --network nonewith byte-identical diagnostics and exit statuses,in a container where
getent hosts golangci-lint.runexits 2. The schema isembedded in the pinned binary. Recorded as a result to re-test on a version
bump, not as a permanent property.
Two canonical forms superseded and deleted, because leaving them would give
consuming repos two contradictory canonical
script/lintforms: thescript/bootstrapgolangci-lint install fromhttps://git.eeqj.de/sneak/prompts/issues/28
(its version-enforcement principle stays documented for any other pinned host
tool), and the per-checkout
GOLANGCI_LINT_CACHE/TMPDIRwrapper fromhttps://git.eeqj.de/sneak/prompts/issues/30
(its
GOCACHEmeasurement and paired-control requirement are kept). The interimVOID rule survives, narrowed to repos that have not yet adopted this.
Verification
script/lintADONE, epoch printed, prettier ranscript/lintB, byte-identical treeCACHEDby design; lint layersDONE, new epochscript/lint, planted violation[warn] README.md, build fails, exit 1script/lint, reverteddocker build -f Dockerfile.lint .with no build-arg[ -n "$CHECK_EPOCH" ]guard, exit 1make checkscript/dockerscript/cibuildDockerfile.lintthenDockerfileNo run was sub-second; no lint layer was ever
CACHED. No docker-in-docker: themain build completes normally and the
Dockerfilecontains no docker invocationat all — the word appears only in the comment explaining why
make checkmustnot return.
Grep sweep for surviving "a successful build implies lint passed" claims leaves
five hits, all correct: three describe
Dockerfile.lint, where a successfulbuild genuinely is a clean lint; one is the new sentence stating the guarantee
belongs to
script/cibuildand not to a baredocker build .; one is datedhistory in
TODO.md. Host-lint greps leave only the container forms..lint-cacheand--allow-serial-runnerssurvive only in deletion instructionsand dated history.
No
docker builder prune,docker system pruneor any other prune was run atany point; invalidation was scoped to this repo's own images via
CHECK_EPOCH.Out of scope, stated rather than silently skipped
This repo's
script/bootstrapnever installed golangci-lint (it is a noderepo), so only the canonical snippet was removed.
script/fmt-checkstill runsprettier on the host: it is a formatting check rather than a lint, and it is
additionally exercised inside the main image build. Propagation to consuming
repos is a separate unit of work.
clawbot referenced this issue2026-08-10 14:53:47 +02:00
Consequence found while implementing this pattern in
sfdupes(sneak/sfdupes#46): the canonical
REPO_POLICIES.mdbecomes self-contradictory for Go repos oncescript/lintis adocker build.REPO_POLICIES.md:92requires everyDockerfileto runmake checkas a build step.
REPO_POLICIES.md:115showsRUN make lintinthe lint stage.
Both now recurse:
make checkcallsscript/lint, which invokesdocker build, inside an image build. Nothing in the policy text warnsabout it, so every repo that follows the policy literally while adopting
this pattern will either build nested-Docker or quietly drop its lint
gate.
What
sfdupesdid, offered as the candidate canonical wording ratherthan as a decision: inside an image build, invoke the gates directly
instead of through the aggregate — the lint stage runs
golangci-lintitself, and the build stage runs the test and fmt-check targets, never
script/check.sneak/homoiconalready does exactly this and says soin a comment ("the docker build is its own single path"), so the
reference implementation and the policy text disagree today.
Second, smaller: dropping the
COPY --from=lintof the linter binaryremoves the only edge that forced BuildKit to finish linting before the
build stage started. Whatever wording lands should require an explicit
ordering edge, because losing it does not fail — the build just stops
gating on lint and still exits 0.
Not pushing this to
nexthere: that PR(#34) is
merge-readyand notmine to disturb.
Flagging one thing this canonicalisation has to include, from implementing the ruling in
sneak/vaultik( sneak/vaultik#113 ):REPO_POLICIES.mdcurrently mandates the pattern being superseded, so a consuming repo that follows the ruling is out of compliance with the policy text.REPO_POLICIES.md:102-166requires a lint stage inside the mainDockerfilerunningmake fmt-checkandmake lint, with the build stage forced to wait on it viaCOPY --from=lint /src/go.sum /dev/null, and gives a worked example. Under the rulingscript/lintbuildsDockerfile.lint, so aDockerfilelint stage runningmake lintwould shell out todocker buildfrom inside a BuildKit step with no daemon. The two cannot both stand.Also worth deciding centrally rather than per repo, since each will otherwise answer it differently: with the lint stage gone from
Dockerfile,make fmt-checkneeds a new home in the container gate (vaultik moved it besidemake testin the builder stage), and theCOPY --from=lintordering trick no longer applies, soscript/cibuildhas to build both files and fail on either.Not raising the cache-busting point separately — the issue body already names it, and it is the item most likely to be dropped when this is copied around.
Two questions for you, and a speculative PR that answers them one way
I was sent to reconcile
prompts/REPO_POLICIES.mdwith this ruling, on thepremise that the doc still mandates the old shape — the clause requiring every
Dockerfile to run
make checkas a build step, and the clause headed"Dockerfiles must use a separate lint stage for fail-fast feedback" with its
COPY --from=lint /src/go.sum /dev/nullordering trick.Both are already rewritten, in
12e8db8onnext, which is in#34. The contradiction is visible on
mainonly becausenexthas not merged. I did not open a second PR rewritingthem, since that would duplicate and conflict with that one. Answering the
question this issue's title implies: yes, this issue does cover the
policy-document rewrite, and it has been done — so I filed no new issue
either.
What I did instead was check that rewritten text against the two repos that have
actually implemented the ruling,
sneak/homoiconandsneak/quak, rather thanagainst the tracker. Three places diverge, and two of them need a decision from
you rather than an edit from me.
1. Does the
sneak/quakdivision of responsibility get to be the shape?sneak/quak#31 splits it as:
Dockerfile—make test, thenmake build. No lint, nofmt-check.Dockerfile.lint—eslint ., thenprettier --check ..script/cibuild—script/lintfirst, then the main build. That composite iswhere "all checks ran" is now true.
The canonical text on
nextsays the mainDockerfileruns the individualnon-lint checks,
script/testandscript/fmt-check. Read literally,sneak/quakis out of compliance: itsfmt-checkmoved into the lint imageinstead.
I think the doc should move rather than quak, and the PR implements that:
the formatting check must run in exactly one of the two images, either
placement allowed, never neither and never both. Where the formatter is the
same pinned dependency as the linter —
prettierout ofnode_modules— thelint image is the better home, because it takes the last host toolchain off the
checked path for exactly the reason the linter came off it. The failure to
guard against is it running in neither, which is the live risk: splitting lint
out of the
Dockerfileis precisely the momentfmt-checkgets dropped fromboth.
If you would rather have one shape enforced, say so and quak changes instead.
Either answer is implementable; what does not work is the doc and the
first adopter disagreeing silently.
2. Is the cache-bust arg one name or per-file?
quak'sDockerfile.lintnames itLINT_EPOCH; the canonical text saysCHECK_EPOCHin both files. quak's guard is functionally correct, so this isdrift and not a defect — but a per-file name is invisible to the grep that
proves every build in a repo is cache-busted, so a renamed guard and a missing
guard read identically without opening both Dockerfiles. The PR fixes the name
at
CHECK_EPOCHeverywhere. If you preferLINT_EPOCHin the lint file, thatis fine too and the doc should say it; one of the two has to give.
3. Not a question — a gap I would land regardless
The policy requires
.dockerignoreto exclude the agent scratch directory,justified on build-context bloat and on another session's unreviewed work
reaching an image layer. Both true, neither load-bearing now.
Dockerfile.lintlints whatever
COPY . .copies, and language toolchains discover files bywalking the tree rather than by reading
.gitignore—./...,eslint .andprettier --check .all descend into a nested worktree.sneak/quakmeasuredthis on the same discovery mechanism in its test runner: a nested
.claude/worktree took the discovered test count from 210 to 1050
(sneak/quak#30).
So a repo that containerises its lint and skips that entry re-creates the
foreign-tree false reds inside the container — in the convincing form, where
the findings are real and simply belong to another checkout. The PR restates
that entry as a correctness precondition of the containerised-lint rule rather
than a size optimisation, and both checklists get the matching item.
The PR
#43, assigned to you, based on and
targeting
nextbecause that is the branch the rule lives on. Four files,documentation only,
make checkgreen,make fmtrun. Speculative and awaitingyour decision — close it and delete the branch if you disagree. No other repo
was touched.
Evidence against the second caution in this issue, from implementing it in
sneak/cattbox(sneak/cattbox#33).>
golangci-lint config verifyresolves its JSON schema over an unpinned live> HTTPS fetch. Inside a build step that makes the lint network-dependent and
> breaks hash-pinning.
Not true for the digest this issue pins. I acted on that caution, told the
implementer to omit the line, and the reviewer then demonstrated it had cost us
a live false green. So I tested the premise instead of propagating it. Same
image,
golangci/golangci-lint@sha256:5cceeef0…, run with the network removedentirely:
The schema is embedded in v2.12.2.
--network noneis a hard control: nothingcould have been fetched. And it genuinely validates rather than degrading to a
no-op when offline, which the second run proves.
Why the line matters more than it looks
Omitting it is not neutral, because
golangci-lint rundoes not cover thesame ground. Measured on cattbox:
runfails, exit 3. Fine either way.runsilently ignores it, exit 0. Two demonstrationswith an identical probe file:
line-lengthmistyped asline-lenghttooklllback to its 120 default and reported0 issues.; mistypinglinters.defaultaslinters.defaultsdropped the key that enables the wholenon-standard linter set, collapsing it to standard,
lllnever running,0 issues., exit 0.So a one-character typo in
.golangci.ymlsilently downgrades the gate todefault linters and reports green. That is the same class of defect this whole
issue exists to eliminate, arriving through the config file instead of the
cache.
config verifycatches it; nothing else in the pipeline does.Suggest striking the caution from the canonical guidance and keeping
RUN golangci-lint config verify --config .golangci.ymlinDockerfile.lintassneak/homoiconalready has it, with a note that it is offline for adigest-pinned v2.12.2 and should be re-checked if the pin moves. Happy to send
that as a PR here if the wording is wanted from me rather than decided by you.
One related note for the canonical template, found in the same work: it
prescribes
RUN make lintin the mainDockerfile's lint stage(
REPO_POLICIES.md:104-127as vendored into cattbox). Oncescript/lintisitself a
docker build, that line is docker-in-docker inside an image build andcannot work. cattbox resolved it by invoking
golangci-lintdirectly in thatstage, which is what
sneak/homoicondoes. Any repo adopting this pattern witha lint stage in its main
Dockerfilewill hit it.Owner ruling on the scope boundary, sneak 2026-08-10, verbatim:
> fmt and fmt check arent docker, just linting.
Posting it here because it is org-wide and because it is an easy line to cross: implementing this in
sneak/lora.vegas(sneak/lora.vegas#38) the implementer containerisedscript/fmt-checktoo, reasoning that leaving prettier on the host would leavemake checkwith a host-run path. That reasoning is coherent and is now overruled — only linting is containerised.The canonical text already on
nexthere matches the ruling, so nothing needs changing upstream. The value of the ruling is that it closes the question for the repos still adopting the pattern:script/fmtandscript/fmt-checkstay on the host, and because they do, the mainDockerfilecan still run the format check directly with no recursion.clawbot referenced this issue2026-08-10 15:07:35 +02:00
New defect class in the canonical pattern, found by adversarial review in
sneak/lora.vegas(sneak/lora.vegas#39). Worth a line in the canonical text because it is silent, and because the canonicalscript/lintform is what triggers it.If
Dockerfile.linthas more than one stage and the lint stage is not the last one,docker build -f Dockerfile.lint .never runs the lint and exits 0. Sibling stages off a shared base are not instantiated unless something depends on them or--targetnames them, so BuildKit builds only the final stage. Demonstrated: a whole-file build ran only the trailing stage and returned success with the lint stage absent from the graph entirely.The
CHECK_EPOCHguard does not catch it. The guard isARG-scoped per stage, so it is satisfied by whichever stage actually ran, and a build that skipped the lint stage skipped its guard too. Every existing proof of "the epoch forces execution" remains true and simply does not apply to a stage that was never instantiated.Two things follow for the canonical shape:
Dockerfile.lintsingle-stage wherever possible. Then the canonicaldocker build -f Dockerfile.lint .cannot skip anything and the file's usual claim — a successful build is a clean lint — is true for every invocation rather than only the one the repo's ownscript/linthappens to use.script/lintpassing the right--targetis not sufficient: it makes correctness a property of one caller rather than of the file, and the canonical caller passes no target at all.This is the same failure shape already flagged here as "present but no longer gating" — a check that is nominally configured, passes, and gates nothing.
Correction to the second caution in the issue body, with evidence — it matters because acting on it as written leaves a false green in every repo that adopts this pattern.
The claim that
golangci-lint config verifyresolves its JSON schema over an unpinned live HTTPS fetch is false at the pinned v2.12.2. Tested in the pinned image with the network off:docker run --network none ... golangci-lint config verify --config .golangci.ymlexits 0 on a real config.additional properties 'linterz' not allowed.It validates offline and it is not silently skipping validation when offline. So the hash-pinning objection to including that line does not apply, and there is no reason to drop it.
Dropping it is not neutral, because
golangci-lint rundoes not catch the same thing. Unparseable YAML it rejects. An unknown top-level key it silently ignores, exit 0. Reproduced against a real containerised gate insneak/vaultik: changing.golangci.yml'slinters:tolinterz:— one character — makesscript/lintexit 0 reporting0 issues.in a run whose lint layer demonstrably executed.default: all, the disable list and every threshold are discarded, and only golangci-lint's small default set runs. A repo could sit in that state indefinitely with a green gate.So the canonical
Dockerfile.lintshould keepRUN golangci-lint config verify --config .golangci.yml, and the caution in the issue body should be struck rather than propagated. One thing to decide when canonicalising it: whatever cache-busting the lintRUNgets, theconfig verifyRUNneeds too — a cached verify layer validates nothing, which is the same trap one level down.Found by an adversarial reviewer of the vaultik implementation ( sneak/vaultik#114 ), where the omission had been made deliberately on the strength of the caution above.
Second finding, and this one is a direct contradiction inside canonical
REPO_POLICIES.mdonce Docker-only linting lands.REPO_POLICIES.md(around lines 266-271 in the version on #42) mandates installing golangci-lint on the host viago install ...@c0d3ddc9. That instruction is incompatible with the Docker-only linting ruling this issue tracks: after the change, nothing installs or runs golangci-lint on the host at all.Confirmed concretely in
sneak/dnswatcher, which has now landed Docker-only linting (sneak/dnswatcher#134). Itsscript/bootstrapdeliberately installs golangci-lint nowhere, so the repo satisfies the version the canonical text pins (v2.12.2 /c0d3ddc9) while violating the mechanism it prescribes. Every repo converted to Docker-only linting will land in the same state.The vendored copy cannot be fixed downstream — it is canonical and must not be hand-edited per policy — so the fix belongs in
sneak/prompts.Suggested resolution when the
Dockerfile.lintshape is settled here: replace the hostgo installmandate with the Docker-only lint requirement, keeping the pinned version but attaching it to the digest-pinned lint image rather than a host install. Worth doing in the same change as whatever is decided aboutgolangci-lint config verify(raised in my earlier comment), since both edit the same section.clawbot referenced this issue2026-08-10 15:30:34 +02:00
Don't do the config check step. That's not necessary. We can assume the config is valid.
It's okay to make linting its own Docker phase. That way you can put it into the main Docker file.
You don't need a Docker file dot lint separately.
Then the linting script entry point can just run that single build phase in Docker using no caching.
Additionally, there is a way to create an artificial dependency between the linter and the main build.
I would do the same thing for the testing phase in the main build.
That way you can ensure that the main build will not run unless linting and testing both pass first.
This pattern exists in one of the existing reposed Docker files and you should find it and use that one.
then both testing and linting happen inside docker and you can simply disable all caching.