Compare commits

..

1 Commits

Author SHA1 Message Date
clawbot
f5761b6227 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.
2026-08-10 12:52:56 +00:00
9 changed files with 202 additions and 195 deletions

View File

@@ -1,16 +1,18 @@
# Hugo static-site build image. The build runs the individual non-lint # Hugo static-site build image. The build runs `make test` -- a clean
# checks -- script/test, a clean `hugo --minify` production build, and # `hugo --minify` production build -- so the image build fails on any
# script/fmt-check, the read-only prettier check -- so the image build # template, content or config error.
# fails on any template, content, config or formatting error.
# #
# It deliberately does NOT run `make check`, and only the lint is # It deliberately does NOT run `make check`. `make check` runs
# missing from what it does run. `make check` calls script/lint, and # script/lint, and script/lint is a `docker build` of Dockerfile.lint,
# script/lint is a `docker build` of Dockerfile.lint, so `RUN make # so `RUN make check` here would be docker-in-docker inside a bare
# check` here would attempt a docker build inside a build step, in a # alpine with no docker client and no daemon socket. The checks are
# bare alpine with no docker client and no daemon socket. Putting # therefore split by where they run: the production build here, lint and
# `make check` (or a `make lint`) back reintroduces exactly that # the prettier format check in Dockerfile.lint. Do not reintroduce a
# recursion. The lint is not skipped: script/cibuild runs script/lint # `make check` (or a `make lint` / `make fmt-check`) line in this file.
# first, in its own container, before this build starts. #
# CI coverage is unaffected: script/cibuild builds this image and then
# calls script/lint and script/fmt-check, so every check in `make check`
# still runs on every push -- see script/cibuild.
# #
# Build this only via script/cibuild or script/docker: both pass the # Build this only via script/cibuild or script/docker: both pass the
# CHECK_EPOCH build argument that this file requires, and a bare # CHECK_EPOCH build argument that this file requires, and a bare
@@ -49,8 +51,5 @@ COPY . .
ARG CHECK_EPOCH ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1 RUN [ -n "$CHECK_EPOCH" ] || exit 1
# The individual non-lint checks - build fails if either fails. Invoked # Run the production build - build fails if the site does not build.
# as script/ entrypoints rather than `make check` for the reason in the RUN echo "check epoch: ${CHECK_EPOCH}" && make test
# header comment above.
RUN echo "check epoch: ${CHECK_EPOCH}" && script/test
RUN script/fmt-check

View File

@@ -1,61 +1,85 @@
# Lint-only image. `script/lint` builds this file and nothing else: the # Lint image. Every lint-class check for this repo runs here and
# lint runs as a build step, so a successful build IS a clean lint. # nowhere else: the checks are build steps, so a successful build IS a
# clean lint. There is no host lint path and no "am I already in a
# container?" bypass -- script/lint and script/fmt-check are reduced to
# building the stage below that carries their check.
# #
# One stage, deliberately. A whole-file `docker build -f Dockerfile.lint .` # Build this only via script/lint or script/fmt-check: both pass the
# builds only the file's LAST stage, and sibling stages off a shared base # CHECK_EPOCH build argument that the stages here require, and a bare
# have no ordering edge between them, so a second stage sitting beside # `docker build -f Dockerfile.lint .` fails by design. See the guards.
# this one would be silently skipped by exactly the invocation the
# canonical org-wide `script/lint` uses -- a green that linted nothing,
# which is the failure mode this file exists to prevent. With a single
# stage there is nothing to skip and `script/lint` needs no `--target`.
# If a second check is ever added here it must be chained (`FROM lint AS
# ...`) or carry an explicit ordering edge, never left as a sibling.
# #
# Only linting is containerised (owner ruling, 2026-08-10: "fmt and fmt # Why this is a separate file from the main Dockerfile, and why that
# check arent docker, just linting"). script/fmt and script/fmt-check run # one no longer runs `make check`: `make check` runs script/lint, and
# on the host, and the main Dockerfile runs the production build and the # script/lint is now a `docker build`. A `RUN make check` in an image
# format check directly -- see the comment there. # would therefore be docker-in-docker inside a bare alpine with no
# # docker client and no daemon socket. The checks are split by where
# The lint is invoked directly below rather than through `make lint` or # they run instead -- the main Dockerfile runs the production build,
# `script/lint`. That is not a style choice: `script/lint` IS this build, # this file runs lint and the format check -- so no image ever shells
# so calling it from inside would recurse into a docker build with no # back into `make check`. script/cibuild drives all of them, so CI
# daemon. # coverage is unchanged.
#
# This repo's lint is a clean Hugo build that surfaces broken internal
# links and template path problems: `hugo` fails on build errors and
# --printPathWarnings reports render-target collisions.
# alpine 3.21, 2026-02-28 # alpine 3.21, 2026-02-28
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 AS base
WORKDIR /src WORKDIR /src
# Keep these four instructions byte-identical to the main Dockerfile's, # Keep these four instructions byte-identical to the main Dockerfile's,
# in the same order: Docker keys layers on the instruction chain, not on # in the same order: Docker keys layers on the instruction chain, not on
# the file they live in, so an identical prefix means this build is a # the file they live in, so an identical prefix means this stage is a
# cache hit against the main image's layers. script/bootstrap compiles # cache hit against the main image's layers. script/bootstrap compiles
# the pinned Hugo from source, which is by far the most expensive step # the pinned Hugo from source, which is by far the most expensive step
# here, and it must not be paid twice. The dependency layer is also # here, and it must not be paid twice.
# deliberately above the ARG below, so it stays cached and only the lint
# step re-runs on every invocation.
COPY script/ script/ COPY script/ script/
RUN script/bootstrap RUN script/bootstrap
COPY . . COPY . .
# --- lint -------------------------------------------------------------
#
# The site's lint is a clean build that surfaces broken internal links
# and template path problems: `hugo` fails on build errors and
# --printPathWarnings reports render-target collisions.
#
# The flags are inlined rather than reached through `RUN script/lint`,
# and that is forced, not lazy: script/lint is the docker build that
# produces this stage, so calling it here is the recursion described
# above. Keep these flags in sync with script/lint's documentation.
FROM base AS lint
# CHECK_EPOCH is a per-invocation nonce supplied by script/lint. Without # CHECK_EPOCH is a per-invocation nonce supplied by script/lint. Without
# it an unchanged tree serves the lint layer from cache: the lint never # it an unchanged tree serves the check layer from cache: the lint never
# executes and the build still exits 0, which is precisely the false # executes and the build still exits 0, which is precisely the false
# green this repo already fixed once in the main Dockerfile. Caching is # green this repo already fixed once in the main Dockerfile. Caching is
# explicitly waived for lint, so the value is expanded into the linted # explicitly waived for lint, so the value is expanded into the checked
# command as well as the guard -- two independent value-keyed # command as well as the guard -- two independent value-keyed
# invalidation points, so a cache miss never depends on BuildKit's # invalidation points, so a cache miss never depends on BuildKit's
# treatment of an unreferenced ARG, and the epoch is visible in the build # treatment of an unreferenced ARG. Declared with no default: a default
# log. Declared with no default: a default is a constant, and a constant # is a constant, and a constant is a stable cache key. ARG is
# is a stable cache key. The guard makes a bare # stage-scoped, so the fmt-check stage below declares its own.
# `docker build -f Dockerfile.lint .` fail loudly instead of silently #
# reusing the empty (and therefore stable) cache key. Keep both # Everything above this line still caches, so script/bootstrap is not
# references. # rebuilt.
ARG CHECK_EPOCH ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1 RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "lint epoch: ${CHECK_EPOCH}" && hugo --minify --printPathWarnings RUN echo "lint epoch: ${CHECK_EPOCH}" && hugo --minify --printPathWarnings
# --- fmt-check --------------------------------------------------------
#
# The read-only prettier check over this repo's markdown and CSS. Same
# version, same scope and same flags as script/fmt, which is the
# authoritative copy and stays on the host because it writes to the
# working tree; keep the two in sync. The exclusions live in
# .prettierignore with the reason for each.
#
# Built as a sibling of `lint` rather than stacked on top of it so that
# a --target build runs exactly one check, and so a lint failure and a
# formatting failure are reported independently.
FROM base AS fmt-check
# Same nonce, same reasoning as the lint stage above. Declared again
# because ARG does not cross a FROM.
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "fmt-check epoch: ${CHECK_EPOCH}" && \
npx --yes "prettier@3.4.2" --check \
'**/*.md' '**/*.css' --tab-width 4 --prose-wrap always

View File

@@ -39,13 +39,12 @@ build, and the formatting check:
make check make check
``` ```
The lint runs inside Docker, so `make check` needs a working Docker daemon; The lint build and the formatting check run inside Docker, so `make check` needs
there is no host fallback. On a machine that has never built the image, the a working Docker daemon; there is no host fallback.
first run compiles the pinned Hugo from source, which takes minutes; later runs
reuse that cached layer.
`make fmt` rewrites the repo's markdown and CSS to the project's prettier `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 To contribute to this site, contact **sneak@sneak.berlin** for git repository
access. access.
@@ -67,36 +66,40 @@ provide:
- `script/test` — the correctness check: a clean `hugo --minify` production - `script/test` — the correctness check: a clean `hugo --minify` production
build 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 `Dockerfile.lint`, where the lint is a build run inside Docker: it builds the `lint` stage of `Dockerfile.lint`, where the
step, so a successful build is a clean lint 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; - `script/fmt` — format every markdown and CSS file in the repo with prettier;
the exclusions live in `.prettierignore` with the reason for each the exclusions live in `.prettierignore` with the reason for each. The one
- `script/fmt-check` — check that formatting (read-only) 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`; - `script/check` — run `script/test`, `script/lint`, then `script/fmt-check`;
modifies no tracked files modifies no tracked files
- `script/docker` — build the Docker image tagged with the project name - `script/docker` — build the Docker image tagged with the project name
- `script/cibuild` — the CI build: `script/lint` first, for fail-fast feedback, - `script/cibuild` — the CI build: the main image (the production build), then
then the main image, which runs the non-lint checks `script/lint` and `script/fmt-check`
- `script/install-precommit` — install the git pre-commit hook that runs - `script/install-precommit` — install the git pre-commit hook that runs
`script/check` `script/check`
Every lint run for this repo happens inside a container, and only the lint does. Every lint run for this repo happens inside a container. `script/lint` and
`script/lint` has no host path and no "already inside a container?" branch, so `script/fmt-check` have no host path and no "already inside a container?"
what a developer runs and what CI runs are the same build. `script/fmt` and branch, so what a developer runs and what CI runs are the same build.
`script/fmt-check` run on the host: a formatting check is not a lint.
That is also why the main `Dockerfile` runs `script/test` and `script/fmt-check` That is also why the main `Dockerfile` runs `make test` rather than
rather than `make check`. `make check` calls `script/lint`, which is itself a `make check`: `make check` calls `script/lint`, which is itself a
`docker build`, so a `make check` inside an image would attempt a docker build `docker build`, so a `make check` inside an image would be docker-in-docker in a
in a bare Alpine with no docker client and no daemon socket. The lint is not bare Alpine with no docker client and no daemon socket. The checks are split by
skipped — `script/cibuild` runs it first, in its own container, before the main where they run — the production build in `Dockerfile`, lint and the format check
image build starts. in `Dockerfile.lint` — and `script/cibuild` drives all three, so CI coverage is
unchanged.
Build any image through `script/cibuild`, `script/docker` or `script/lint` only. Build any image through `script/cibuild`, `script/docker`, `script/lint` or
All three pass a per-invocation `CHECK_EPOCH` build argument that the `script/fmt-check` only. All four pass a per-invocation `CHECK_EPOCH` build
Dockerfiles require, so a check layer can never be served from cache — without argument that the Dockerfiles require, so a check layer can never be served from
it Docker returns a green it did not earn. A bare `docker build` fails closed on cache — without it Docker returns a green it did not earn. A bare `docker build`
the `CHECK_EPOCH` guard rather than caching its way to a false success. 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. A convenience `make serve` target runs `hugo server` for local preview.

91
TODO.md
View File

@@ -20,10 +20,10 @@ wrangler CLI install, an exact version), and the Hugo that builds the published
site is a deliberate pinned version rather than whatever the base image's site is a deliberate pinned version rather than whatever the base image's
package repo serves. The site now ships a Cloudflare Pages `_headers` file, so package repo serves. The site now ships a Cloudflare Pages `_headers` file, so
its response security headers are declared in the repo instead of being whatever its response security headers are declared in the repo instead of being whatever
the edge defaults to — unverified in production until the next deploy. The lint the edge defaults to — unverified in production until the next deploy. Every
now runs inside a container and nowhere else: `script/lint` is a build of lint-class check now runs inside a container and nowhere else: `script/lint` and
`Dockerfile.lint`, with no host path to fall back to. Formatting is not a lint `script/fmt-check` build stages of `Dockerfile.lint`, with no host path to fall
and stays on the host. back to.
# Next Step # Next Step
@@ -36,53 +36,46 @@ section matches.
# Completed Steps # Completed Steps
- 2026-08-10: moved the lint into Docker - 2026-08-10: moved every lint-class check into Docker
(https://git.eeqj.de/sneak/lora.vegas/issues/38). A new root `Dockerfile.lint` (https://git.eeqj.de/sneak/lora.vegas/issues/38). A new root `Dockerfile.lint`
runs `hugo --minify --printPathWarnings` as a build step, so a successful carries the checks as build steps — a `lint` stage running
build is a clean lint, and `script/lint` is nothing but a build of that file — `hugo --minify --printPathWarnings` and a `fmt-check` stage running the
no host path and deliberately no "already inside a container?" branch, which prettier check — on a shared `base` stage whose first four instructions are
would be a host lint path in disguise. The containerisation boundary is lint byte-identical to the main `Dockerfile`'s, so the expensive
only, per the owner ruling of the same day: formatting is not a lint, so `RUN script/bootstrap` layer that compiles Hugo from source is a cache hit
`script/fmt` and `script/fmt-check` stay on the host. `Dockerfile.lint` has against the main image rather than a second build of the same thing.
exactly one stage on purpose. A whole-file `docker build -f Dockerfile.lint .` `script/lint` and `script/fmt-check` are now nothing but a `docker build` of
builds only the file's last stage, and sibling stages off a shared base have their stage; there is no host path and deliberately no "already inside a
no ordering edge, so any second stage beside the lint would be silently container?" branch, which would be a host lint path in disguise. The recursion
skipped by the invocation the canonical org-wide `script/lint` uses — a green this creates was resolved by splitting the checks by where they run rather
that linted nothing. With one stage there is nothing to skip and `script/lint` than by adding an escape hatch: `make check` runs `script/lint`, so the main
needs no `--target`. Its first four instructions are byte-identical to the `Dockerfile` can no longer `RUN make check` — that would be docker-in-docker
main `Dockerfile`'s, so the expensive `RUN script/bootstrap` layer that inside a bare Alpine with no docker client and no daemon socket, and
compiles Hugo from source is shared between the two images rather than paid `script/cibuild` is what CI runs on every push. The main `Dockerfile`
twice. The recursion this creates was resolved by direction, not detection: therefore runs `make test`, the production build, and `script/cibuild` builds
`make check` calls `script/lint`, so the main `Dockerfile` can no longer it and then calls `script/lint` and `script/fmt-check`, so CI still covers all
`RUN make check` — that would attempt a docker build inside a build step, in a three and cannot drift from what a developer runs. `script/fmt` stays on the
bare Alpine with no docker client and no daemon socket. It runs the individual host because it rewrites the working tree, which makes it the authoritative
non-lint checks instead, `script/test` and `script/fmt-check`, matching the copy of the prettier version and flags that the `fmt-check` stage duplicates;
canonical shape upstream, and `script/cibuild` runs `script/lint` first for both sides carry a keep-in-sync note, and that duplication is forced, since
fail-fast feedback before the main image build starts. So CI still covers all any `RUN script/fmt-check` inside the image is the recursion again. Caching is
three checks and cannot drift from what a developer runs. Caching is waived waived for the checks exactly as the main `Dockerfile` already does it:
for the lint exactly as the main `Dockerfile` already does it: `ARG CHECK_EPOCH` with no default, declared and guarded separately in each
`ARG CHECK_EPOCH` with no default, guarded with stage because `ARG` does not cross a `FROM`, with the value expanded into the
`[ -n "$CHECK_EPOCH" ] || exit 1`, and the value expanded into the linted checked command as well as the guard. All four image-building entrypoints now
command as well as the guard, so invalidation never rests on BuildKit's generate and pass it — `script/cibuild`, `script/docker`, `script/lint`,
handling of an unreferenced `ARG`. Every image-building entrypoint generates `script/fmt-check` — which is the failure mode this repo already hit once, a
and passes it — `script/cibuild`, `script/docker`, `script/lint` — which is Dockerfile guard asserting a property one entrypoint did not supply. Verified
the failure mode this repo already hit once, a Dockerfile guard asserting a rather than assumed: two consecutive `script/lint` runs on an unchanged tree
property one entrypoint did not supply. `script/lint` builds with both executed hugo for real (second run 2.9s wall, `RUN script/bootstrap`
`--output type=cacheonly`: the build is run for its exit status, not for an `CACHED`, distinct epoch echoed, `Total in 37 ms` printed), a constant-epoch
image, and since the lint layer is cache-busted every run an exporting build counterfactual restored the false green (exit 0 in 0.25s, lint layer `CACHED`,
would leave one dangling image per lint on a host shared with other work. no hugo output at all), an empty epoch failed closed on the guard, a broken
Verified rather than assumed: two consecutive `script/lint` runs on an template failed the lint stage with hugo's own render error, and an over-long
unchanged tree both executed hugo for real (second run 0.85s wall, line appended to `README.md` failed the fmt-check stage with
`RUN script/bootstrap` `CACHED`, distinct epochs echoed, real build tables
printed); a whole-file `docker build -f Dockerfile.lint .` with the argument
and no `--target` ran the lint for real, which is the regression test for the
skipped-sibling hazard; a bare build with no argument failed closed on the
guard; a planted template error failed the lint with hugo's own render error
and made `script/cibuild` exit in 0.6s without the main image build starting
at all; a planted over-long line failed the host `script/fmt-check` with
`[warn] README.md`; both violations were reverted and re-run clean. Not `[warn] README.md`; both violations were reverted and re-run clean. Not
changed here, and still true: the lint fails on hugo build errors but not on changed here, and still true: the lint stage fails on hugo build errors but
render-target collisions, which `--printPathWarnings` only prints not on render-target collisions, which `--printPathWarnings` only prints
(https://git.eeqj.de/sneak/lora.vegas/issues/25) — containerising the run (https://git.eeqj.de/sneak/lora.vegas/issues/25) — containerising the run
neither fixes nor worsens that neither fixes nor worsens that
- 2026-08-10: added the `LICENSE` file and made the README say what it says - 2026-08-10: added the `LICENSE` file and made the README say what it says

View File

@@ -4,13 +4,10 @@
# canonical order: the clean production build, then the lint build that # canonical order: the clean production build, then the lint build that
# reports path warnings, then the read-only formatting check. # reports path warnings, then the read-only formatting check.
# #
# The lint - and only the lint - runs inside Docker: it is a build of # The last two run inside Docker (they build stages of Dockerfile.lint),
# Dockerfile.lint, so this script needs a working docker daemon and has # so this script needs a working docker daemon. That is deliberate:
# no host fallback to drop back to. Budget for the cold case: the first # every lint run for this repo happens in a container, and there is no
# lint on a machine with no cached script/bootstrap layer compiles the # host fallback to drop back to.
# pinned Hugo from source, which takes minutes. That cost falls on the
# pre-commit hook too, since it runs this script. Every later run reuses
# that layer and only the lint step re-executes.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"

View File

@@ -1,30 +1,28 @@
#!/bin/sh #!/bin/sh
# script/cibuild: run the CI build. The Gitea workflow runs this on # script/cibuild: run the CI build. The Gitea workflow runs this on
# push, and it is the single entrypoint that covers everything. Two # push, and it is the single entrypoint that covers everything:
# container builds, in order:
# #
# 1. script/lint, which builds Dockerfile.lint -- the lint runs as a # 1. the main Dockerfile, which runs the clean `hugo --minify`
# build step there # production build (`make test`)
# 2. the main Dockerfile, which runs the non-lint checks: the clean # 2. script/lint, which builds Dockerfile.lint's `lint` stage
# `hugo --minify` production build (script/test) and the read-only # 3. script/fmt-check, which builds Dockerfile.lint's `fmt-check`
# prettier check (script/fmt-check) # stage
# #
# Lint goes first, for fail-fast feedback: on a runner with no cached # Steps 2 and 3 are delegated to the same scripts a developer runs, so
# script/bootstrap layer the main image compiles Hugo from source, and a # CI cannot drift from `make check`. They are separate builds rather
# lint failure should not wait behind that. It is a separate build # than a `RUN make check` inside the main image because script/lint is
# rather than a step inside the main image because script/lint is itself # itself a `docker build`: shelling back into `make check` from an image
# a `docker build`, and a docker build cannot run a docker build. See # would be docker-in-docker inside a bare alpine with no docker client
# Dockerfile.lint for the full reasoning. # and no daemon socket. See Dockerfile.lint for the full reasoning.
# #
# The lint is delegated to the same script a developer runs, so CI # The main image build below only implies a passing production build
# cannot drift from `make check`. # because of CHECK_EPOCH. Docker keys the `RUN make test` layer on
# # content, so on an unchanged tree it is served from cache: the build
# Neither build implies a passing check without CHECK_EPOCH. Docker keys # never executes and the image build still exits 0. Passing a value that
# the check layers on content, so on an unchanged tree they are served # differs on every invocation invalidates that layer and everything
# from cache: nothing executes and the build still exits 0. Passing a # below it, while the script/bootstrap toolchain layer above it keeps
# value that differs on every invocation invalidates those layers and # caching. script/lint and script/fmt-check each do the same for their
# everything below them, while the script/bootstrap toolchain layer # own stage.
# above keeps caching. script/lint does the same for its own build.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -32,8 +30,6 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
"$SCRIPT_DIR/lint"
# Assigned to a variable rather than substituted inline in the # Assigned to a variable rather than substituted inline in the
# argument list: a command substitution that fails inside an # argument list: a command substitution that fails inside an
# argument does not trip `set -e`, so the inline form would quietly # argument does not trip `set -e`, so the inline form would quietly
@@ -44,6 +40,9 @@ main() {
# 0, so `$$` is appended to cover that degradation. # 0, so `$$` is appended to cover that degradation.
epoch="$(date +%s%N)$$" epoch="$(date +%s%N)$$"
docker build --build-arg CHECK_EPOCH="$epoch" . docker build --build-arg CHECK_EPOCH="$epoch" .
"$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
} }
main "$@" main "$@"

View File

@@ -7,10 +7,11 @@
# Go templates and not HTML, and content/, whose reformatting was # Go templates and not HTML, and content/, whose reformatting was
# measured to change the rendered page. # measured to change the rendered page.
# #
# Both prettier entrypoints run on the host: only linting is # This runs on the host, unlike the read-only check: it rewrites the
# containerised (owner ruling, 2026-08-10), and formatting is not a # working tree, which a container build cannot do. It is therefore the
# lint. Keep the version, scope and flags here in sync with # authoritative copy of the prettier version, scope and flags -- the
# script/fmt-check. # fmt-check stage of Dockerfile.lint duplicates them and must be kept in
# sync with this file.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"

View File

@@ -1,22 +1,27 @@
#!/bin/sh #!/bin/sh
# script/fmt-check: check the formatting of this repo's markdown and # script/fmt-check: check the formatting of this repo's markdown and
# CSS (read-only). Same scope and same settings as script/fmt - keep # CSS (read-only). Like script/lint, it runs only in Docker: it builds
# the two in sync - but fails instead of writing. # the `fmt-check` stage of Dockerfile.lint, where prettier runs as a
# build step. Leaving prettier to run on the host here would have left
# `make check` with a host lint path, which is the thing being removed.
# #
# This runs on the host, not in a container: only linting is # The version, scope and flags live in Dockerfile.lint and must stay in
# containerised (owner ruling, 2026-08-10), and a formatting check is # sync with script/fmt, which is the authoritative copy and stays on the
# not a lint. Running here also keeps it usable offline, since npx # host because it writes to the working tree.
# reuses ~/.npm/_npx after the first run. #
# Dockerfile.lint requires the CHECK_EPOCH build argument, generated
# here exactly as script/cibuild generates it -- see that script for why
# the check layer must not be allowed to cache, and why the value is
# built in an assignment rather than inline.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
PRETTIER_VERSION="3.4.2"
main() { main() {
cd "$ROOT" cd "$ROOT"
npx --yes "prettier@${PRETTIER_VERSION}" --check \ epoch="$(date +%s%N)$$"
'**/*.md' '**/*.css' --tab-width 4 --prose-wrap always docker build -f Dockerfile.lint --target fmt-check \
--build-arg CHECK_EPOCH="$epoch" .
} }
main "$@" main "$@"

View File

@@ -3,18 +3,14 @@
# the lint gate is a clean build that surfaces broken internal links and # the lint gate is a clean build that surfaces broken internal links and
# template path problems -- but where it runs is not negotiable: every # template path problems -- but where it runs is not negotiable: every
# lint run happens inside a Docker container, so this script does # lint run happens inside a Docker container, so this script does
# nothing except build Dockerfile.lint. The lint is a build step there, # nothing except build the `lint` stage of Dockerfile.lint. The check is
# so a successful build is a clean lint. There is deliberately no host # a build step there, so a successful build is a clean lint. There is
# fallback and no "already inside a container?" branch: either would be # deliberately no host fallback and no "already inside a container?"
# a host lint path wearing a disguise. # branch: either would be a host lint path wearing a disguise.
#
# No --target: Dockerfile.lint has exactly one stage, so the whole-file
# build IS the lint. See that file for why a second, sibling stage would
# be a silent skip.
# #
# Dockerfile.lint requires the CHECK_EPOCH build argument, generated # Dockerfile.lint requires the CHECK_EPOCH build argument, generated
# here exactly as script/cibuild generates it -- see that script for why # here exactly as script/cibuild generates it -- see that script for why
# the lint layer must not be allowed to cache, and why the value is # the check layer must not be allowed to cache, and why the value is
# built in an assignment rather than inline. # built in an assignment rather than inline.
set -eu set -eu
@@ -23,18 +19,8 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
epoch="$(date +%s%N)$$" epoch="$(date +%s%N)$$"
# --output type=cacheonly: this build is run for its exit status, docker build -f Dockerfile.lint --target lint \
# not for an image. Because the lint layer is cache-busted on every --build-arg CHECK_EPOCH="$epoch" .
# invocation the result is a new image every time, and an untagged
# build would leave one dangling image per lint run on a host shared
# with other work. cacheonly keeps the build cache (so
# script/bootstrap still hits) and exports nothing. Failures still
# propagate: an empty CHECK_EPOCH or a failing lint exits non-zero.
docker build \
--build-arg CHECK_EPOCH="$epoch" \
--output type=cacheonly \
-f Dockerfile.lint \
.
} }
main "$@" main "$@"