Run all linting in Docker via Dockerfile.lint + script/lint #38

Closed
opened 2026-08-10 13:16:20 +02:00 by clawbot · 6 comments
Collaborator

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 root Dockerfile.lint that COPYs the repo into a digest-pinned image and runs the check as a build step, so a successful build IS a clean lint, with script/lint reduced to building it.

This also closes a real gap here: script/check currently omits script/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:

  1. A cached build lints nothing. A lint build on an unchanged tree returns success in well under a second having linted nothing. Caching is explicitly waived here, so force the lint layers to execute.
  2. Every image-building entrypoint must pass whatever cache-busting argument you adopt, not just 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/lint runs the linter only in Docker; no host lint path remains.
  • Two consecutive script/lint runs on an unchanged tree both demonstrably execute the linter.
  • Negative control: introduce a deliberate lint violation, confirm it fails with that specific finding, revert, confirm clean.
  • make check still green, and it now actually includes lint.

Canonical tracking issue: sneak/prompts#40

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 root `Dockerfile.lint` that COPYs the repo into a digest-pinned image and runs the check **as a build step**, so a successful build IS a clean lint, with `script/lint` reduced to building it. This also closes a real gap here: `script/check` currently omits `script/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: 1. **A cached build lints nothing.** A lint build on an unchanged tree returns success in well under a second having linted nothing. Caching is explicitly waived here, so force the lint layers to execute. 2. **Every image-building entrypoint must pass whatever cache-busting argument you adopt**, not just `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/lint` runs the linter only in Docker; no host lint path remains. - Two consecutive `script/lint` runs on an unchanged tree both demonstrably execute the linter. - Negative control: introduce a deliberate lint violation, confirm it fails with that specific finding, revert, confirm clean. - `make check` still green, and it now actually includes lint. Canonical tracking issue: https://git.eeqj.de/sneak/prompts/issues/40
Author
Collaborator

Correction to one premise in the issue body: script/check does not omit script/lint. On main at 7d7bec5 it runs test, then lint, then fmt-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.

Correction to one premise in the issue body: `script/check` does **not** omit `script/lint`. On `main` at `7d7bec5` it runs `test`, then `lint`, then `fmt-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`.
Author
Collaborator

The blocking constraint: this repo cannot copy sneak/homoicon verbatim

homoicon's main Dockerfile invokes its tools directly and never shells back into make. This repo's Dockerfile ends with RUN echo "check epoch: ${CHECK_EPOCH}" && make check, and make check runs script/lint. The moment script/lint becomes docker 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 — so script/cibuild fails, and script/cibuild is what .gitea/workflows/check.yml runs on every push. Resolve this deliberately; do not discover it.

Do not solve it by giving script/lint a "am I already in a container?" escape hatch. That is a host lint path wearing a disguise and the definition of done forbids it.

  1. Dockerfile.lint at the repo root, same pinned-by-digest alpine base the Dockerfile already uses. COPY script/ then RUN script/bootstrap (hugo and node both come from there), then COPY . .. The bootstrap layer compiles Hugo from source, so it must sit above the cache-busting ARG and keep caching, exactly as the existing Dockerfile does.
  2. Lint runs as build steps in that file: the hugo --minify --printPathWarnings build and the prettier check. A successful build is a clean lint.
  3. script/lint reduces to building Dockerfile.lint with a per-invocation cache-busting build arg, generated the same way script/cibuild and script/docker already generate CHECK_EPOCH — as a whole assignment, not inline, for the set -e reason documented in script/cibuild.
  4. The ARG is declared with no default and guarded ([ -n "$X" ] || exit 1), and its value is expanded into the lint RUN commands so invalidation does not depend on BuildKit's treatment of an unreferenced ARG. This repo already learned that both the guard and the checked command must reference the value.
  5. Every entrypoint that builds an image passes the arg — this repo already got burned by a Dockerfile guard asserting a property one entrypoint did not supply. If script/fmt-check also becomes a container build, it passes it too.
  6. Main Dockerfile and script/cibuild adjust 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/lint runs the linter only in Docker. No host lint path, no in-container bypass.
  • Two consecutive script/lint runs 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.
  • Negative control: introduce a deliberate violation of each check the lint image performs, confirm the build fails with that specific finding, revert, confirm clean.
  • script/cibuild still succeeds, and you can show it executed rather than being served from cache.
  • make check green and still covering lint.

Constraints

  • Never 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-cache on a single build.
  • The site is live in production. deploy.yml and the deploy path are out of scope for this issue; if you believe one must change, stop and say so.
## The blocking constraint: this repo cannot copy `sneak/homoicon` verbatim `homoicon`'s main `Dockerfile` invokes its tools directly and never shells back into `make`. This repo's `Dockerfile` ends with `RUN echo "check epoch: ${CHECK_EPOCH}" && make check`, and `make check` runs `script/lint`. The moment `script/lint` becomes `docker 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 — so `script/cibuild` fails, and `script/cibuild` is what `.gitea/workflows/check.yml` runs on every push. Resolve this deliberately; do not discover it. Do **not** solve it by giving `script/lint` a "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) 1. `Dockerfile.lint` at the repo root, same pinned-by-digest alpine base the `Dockerfile` already uses. `COPY script/` then `RUN script/bootstrap` (hugo and node both come from there), then `COPY . .`. The bootstrap layer compiles Hugo from source, so it must sit **above** the cache-busting `ARG` and keep caching, exactly as the existing `Dockerfile` does. 2. Lint runs as build steps in that file: the `hugo --minify --printPathWarnings` build and the prettier check. A successful build is a clean lint. 3. `script/lint` reduces to building `Dockerfile.lint` with a per-invocation cache-busting build arg, generated the same way `script/cibuild` and `script/docker` already generate `CHECK_EPOCH` — as a whole assignment, not inline, for the `set -e` reason documented in `script/cibuild`. 4. The `ARG` is declared with no default and guarded (`[ -n "$X" ] || exit 1`), and its value is expanded into the lint `RUN` commands so invalidation does not depend on BuildKit's treatment of an unreferenced `ARG`. This repo already learned that both the guard and the checked command must reference the value. 5. **Every entrypoint that builds an image passes the arg** — this repo already got burned by a Dockerfile guard asserting a property one entrypoint did not supply. If `script/fmt-check` also becomes a container build, it passes it too. 6. Main `Dockerfile` and `script/cibuild` adjust 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/lint` runs the linter only in Docker. No host lint path, no in-container bypass. - Two consecutive `script/lint` runs 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. - Negative control: introduce a deliberate violation of each check the lint image performs, confirm the build fails with that specific finding, revert, confirm clean. - `script/cibuild` still succeeds, and you can show it executed rather than being served from cache. - `make check` green and still covering lint. ## Constraints - Never `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-cache` on a single build. - The site is live in production. `deploy.yml` and the deploy path are out of scope for this issue; if you believe one must change, stop and say so.
Author
Collaborator

Implementation plan

The recursion question decides everything else, so it goes first.

Decision: the main Dockerfile stops running make check

make check cannot run inside the main image once script/lint is a docker 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 into make check:

  • Dockerfile (unchanged role otherwise) runs make test — the clean hugo --minify production build, and nothing else. It keeps its CHECK_EPOCH ARG, its no-default guard, and the value expanded into the RUN.
  • 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 shared base:
    • linthugo --minify --printPathWarnings
    • fmt-check — the prettier check, same version, same scope, same flags as script/fmt
  • base is the same digest-pinned alpine the Dockerfile uses, with COPY 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 Dockerfile invokes script/lint, script/fmt-check or script/check, so there is no docker-in-docker anywhere and no in-container bypass to write.

Entrypoints

  • script/lint becomes docker build -f Dockerfile.lint --target lint with a per-invocation cache-busting arg. Nothing else. No host path, no branch.
  • script/fmt-check becomes the same build with --target fmt-check. It has to move too: leaving prettier running on the host would leave a host lint path in make check, which the definition of done forbids.
  • script/fmt stays 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/check is untouched: test, lint, fmt-check. Two of those three are now container builds.
  • script/cibuild builds the main image and then calls script/lint and script/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.
  • Every entrypoint that builds an image generates and passes the arg — 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 the set -e reason script/cibuild documents.

Cache busting

Same shape this repo already settled: ARG CHECK_EPOCH declared per stage with no default, guarded with [ -n "$CHECK_EPOCH" ] || exit 1, and the value expanded into the checked RUN as well, so invalidation does not rest on BuildKit's handling of an unreferenced ARG. ARG is stage-scoped, so both the lint and fmt-check stages declare and guard it independently. Everything above the ARG still caches.

Known duplication, stated rather than hidden

The prettier version and flags, and the hugo lint flags, are inlined into Dockerfile.lint instead of being reached through a script. That is forced: any RUN script/lint or RUN script/fmt-check inside 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 runs make check) and TODO.md per its own Workflow.

Verification I will show

Two consecutive script/lint runs 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; a script/cibuild run with evidence the check layers executed rather than being served from cache. No global cache prune — invalidation stays scoped to the single build.

## Implementation plan The recursion question decides everything else, so it goes first. ### Decision: the main `Dockerfile` stops running `make check` `make check` cannot run inside the main image once `script/lint` is a `docker 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 into `make check`: - **`Dockerfile`** (unchanged role otherwise) runs `make test` — the clean `hugo --minify` production build, and nothing else. It keeps its `CHECK_EPOCH` `ARG`, its no-default guard, and the value expanded into the `RUN`. - **`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 shared `base`: - `lint` — `hugo --minify --printPathWarnings` - `fmt-check` — the prettier check, same version, same scope, same flags as `script/fmt` - `base` is the same digest-pinned alpine the `Dockerfile` uses, with `COPY 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 `Dockerfile` invokes `script/lint`, `script/fmt-check` or `script/check`, so there is no docker-in-docker anywhere and no in-container bypass to write. ### Entrypoints - `script/lint` becomes `docker build -f Dockerfile.lint --target lint` with a per-invocation cache-busting arg. Nothing else. No host path, no branch. - `script/fmt-check` becomes the same build with `--target fmt-check`. It has to move too: leaving prettier running on the host would leave a host lint path in `make check`, which the definition of done forbids. - `script/fmt` stays 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/check` is untouched: `test`, `lint`, `fmt-check`. Two of those three are now container builds. - `script/cibuild` builds the main image and then calls `script/lint` and `script/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. - Every entrypoint that builds an image generates and passes the arg — `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 the `set -e` reason `script/cibuild` documents. ### Cache busting Same shape this repo already settled: `ARG CHECK_EPOCH` declared per stage with **no default**, guarded with `[ -n "$CHECK_EPOCH" ] || exit 1`, and the value expanded into the checked `RUN` as well, so invalidation does not rest on BuildKit's handling of an unreferenced `ARG`. `ARG` is stage-scoped, so both the `lint` and `fmt-check` stages declare and guard it independently. Everything above the `ARG` still caches. ### Known duplication, stated rather than hidden The prettier version and flags, and the hugo lint flags, are inlined into `Dockerfile.lint` instead of being reached through a script. That is forced: any `RUN script/lint` or `RUN script/fmt-check` inside 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 runs `make check`) and `TODO.md` per its own Workflow. ### Verification I will show Two consecutive `script/lint` runs 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; a `script/cibuild` run with evidence the check layers executed rather than being served from cache. No global cache prune — invalidation stays scoped to the single build.
Author
Collaborator

Implemented as f5761b6 on next, carried by #39 .

What landed

Dockerfile.lint at the repo root, on the same digest-pinned alpine, with a shared base stage (COPY script/ then RUN script/bootstrap then COPY . .) and two check stages built on it:

  • linthugo --minify --printPathWarnings
  • fmt-check — the prettier check, same version, scope and flags as script/fmt

The checks are build steps, so a successful build is a clean check. script/lint and script/fmt-check are reduced to docker 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 main Dockerfile's and in the same order, so the script/bootstrap layer 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/bootstrap came back CACHED off the lint image's layers.

The recursion, resolved deliberately

make check runs script/lint, which is now a docker build, so the main Dockerfile can no longer RUN make check — that is docker-in-docker in a bare alpine with no docker client and no daemon socket, and script/cibuild is what check.yml runs on every push. Resolved by splitting the checks by where they run, not by an escape hatch:

  • main DockerfileRUN ... make test, the clean production build, and nothing else
  • Dockerfile.lint stage lint — the lint
  • Dockerfile.lint stage fmt-check — the format check
  • script/cibuild — builds the main image, then calls script/lint and script/fmt-check

So CI still covers lint, the production build and the format check, and it runs the same scripts a developer runs. script/check is 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 a make check line.

Noted plainly: this deviates from REPO_POLICIES.md's "all Dockerfiles must run make check". That rule and "every lint run happens in Docker" cannot both hold once make check contains the lint.

script/fmt stays 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 the fmt-check stage duplicates. Both sides carry a keep-in-sync note. The duplication is forced: any RUN script/fmt-check in the image is the recursion again.

Cache busting

ARG CHECK_EPOCH, no default, guarded with [ -n "$CHECK_EPOCH" ] || exit 1, value expanded into the checked RUN as well as the guard. Declared and guarded separately in each stage, since ARG does not cross a FROM. 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/lint runs the linter only in Docker. The script is four lines of docker build; git diff shows the hugo invocation removed from it entirely.

Two consecutive runs on an unchanged tree both executed the linter. Second run, 2.9s wall:

#8 [base 4/5] RUN script/bootstrap
#8 CACHED
#11 [lint 2/2] RUN echo "lint epoch: 17863657614329692671406715" && hugo --minify --printPathWarnings
#11 0.175 lint epoch: 17863657614329692671406715
#11 0.272 Start building sites …
#11 0.272 hugo v0.164.0 linux/amd64 BuildDate=unknown
#11 0.302 Total in 37 ms
#11 DONE 0.7s
real	0m2.948s

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:

#11 [lint 2/2] RUN echo "lint epoch: constant-counterfactual-i38" && hugo --minify --printPathWarnings
#11 CACHED
real	0m0.249s

Exit 0 in a quarter second, no hugo output at all.

Guard fails closed. With an empty epoch:

#10 [lint 1/2] RUN [ -n "" ] || exit 1
#10 ERROR: process "/bin/sh -c [ -n \"$CHECK_EPOCH\" ] || exit 1" did not complete successfully: exit code: 1

Negative control, lint stage. An undefined field appended to baseof.html:

#11 0.392 ERROR error building site: render: ... execute of template failed: template: index.html:20:3:
executing "index.html" at <.ThisFunctionDoesNotExist>: can't evaluate field ThisFunctionDoesNotExist
in type *hugolib.pageState
#11 ERROR: process "/bin/sh -c echo \"lint epoch: ${CHECK_EPOCH}\" && hugo --minify --printPathWarnings"
did not complete successfully: exit code: 1

Reverted, re-run clean.

Negative control, fmt-check stage. An over-long unwrapped line appended to README.md:

#11 1.916 [warn] README.md
#11 2.468 [warn] Code style issues found in the above file. Run Prettier with --write to fix.
#11 ERROR: ... did not complete successfully: exit code: 1

Reverted, re-run clean (All matched files use Prettier code style!).

script/cibuild succeeds and demonstrably executed. One invocation, exit 0, 20s wall, three distinct epochs, every script/bootstrap layer CACHED:

#12 [7/7] RUN echo "check epoch: 17863663064508791421889833" && make test
#12 0.359 Total in 23 ms
#12 [lint 2/2] RUN echo "lint epoch: 17863663117476264401893611" && hugo --minify --printPathWarnings
#12 0.407 Total in 26 ms
#12 [fmt-check 2/2] RUN echo "fmt-check epoch: 17863663165809243071900182" && npx --yes "prettier@3.4.2" --check ...
#12 1.703 All matched files use Prettier code style!

make check green 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 lint stage fails on hugo build errors but not on render-target collisions — --printPathWarnings prints 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.md was updated in the same commit per its Workflow section. .gitea/workflows/deploy.yml and the deploy path were not touched; check.yml needed no change, since it already runs script/cibuild.

Implemented as `f5761b6` on `next`, carried by https://git.eeqj.de/sneak/lora.vegas/pulls/39 . ## What landed `Dockerfile.lint` at the repo root, on the same digest-pinned alpine, with a shared `base` stage (`COPY script/` then `RUN script/bootstrap` then `COPY . .`) and two check stages built on it: - `lint` — `hugo --minify --printPathWarnings` - `fmt-check` — the prettier check, same version, scope and flags as `script/fmt` The checks are build steps, so a successful build is a clean check. `script/lint` and `script/fmt-check` are reduced to `docker 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 main `Dockerfile`'s and in the same order, so the `script/bootstrap` layer 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/bootstrap` came back `CACHED` off the lint image's layers. ## The recursion, resolved deliberately `make check` runs `script/lint`, which is now a `docker build`, so the main `Dockerfile` can no longer `RUN make check` — that is docker-in-docker in a bare alpine with no docker client and no daemon socket, and `script/cibuild` is what `check.yml` runs on every push. Resolved by splitting the checks by where they run, not by an escape hatch: - main `Dockerfile` — `RUN ... make test`, the clean production build, and nothing else - `Dockerfile.lint` stage `lint` — the lint - `Dockerfile.lint` stage `fmt-check` — the format check - `script/cibuild` — builds the main image, then calls `script/lint` and `script/fmt-check` So CI still covers lint, the production build and the format check, and it runs the same scripts a developer runs. `script/check` is 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 a `make check` line. Noted plainly: this deviates from `REPO_POLICIES.md`'s "all Dockerfiles must run `make check`". That rule and "every lint run happens in Docker" cannot both hold once `make check` contains the lint. `script/fmt` stays 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 the `fmt-check` stage duplicates. Both sides carry a keep-in-sync note. The duplication is forced: any `RUN script/fmt-check` in the image is the recursion again. ## Cache busting `ARG CHECK_EPOCH`, no default, guarded with `[ -n "$CHECK_EPOCH" ] || exit 1`, value expanded into the checked `RUN` as well as the guard. Declared and guarded separately in each stage, since `ARG` does not cross a `FROM`. 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/lint` runs the linter only in Docker.** The script is four lines of `docker build`; `git diff` shows the `hugo` invocation removed from it entirely. **Two consecutive runs on an unchanged tree both executed the linter.** Second run, 2.9s wall: ``` #8 [base 4/5] RUN script/bootstrap #8 CACHED #11 [lint 2/2] RUN echo "lint epoch: 17863657614329692671406715" && hugo --minify --printPathWarnings #11 0.175 lint epoch: 17863657614329692671406715 #11 0.272 Start building sites … #11 0.272 hugo v0.164.0 linux/amd64 BuildDate=unknown #11 0.302 Total in 37 ms #11 DONE 0.7s real 0m2.948s ``` **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: ``` #11 [lint 2/2] RUN echo "lint epoch: constant-counterfactual-i38" && hugo --minify --printPathWarnings #11 CACHED real 0m0.249s ``` Exit 0 in a quarter second, no hugo output at all. **Guard fails closed.** With an empty epoch: ``` #10 [lint 1/2] RUN [ -n "" ] || exit 1 #10 ERROR: process "/bin/sh -c [ -n \"$CHECK_EPOCH\" ] || exit 1" did not complete successfully: exit code: 1 ``` **Negative control, lint stage.** An undefined field appended to `baseof.html`: ``` #11 0.392 ERROR error building site: render: ... execute of template failed: template: index.html:20:3: executing "index.html" at <.ThisFunctionDoesNotExist>: can't evaluate field ThisFunctionDoesNotExist in type *hugolib.pageState #11 ERROR: process "/bin/sh -c echo \"lint epoch: ${CHECK_EPOCH}\" && hugo --minify --printPathWarnings" did not complete successfully: exit code: 1 ``` Reverted, re-run clean. **Negative control, fmt-check stage.** An over-long unwrapped line appended to `README.md`: ``` #11 1.916 [warn] README.md #11 2.468 [warn] Code style issues found in the above file. Run Prettier with --write to fix. #11 ERROR: ... did not complete successfully: exit code: 1 ``` Reverted, re-run clean (`All matched files use Prettier code style!`). **`script/cibuild` succeeds and demonstrably executed.** One invocation, exit 0, 20s wall, three distinct epochs, every `script/bootstrap` layer `CACHED`: ``` #12 [7/7] RUN echo "check epoch: 17863663064508791421889833" && make test #12 0.359 Total in 23 ms #12 [lint 2/2] RUN echo "lint epoch: 17863663117476264401893611" && hugo --minify --printPathWarnings #12 0.407 Total in 26 ms #12 [fmt-check 2/2] RUN echo "fmt-check epoch: 17863663165809243071900182" && npx --yes "prettier@3.4.2" --check ... #12 1.703 All matched files use Prettier code style! ``` **`make check` green 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 `lint` stage fails on hugo build errors but not on render-target collisions — `--printPathWarnings` prints and exits 0, which is why the lint negative control above is a build error rather than a collision. Pre-existing and tracked at https://git.eeqj.de/sneak/lora.vegas/issues/25 ; containerising the run neither fixes nor worsens it, and fixing it was out of scope here. `TODO.md` was updated in the same commit per its Workflow section. `.gitea/workflows/deploy.yml` and the deploy path were not touched; `check.yml` needed no change, since it already runs `script/cibuild`.
Author
Collaborator

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/fmt and script/fmt-check stay on the host.

This overrides part of my earlier brief. Point 5 of the recommended shape floated "if script/fmt-check also becomes a container build" as an open option — it is now closed, and closed the other way. Consequences:

  • script/lint is the only entrypoint that becomes a Docker build.
  • script/fmt-check running on the host means there is no recursion in invoking it inside the main image, so the main Dockerfile can and should still run the production build and the format check directly.
  • This lands the repo on the same shape as the canonical text in 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) containerised script/fmt-check and dropped the format check from the main image, so it needs rework on both points.

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/fmt` and `script/fmt-check` stay on the host. This overrides part of my earlier brief. Point 5 of the recommended shape floated "if `script/fmt-check` also becomes a container build" as an open option — it is now closed, and closed the other way. Consequences: - `script/lint` is the only entrypoint that becomes a Docker build. - `script/fmt-check` running on the host means there is no recursion in invoking it inside the main image, so the main `Dockerfile` can and should still run the production build and the format check directly. - This lands the repo on the same shape as the canonical text in `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`) containerised `script/fmt-check` and dropped the format check from the main image, so it needs rework on both points.
Author
Collaborator

Reworked. f5761b6 is amended to 25b6c0a on next, carried by #39 . 407b0a0 is preserved as the parent.

What changed against the owner ruling

The containerisation boundary is now lint only. script/fmt and script/fmt-check are back on the host with the exact version, scope and flags they had on mainprettier@3.4.2, '**/*.md' '**/*.css' --tab-width 4 --prose-wrap always. The fmt-check stage is gone from Dockerfile.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 Dockerfile runs the production build and the format check again — RUN script/test and RUN script/fmt-check as separate lines under the CHECK_EPOCH guard, matching the canonical sneak/prompts shape. Only the lint is absent from it, and it is not skipped: script/cibuild now runs script/lint first, for fail-fast feedback, before the main image build starts.

The one-stage decision

Dropping the fmt-check stage also removed a live false green rather than only a policy divergence. A whole-file docker 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's lint stage was skipped entirely by exactly the invocation the canonical org-wide script/lint uses — exit 0, no lint. The per-stage CHECK_EPOCH guard could not catch it, since the stage that did run satisfied it.

Dockerfile.lint now has exactly one stage, script/lint passes 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/lint also 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, so script/bootstrap still 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/lint runs, unchanged tree, both executed the lint. Second run, 0.849s wall:

#9 CACHED                       (RUN script/bootstrap)
#11 [6/7] RUN [ -n "17863676988156482392882534" ] || exit 1
#12 [7/7] RUN echo "lint epoch: 17863676988156482392882534" && hugo --minify --printPathWarnings
#12 0.278 lint epoch: 17863676988156482392882534
#12 0.316 Start building sites …
#12 0.316 hugo v0.164.0 linux/amd64 BuildDate=unknown
#12 0.337 Total in 23 ms
#12 DONE 0.3s

No lint layer was ever CACHED in 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:

#12 [7/7] RUN echo "lint epoch: rw38probe-1786367753851078619" && hugo --minify --printPathWarnings
#12 0.224 lint epoch: rw38probe-1786367753851078619
#12 0.277 Total in 19 ms
#12 DONE 0.3s

Guard fails closed. docker build -f Dockerfile.lint . with no argument:

  60 | >>> RUN [ -n "$CHECK_EPOCH" ] || exit 1
ERROR: failed to build: failed to solve: process "/bin/sh -c [ -n \"$CHECK_EPOCH\" ] || exit 1" did not complete successfully: exit code: 1

Negative control, lint. An undefined field appended to themes/loravega/layouts/index.html:

0.210 ERROR error building site: render: [en v1.0.0 guest] failed to render pages: render of
"/src/content/_index.md" failed: "/src/themes/loravega/layouts/index.html:7:3": execute of
template failed: template: index.html:7:3: executing "index.html" at
<.ThisFieldDoesNotExistRw38>: can't evaluate field ThisFieldDoesNotExistRw38 in type
*hugolib.pageState

Reverted, re-run clean.

Negative control, host script/fmt-check. An over-long unwrapped line appended to README.md gave exit 1 with [warn] README.md and Code style issues found in the above file. Run Prettier with --write to fix.; reverted, All matched files use Prettier code style!.

script/cibuild fails fast. With that same template error planted, it exited 1 in 0.630s and the main image build never started — zero check epoch lines in the whole log.

All three entrypoints green with each check observed executing. script/cibuild 11.5s (lint build with its own epoch and real hugo output, then the main image with a distinct check epoch, script/test output, and RUN script/fmt-check printing All matched files use Prettier code style!; RUN script/bootstrap CACHED in both builds, which is the shared-prefix cache hit working). make check 3.9s. script/docker 10.3s.

Deploy path untouched, checked as a diff rather than inferred: .gitea/, script/bootstrap, script/test and .dockerignore are byte-identical to main at 7d7bec5.

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 — --printPathWarnings prints 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.md was updated in the same commit per its Workflow section.

Reworked. `f5761b6` is amended to `25b6c0a` on `next`, carried by https://git.eeqj.de/sneak/lora.vegas/pulls/39 . `407b0a0` is preserved as the parent. ## What changed against the owner ruling The containerisation boundary is now **lint only**. `script/fmt` and `script/fmt-check` are back on the host with the exact version, scope and flags they had on `main` — `prettier@3.4.2`, `'**/*.md' '**/*.css' --tab-width 4 --prose-wrap always`. The `fmt-check` stage is gone from `Dockerfile.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 `Dockerfile` runs the production build **and** the format check again — `RUN script/test` and `RUN script/fmt-check` as separate lines under the `CHECK_EPOCH` guard, matching the canonical `sneak/prompts` shape. Only the lint is absent from it, and it is not skipped: `script/cibuild` now runs `script/lint` **first**, for fail-fast feedback, before the main image build starts. ## The one-stage decision Dropping the `fmt-check` stage also removed a live false green rather than only a policy divergence. A whole-file `docker 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's `lint` stage was skipped entirely by exactly the invocation the canonical org-wide `script/lint` uses — exit 0, no lint. The per-stage `CHECK_EPOCH` guard could not catch it, since the stage that did run satisfied it. `Dockerfile.lint` now has exactly one stage, `script/lint` passes 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/lint` also 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, so `script/bootstrap` still 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/lint` runs, unchanged tree, both executed the lint.** Second run, 0.849s wall: ``` #9 CACHED (RUN script/bootstrap) #11 [6/7] RUN [ -n "17863676988156482392882534" ] || exit 1 #12 [7/7] RUN echo "lint epoch: 17863676988156482392882534" && hugo --minify --printPathWarnings #12 0.278 lint epoch: 17863676988156482392882534 #12 0.316 Start building sites … #12 0.316 hugo v0.164.0 linux/amd64 BuildDate=unknown #12 0.337 Total in 23 ms #12 DONE 0.3s ``` No lint layer was ever `CACHED` in 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`: ``` #12 [7/7] RUN echo "lint epoch: rw38probe-1786367753851078619" && hugo --minify --printPathWarnings #12 0.224 lint epoch: rw38probe-1786367753851078619 #12 0.277 Total in 19 ms #12 DONE 0.3s ``` **Guard fails closed.** `docker build -f Dockerfile.lint .` with no argument: ``` 60 | >>> RUN [ -n "$CHECK_EPOCH" ] || exit 1 ERROR: failed to build: failed to solve: process "/bin/sh -c [ -n \"$CHECK_EPOCH\" ] || exit 1" did not complete successfully: exit code: 1 ``` **Negative control, lint.** An undefined field appended to `themes/loravega/layouts/index.html`: ``` 0.210 ERROR error building site: render: [en v1.0.0 guest] failed to render pages: render of "/src/content/_index.md" failed: "/src/themes/loravega/layouts/index.html:7:3": execute of template failed: template: index.html:7:3: executing "index.html" at <.ThisFieldDoesNotExistRw38>: can't evaluate field ThisFieldDoesNotExistRw38 in type *hugolib.pageState ``` Reverted, re-run clean. **Negative control, host `script/fmt-check`.** An over-long unwrapped line appended to `README.md` gave exit 1 with `[warn] README.md` and `Code style issues found in the above file. Run Prettier with --write to fix.`; reverted, `All matched files use Prettier code style!`. **`script/cibuild` fails fast.** With that same template error planted, it exited 1 in 0.630s and the main image build never started — zero `check epoch` lines in the whole log. **All three entrypoints green with each check observed executing.** `script/cibuild` 11.5s (lint build with its own epoch and real hugo output, then the main image with a distinct `check epoch`, `script/test` output, and `RUN script/fmt-check` printing `All matched files use Prettier code style!`; `RUN script/bootstrap` `CACHED` in both builds, which is the shared-prefix cache hit working). `make check` 3.9s. `script/docker` 10.3s. **Deploy path untouched**, checked as a diff rather than inferred: `.gitea/`, `script/bootstrap`, `script/test` and `.dockerignore` are byte-identical to `main` at `7d7bec5`. 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 — `--printPathWarnings` prints them and exits 0. Pre-existing and tracked at https://git.eeqj.de/sneak/lora.vegas/issues/25 ; containerising the run neither fixes nor worsens it, and it bounds what the lint negative control above could demonstrate. `TODO.md` was updated in the same commit per its Workflow section.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/lora.vegas#38