Run all linting in Docker via Dockerfile.lint + script/lint #38
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.This is a Hugo site, so the pattern applies to whatever lints and format-checks it — prettier over the markdown and any config linting — rather than golangci-lint. The ruling is about every lint run, not about Go. Reference shape is
sneak/homoicon: a rootDockerfile.lintthat COPYs the repo into a digest-pinned image and runs the check as a build step, so a successful build IS a clean lint, withscript/lintreduced to building it.This also closes a real gap here:
script/checkcurrently omitsscript/lint, so linting runs nowhere in the gate. Containerising it and wiring it into the gate can land together, but state plainly which change is responsible for the first genuine lint failure that surfaces.Two things to get right:
script/cibuild— this repo already identified that failure mode, where a Dockerfile guard claims a property that one entrypoint does not actually supply.Definition of done
script/lintruns the linter only in Docker; no host lint path remains.script/lintruns on an unchanged tree both demonstrably execute the linter.make checkstill green, and it now actually includes lint.Canonical tracking issue: sneak/prompts#40
Correction to one premise in the issue body:
script/checkdoes not omitscript/lint. Onmainat7d7bec5it runstest, thenlint, thenfmt-check. That gap was closed earlier; the scope here is purely containerising the lint run, not wiring it into the gate.The rest stands: the lint currently executes on the host, and there is no
Dockerfile.lint.The blocking constraint: this repo cannot copy
sneak/homoiconverbatimhomoicon's mainDockerfileinvokes its tools directly and never shells back intomake. This repo'sDockerfileends withRUN echo "check epoch: ${CHECK_EPOCH}" && make check, andmake checkrunsscript/lint. The momentscript/lintbecomesdocker build -f Dockerfile.lint ., that line is a docker-in-docker call inside a bare alpine image with no docker client and no daemon socket — soscript/cibuildfails, andscript/cibuildis what.gitea/workflows/check.ymlruns on every push. Resolve this deliberately; do not discover it.Do not solve it by giving
script/linta "am I already in a container?" escape hatch. That is a host lint path wearing a disguise and the definition of done forbids it.Recommended shape (deviate only with a stated reason)
Dockerfile.lintat the repo root, same pinned-by-digest alpine base theDockerfilealready uses.COPY script/thenRUN script/bootstrap(hugo and node both come from there), thenCOPY . .. The bootstrap layer compiles Hugo from source, so it must sit above the cache-bustingARGand keep caching, exactly as the existingDockerfiledoes.hugo --minify --printPathWarningsbuild and the prettier check. A successful build is a clean lint.script/lintreduces to buildingDockerfile.lintwith a per-invocation cache-busting build arg, generated the same wayscript/cibuildandscript/dockeralready generateCHECK_EPOCH— as a whole assignment, not inline, for theset -ereason documented inscript/cibuild.ARGis declared with no default and guarded ([ -n "$X" ] || exit 1), and its value is expanded into the lintRUNcommands so invalidation does not depend on BuildKit's treatment of an unreferencedARG. This repo already learned that both the guard and the checked command must reference the value.script/fmt-checkalso becomes a container build, it passes it too.Dockerfileandscript/cibuildadjust so CI still lints without recursing. Whatever split you choose, state it in the PR body: which command runs where, and how CI still covers lint, the production build, and the format check.Definition of done
script/lintruns the linter only in Docker. No host lint path, no in-container bypass.script/lintruns on an unchanged tree both demonstrably execute the linter — show the second run's real output and elapsed time, not an exit code. A fully cached build returns 0 in under a second having linted nothing.script/cibuildstill succeeds, and you can show it executed rather than being served from cache.make checkgreen and still covering lint.Constraints
docker builder prune— the build cache is shared with other work and destroying it is not recoverable. Scope invalidation with--no-cache-filter=<stage>or--no-cacheon a single build.deploy.ymland the deploy path are out of scope for this issue; if you believe one must change, stop and say so.Implementation plan
The recursion question decides everything else, so it goes first.
Decision: the main
Dockerfilestops runningmake checkmake checkcannot run inside the main image oncescript/lintis adocker build, and the forbidden escape hatch is the only way to keep that line as it stands. So the checks are split by where they run, and no image ever shells back intomake check:Dockerfile(unchanged role otherwise) runsmake test— the cleanhugo --minifyproduction build, and nothing else. It keeps itsCHECK_EPOCHARG, its no-default guard, and the value expanded into theRUN.Dockerfile.lint(new, repo root) carries every lint-class check as build steps, so a successful build is a clean lint. Two stages on a sharedbase:lint—hugo --minify --printPathWarningsfmt-check— the prettier check, same version, same scope, same flags asscript/fmtbaseis the same digest-pinned alpine theDockerfileuses, withCOPY script/,RUN script/bootstrap,COPY . .in exactly the same instruction order, so the bootstrap layer that compiles Hugo from source is a cache hit shared with the main image and never rebuilds.Neither
Dockerfileinvokesscript/lint,script/fmt-checkorscript/check, so there is no docker-in-docker anywhere and no in-container bypass to write.Entrypoints
script/lintbecomesdocker build -f Dockerfile.lint --target lintwith a per-invocation cache-busting arg. Nothing else. No host path, no branch.script/fmt-checkbecomes the same build with--target fmt-check. It has to move too: leaving prettier running on the host would leave a host lint path inmake check, which the definition of done forbids.script/fmtstays on the host. It is a mutation of the working tree, not a gate — a container cannot write the fix back — and it is the one place the prettier version and flags are authoritative.script/checkis untouched:test,lint,fmt-check. Two of those three are now container builds.script/cibuildbuilds the main image and then callsscript/lintandscript/fmt-check, so CI runs exactly what a developer runs. Coverage in CI stays complete: production build in the main image, lint and format check in the lint image.script/cibuild,script/docker,script/lint,script/fmt-check. Four of them now, and the value is built as a whole assignment, not inline, for theset -ereasonscript/cibuilddocuments.Cache busting
Same shape this repo already settled:
ARG CHECK_EPOCHdeclared per stage with no default, guarded with[ -n "$CHECK_EPOCH" ] || exit 1, and the value expanded into the checkedRUNas well, so invalidation does not rest on BuildKit's handling of an unreferencedARG.ARGis stage-scoped, so both thelintandfmt-checkstages declare and guard it independently. Everything above theARGstill caches.Known duplication, stated rather than hidden
The prettier version and flags, and the hugo lint flags, are inlined into
Dockerfile.lintinstead of being reached through a script. That is forced: anyRUN script/lintorRUN script/fmt-checkinside the image is the recursion again. The duplicated constants get a keep-in-sync comment on both sides.Also in this commit
README.md's Entrypoints section (it currently states the Dockerfile runsmake check) andTODO.mdper its own Workflow.Verification I will show
Two consecutive
script/lintruns on an unchanged tree with real output and elapsed time; a separate negative control per check the lint image performs, each reverted and re-run clean; ascript/cibuildrun with evidence the check layers executed rather than being served from cache. No global cache prune — invalidation stays scoped to the single build.Implemented as
f5761b6onnext, carried by #39 .What landed
Dockerfile.lintat the repo root, on the same digest-pinned alpine, with a sharedbasestage (COPY script/thenRUN script/bootstrapthenCOPY . .) and two check stages built on it:lint—hugo --minify --printPathWarningsfmt-check— the prettier check, same version, scope and flags asscript/fmtThe checks are build steps, so a successful build is a clean check.
script/lintandscript/fmt-checkare reduced todocker build -f Dockerfile.lint --target ...; there is no host path in either, and no "am I already inside a container?" branch.base's first four instructions are byte-identical to the mainDockerfile's and in the same order, so thescript/bootstraplayer that compiles Hugo from source caches across both files. Measured: in a fresh clone that had never built the main image,script/cibuild's main-image[4/7] RUN script/bootstrapcame backCACHEDoff the lint image's layers.The recursion, resolved deliberately
make checkrunsscript/lint, which is now adocker build, so the mainDockerfilecan no longerRUN make check— that is docker-in-docker in a bare alpine with no docker client and no daemon socket, andscript/cibuildis whatcheck.ymlruns on every push. Resolved by splitting the checks by where they run, not by an escape hatch:Dockerfile—RUN ... make test, the clean production build, and nothing elseDockerfile.lintstagelint— the lintDockerfile.lintstagefmt-check— the format checkscript/cibuild— builds the main image, then callsscript/lintandscript/fmt-checkSo CI still covers lint, the production build and the format check, and it runs the same scripts a developer runs.
script/checkis unchanged (test,lint,fmt-check) and now needs a Docker daemon, with no fallback. Both Dockerfiles carry a comment telling the next reader not to reintroduce amake checkline.Noted plainly: this deviates from
REPO_POLICIES.md's "all Dockerfiles must runmake check". That rule and "every lint run happens in Docker" cannot both hold oncemake checkcontains the lint.script/fmtstays on the host — it rewrites the working tree, which a container build cannot do — and is therefore the authoritative copy of the prettier version and flags that thefmt-checkstage duplicates. Both sides carry a keep-in-sync note. The duplication is forced: anyRUN script/fmt-checkin the image is the recursion again.Cache busting
ARG CHECK_EPOCH, no default, guarded with[ -n "$CHECK_EPOCH" ] || exit 1, value expanded into the checkedRUNas well as the guard. Declared and guarded separately in each stage, sinceARGdoes not cross aFROM. All four image-building entrypoints generate and pass it —script/cibuild,script/docker,script/lint,script/fmt-check— each as a whole assignment rather than inline.Verification, against the definition of done
script/lintruns the linter only in Docker. The script is four lines ofdocker build;git diffshows thehugoinvocation removed from it entirely.Two consecutive runs on an unchanged tree both executed the linter. Second run, 2.9s wall:
Constant-epoch counterfactual restores the false green, which is the evidence that the nonce is what makes it execute. With the epoch pinned to a fixed string, the second run:
Exit 0 in a quarter second, no hugo output at all.
Guard fails closed. With an empty epoch:
Negative control, lint stage. An undefined field appended to
baseof.html:Reverted, re-run clean.
Negative control, fmt-check stage. An over-long unwrapped line appended to
README.md:Reverted, re-run clean (
All matched files use Prettier code style!).script/cibuildsucceeds and demonstrably executed. One invocation, exit 0, 20s wall, three distinct epochs, everyscript/bootstraplayerCACHED:make checkgreen and still covering lint — 12s, exit 0, with the lint and fmt-check layers observed executing (distinct epochs, real hugo and prettier output) rather than served from cache.No global cache prune at any point; every invalidation was scoped to a single build through its own build arg.
One limitation, unchanged by this commit
The
lintstage fails on hugo build errors but not on render-target collisions —--printPathWarningsprints and exits 0, which is why the lint negative control above is a build error rather than a collision. Pre-existing and tracked at #25 ; containerising the run neither fixes nor worsens it, and fixing it was out of scope here.TODO.mdwas updated in the same commit per its Workflow section..gitea/workflows/deploy.ymland the deploy path were not touched;check.ymlneeded no change, since it already runsscript/cibuild.Owner ruling, sneak 2026-08-10, scoping this issue:
> fmt and fmt check arent docker, just linting.
So the containerisation boundary is lint only.
script/fmtandscript/fmt-checkstay on the host.This overrides part of my earlier brief. Point 5 of the recommended shape floated "if
script/fmt-checkalso becomes a container build" as an open option — it is now closed, and closed the other way. Consequences:script/lintis the only entrypoint that becomes a Docker build.script/fmt-checkrunning on the host means there is no recursion in invoking it inside the main image, so the mainDockerfilecan and should still run the production build and the format check directly.sneak/prompts, which independently kept the format check on the host on the grounds that a formatting check is not a lint.The commit currently on
next(f5761b6) containerisedscript/fmt-checkand dropped the format check from the main image, so it needs rework on both points.Reworked.
f5761b6is amended to25b6c0aonnext, carried by #39 .407b0a0is preserved as the parent.What changed against the owner ruling
The containerisation boundary is now lint only.
script/fmtandscript/fmt-checkare back on the host with the exact version, scope and flags they had onmain—prettier@3.4.2,'**/*.md' '**/*.css' --tab-width 4 --prose-wrap always. Thefmt-checkstage is gone fromDockerfile.lint, and so is the forced duplication of prettier's settings between a script and a Dockerfile, along with the keep-in-sync notes it required.Because a format check on the host cannot recurse, the main
Dockerfileruns the production build and the format check again —RUN script/testandRUN script/fmt-checkas separate lines under theCHECK_EPOCHguard, matching the canonicalsneak/promptsshape. Only the lint is absent from it, and it is not skipped:script/cibuildnow runsscript/lintfirst, for fail-fast feedback, before the main image build starts.The one-stage decision
Dropping the
fmt-checkstage also removed a live false green rather than only a policy divergence. A whole-filedocker build -f Dockerfile.lint .builds only the file's last stage, and sibling stages off a shared base carry no ordering edge, so the old file'slintstage was skipped entirely by exactly the invocation the canonical org-widescript/lintuses — exit 0, no lint. The per-stageCHECK_EPOCHguard could not catch it, since the stage that did run satisfied it.Dockerfile.lintnow has exactly one stage,script/lintpasses no--target, and there is nothing left to skip. The file says so, and says that any second check added later must be chained (FROM lint AS ...) or carry an explicit ordering edge, never left as a sibling.script/lintalso builds with--output type=cacheonly— the build is run for its exit status, not for an image, and since the lint layer is cache-busted every invocation an exporting build leaves one dangling image per lint run on a shared host. The build cache is unaffected, soscript/bootstrapstill hits.Verification, re-established from scratch
Done in a fresh clone against this commit's tree; none of it is inherited from the superseded commit's evidence.
Two consecutive
script/lintruns, unchanged tree, both executed the lint. Second run, 0.849s wall:No lint layer was ever
CACHEDin any run.The whole-file build now genuinely lints — the regression test for the skipped-sibling hazard,
docker build -f Dockerfile.lint --build-arg CHECK_EPOCH=... .with no--target:Guard fails closed.
docker build -f Dockerfile.lint .with no argument:Negative control, lint. An undefined field appended to
themes/loravega/layouts/index.html:Reverted, re-run clean.
Negative control, host
script/fmt-check. An over-long unwrapped line appended toREADME.mdgave exit 1 with[warn] README.mdandCode style issues found in the above file. Run Prettier with --write to fix.; reverted,All matched files use Prettier code style!.script/cibuildfails fast. With that same template error planted, it exited 1 in 0.630s and the main image build never started — zerocheck epochlines in the whole log.All three entrypoints green with each check observed executing.
script/cibuild11.5s (lint build with its own epoch and real hugo output, then the main image with a distinctcheck epoch,script/testoutput, andRUN script/fmt-checkprintingAll matched files use Prettier code style!;RUN script/bootstrapCACHEDin both builds, which is the shared-prefix cache hit working).make check3.9s.script/docker10.3s.Deploy path untouched, checked as a diff rather than inferred:
.gitea/,script/bootstrap,script/testand.dockerignoreare byte-identical tomainat7d7bec5.No prune of any kind was run at any point; every invalidation was scoped to a single build through its own build argument.
Unchanged limitation
The lint fails on hugo build errors but not on render-target collisions —
--printPathWarningsprints them and exits 0. Pre-existing and tracked at #25 ; containerising the run neither fixes nor worsens it, and it bounds what the lint negative control above could demonstrate.TODO.mdwas updated in the same commit per its Workflow section.