Run all linting in Docker via Dockerfile.lint + script/lint #30
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 TypeScript repo, so the pattern applies to ESLint and prettier 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 linter as a build step, so a successful build IS a clean lint, withscript/lintreduced to building it. That also works where the docker daemon is remote and bind mounts are impossible.For this repo that means a digest-pinned node image,
yarn install --frozen-lockfile, then eslint and prettier as build steps. Copy the manifests before the sources so dependency installation caches separately from the lint steps.Two things to get right, both of which would otherwise ship a false green:
.claude/and does not read.gitignore, which inflated a test count from 210 to 1050. The same exposure applies to a lint run over the build context, so make sure.dockerignoreexcludes agent directories or the container lints foreign trees.Definition of done
script/lintruns eslint and prettier only in Docker; no host lint path remains.script/lintruns on an unchanged tree both demonstrably execute the linters.make checkstill green.Canonical tracking issue: sneak/prompts#40
Implementation requirements, specific to this repo's current shape.
The recursion trap.
Dockerfile(check stage) runsmake check->script/check->script/lint. The momentscript/lintbecomesdocker build, the containerised check stage tries to run Docker inside a container and the CI build breaks.script/precommitcallsscript/linttoo. So this change is not confined toscript/lint; theDockerfile/script/checksplit has to be reworked so that lint happens exactly once, inDockerfile.lint, and the check image never invokes a nesteddocker build. The existinglintstage insideDockerfile(with itsCOPY --from=lintordering hack) is the thing being replaced — it should not survive alongsideDockerfile.lint.Cache. No lint cache is wanted, and a cached lint layer lints nothing while exiting 0. Whatever mechanism forces execution, it must fail closed the way
CHECK_EPOCHdoes inDockerfiletoday — an unset build arg is the empty string, which is a perfectly stable cache key, so the guard has to reject it rather than accept a silent false green.Build context.
.dockerignorealready excludes.claude/for exactly the reason the issue describes; keep that exclusion and confirm it still holds for the lint context. Note the deliberate exception at the top of that file:.gitignorestays in the context because prettier 3 reads it as a default ignore file, so the set of files linted inside the container matches the set linted outside.Evidence required in the PR body, since none of this is visible from a green exit code: the two consecutive runs both showing linter output, the negative control failing on the specific introduced violation and passing again after revert, and a file count or equivalent showing the container linted this tree only and not a nested agent directory.
Out of scope: #29 (prettier running twice under
make check) touches the same scripts and is the next unit — do not fix it here, but do not make it harder to fix either.Implementation plan for #30.
New
Dockerfile.lint(root, reference shapesneak/homoicon), reusing the digest already pinned inDockerfile(node 22.22.0 on Alpine 3.23.3):COPY script/,package.json,yarn.lock, thenRUN script/bootstrap— dependency install caches as its own layer, per repo policy the image bootstraps via the script rather than duplicating installs inline (script/bootstrapends inyarn install --frozen-lockfile).COPY . .ARG LINT_EPOCH+RUN [ -n "$LINT_EPOCH" ] || exit 1, exactly the fail-closed contractCHECK_EPOCHhas today: an unset arg is the empty string and therefore a stable cache key, so a baredocker build -f Dockerfile.lint .hard-fails instead of serving a cached green.RUN yarn run eslint .andRUN yarn run prettier --check .as build steps below the guard, so a successful build IS a clean lint. Both layers are children of the invalidated guard layer, so they cannot be served from cache.Breaking the recursion.
script/lintbecomesdocker build --build-arg LINT_EPOCH=... -f Dockerfile.lint ., so nothing that runs inside a container may call it:lintstage inDockerfilegoes away entirely, including theCOPY --from=lint /app/yarn.lock /dev/nullordering hack —Dockerfile.lintreplaces it.Dockerfilerunsmake testandmake buildunder the existingCHECK_EPOCHguard, and no longer runsmake check, becausescript/checkcallsscript/lintand that is now a nesteddocker build. Lint therefore happens exactly once, inDockerfile.lint.script/cibuildbecomes the composite gate: it runsscript/lintfirst (fail fast on lint, one definition of the lint build), then theDockerfiletest/build image.script/checkandscript/precommitkeep callingscript/lintunchanged — on the host that now routes to Docker, which is the ruling.Build context.
.dockerignoreat the context root applies to-f Dockerfile.lintbuilds too (noDockerfile.lint.dockerignorewill exist to override it). The.claude/exclusion and the deliberate.gitignore-stays-in-context exception are preserved as-is; I will verify by counting the files actually present inside the lint image.Tests. New
test/packaging/lint-docker.test.tsalongside the existing packaging assertions:script/lintruns onlydocker build -f Dockerfile.lintand no host linter;Dockerfile.lintis digest-pinned, copies manifests before sources, carries the fail-closed epoch guard, and runs both linters as build steps;Dockerfilecontains no lint stage and nomake check/make lint;script/cibuildruns the lint build before the check build.Docs. README Entrypoints section updated for the new
script/lint,script/cibuildand Dockerfile split;TODO.mdgets a Completed Steps entry (its Next Step is the README API reference, unrelated, so that stays put).Verification will be posted with the evidence: two consecutive
script/lintruns both showing linter execution, a negative control for an eslint violation and separately a formatting violation each failing on the specific finding then passing after revert, an in-image file count proving only this tree was linted, andmake checkplusscript/cibuildshown executing rather than cached.Out of scope and untouched: #29.
Implemented on
nextasfed39d19cf3cdbfc695e50045a0055cc743c66d1, opened as #31 (no opennext->mainPR existed, so this commit starts the new cycle). Full evidence is in the PR body; summary against the definition of done:script/lintruns eslint and prettier only in Docker. New rootDockerfile.lintcopies the repo into the node image already digest-pinned inDockerfileand runs both linters as build steps, so a successful build is a clean lint.script/lintis reduced to building it. No host lint path remains: the script invokes no linter against the working tree, and"lint": "eslint ."is deleted frompackage.jsonsoyarn lintcan no longer produce a verdict from an unpinned local toolchain.Recursion.
script/lintis adocker build, so thelintstage inDockerfileand itsCOPY --from=lint /app/yarn.lock /dev/nullordering hack are gone, and that image no longer runsmake check— it runsmake testandmake buildunder the unchangedCHECK_EPOCHguard.script/cibuildis now the composite gate and builds the lint image first, then the test/build image, so lint happens exactly once and nothing in a container invokes a nesteddocker build.Cache, fail closed.
ARG LINT_EPOCHplusRUN [ -n "$LINT_EPOCH" ] || exit 1, with both linters below the guard so a fresh epoch forces them to execute. Verified both directions: a baredocker build -f Dockerfile.lint .exits 1 at the guard rather than serving a green, and two consecutivemake lintruns on an untouched tree both ran the linters —CACHEDappeared only on the bootstrap and dependency layers, with steps 8 and 9 showing eslint and prettier output and the guard line echoing a different epoch each run.Negative controls. An unused local failed the build at the eslint step on that exact finding (
'deliberatelyUnused' is assigned a value but never used), and separately a mangled-spacing file failed at the prettier step naming that file; both were clean again after revert.Foreign trees.
.dockerignoreis unchanged, including the.claude/exclusion and the deliberate exception that keeps.gitignorein the context for prettier 3. Verified from inside the image: 46 TS/JS files, 23 files undertest/, no.claude,.gitignorepresent. An active control planted a worktree at.claude/worktrees/foreign/test/containing both an eslint error and a formatting error of the kinds the build demonstrably catches; the lint build still passed, so the container did not see it. A new test also asserts no per-Dockerfile.dockerignoreshadows the root one for either image.make checkgreen: 22 test files, 244 tests.script/cibuildgreen and shown executing.Two things for the reviewer. First, the
Dockerfileimage no longer runsmake checkasREPO_POLICIES.mdasks; that is unavoidable oncescript/checkreachesscript/lintandscript/lintis adocker build, and coverage is preserved becausescript/cibuildbuilds both images. Second, the whole unit landed as a single commit rather than the README's tests-first pair, per the instruction for this work item.#29 was left alone:
script/fmt-checkstill runs prettier on the host, unchanged.