Run every lint-class check inside Docker (closes #38)
All checks were successful
check / check (push) Successful in 1m9s

Add a root Dockerfile.lint that carries the checks as build steps -- a
`lint` stage running `hugo --minify --printPathWarnings` and a
`fmt-check` stage running the prettier check -- and reduce script/lint
and script/fmt-check to building their stage. A successful build is a
clean check. There is no host path and deliberately no "am I already
inside a container?" branch, which would be a host lint path in
disguise.

The two stages share a `base` whose first four instructions are
byte-identical to the main Dockerfile's, so the expensive
`RUN script/bootstrap` layer that compiles the pinned Hugo from source
is a cache hit against the main image instead of a second build of the
same thing.

Resolve the resulting recursion by splitting the checks by where they
run, not with an escape hatch. `make check` runs script/lint, so the
main Dockerfile can no longer `RUN make check`: that would be
docker-in-docker inside a bare Alpine with no docker client and no
daemon socket, and script/cibuild is what CI runs on every push. The
main Dockerfile therefore runs `make test`, the production build, and
script/cibuild builds it and then calls script/lint and
script/fmt-check. CI still covers the production build, lint and the
format check, and it runs exactly what a developer runs.

script/fmt stays on the host because it rewrites the working tree,
which a container build cannot do. That makes it the authoritative
copy of the prettier version, scope and flags that the fmt-check stage
duplicates; both sides carry a keep-in-sync note. The duplication is
forced: any `RUN script/fmt-check` inside the image is the recursion
again.

Caching is waived for the checks in the shape this repo already
settled: `ARG CHECK_EPOCH` with no default, declared and guarded
separately in each stage because ARG does not cross a FROM, with the
value expanded into the checked command as well as the guard so
invalidation does not rest on BuildKit's treatment of an unreferenced
ARG. All four image-building entrypoints now generate and pass it --
script/cibuild, script/docker, script/lint, script/fmt-check.

Verified: two consecutive script/lint runs on an unchanged tree both
executed hugo for real, with script/bootstrap CACHED; a constant-epoch
counterfactual restored the false green (exit 0, lint layer CACHED, no
hugo output); an empty epoch failed closed on the guard; a broken
template failed the lint stage and an unformatted README failed the
fmt-check stage, both reverted and re-run clean; script/cibuild and
`make check` are green with all three checks demonstrably executing.
This commit is contained in:
clawbot
2026-08-10 12:52:56 +00:00
parent 407b0a0d79
commit f5761b6227
9 changed files with 295 additions and 50 deletions

View File

@@ -39,8 +39,12 @@ build, and the formatting check:
make check
```
The lint build and the formatting check run inside Docker, so `make check` needs
a working Docker daemon; there is no host fallback.
`make fmt` rewrites the repo's markdown and CSS to the project's prettier
settings; run it if `make check` fails on formatting.
settings; run it if `make check` fails on formatting. It runs on the host,
because it writes to your working tree.
To contribute to this site, contact **sneak@sneak.berlin** for git repository
access.
@@ -61,22 +65,41 @@ provide:
git pre-commit hook
- `script/test` — the correctness check: a clean `hugo --minify` production
build
- `script/lint` — a clean build that surfaces broken links and path collisions
- `script/lint` — a clean build that surfaces broken links and path collisions,
run inside Docker: it builds the `lint` stage of `Dockerfile.lint`, where the
check is a build step, so a successful build is a clean lint
- `script/fmt` — format every markdown and CSS file in the repo with prettier;
the exclusions live in `.prettierignore` with the reason for each
- `script/fmt-check` — check that formatting (read-only)
the exclusions live in `.prettierignore` with the reason for each. The one
prettier entrypoint that runs on the host, because it writes to your working
tree
- `script/fmt-check` — check that formatting (read-only), also inside Docker:
the `fmt-check` stage of `Dockerfile.lint`
- `script/check` — run `script/test`, `script/lint`, then `script/fmt-check`;
modifies no tracked files
- `script/docker` — build the Docker image tagged with the project name
- `script/cibuild` — the CI build; the Dockerfile runs `make check`
- `script/cibuild` — the CI build: the main image (the production build), then
`script/lint` and `script/fmt-check`
- `script/install-precommit` — install the git pre-commit hook that runs
`script/check`
Build the image through `script/cibuild` or `script/docker` only. Both pass a
per-invocation `CHECK_EPOCH` build argument that the Dockerfile requires, so the
`make check` layer can never be served from cache — without it Docker returns a
green it did not earn. A bare `docker build .` fails closed on the Dockerfile's
`CHECK_EPOCH` guard rather than caching its way to a false success.
Every lint run for this repo happens inside a container. `script/lint` and
`script/fmt-check` have no host path and no "already inside a container?"
branch, so what a developer runs and what CI runs are the same build.
That is also why the main `Dockerfile` runs `make test` rather than
`make check`: `make check` calls `script/lint`, which is itself a
`docker build`, so a `make check` inside an image would be docker-in-docker in a
bare Alpine with no docker client and no daemon socket. The checks are split by
where they run — the production build in `Dockerfile`, lint and the format check
in `Dockerfile.lint` — and `script/cibuild` drives all three, so CI coverage is
unchanged.
Build any image through `script/cibuild`, `script/docker`, `script/lint` or
`script/fmt-check` only. All four pass a per-invocation `CHECK_EPOCH` build
argument that the Dockerfiles require, so a check layer can never be served from
cache — without it Docker returns a green it did not earn. A bare `docker build`
fails closed on the `CHECK_EPOCH` guard rather than caching its way to a false
success.
A convenience `make serve` target runs `hugo server` for local preview.