Compare commits

..

1 Commits

Author SHA1 Message Date
clawbot
25b6c0a9de Run the lint inside Docker via Dockerfile.lint (closes #38)
All checks were successful
check / check (push) Successful in 1m23s
Add a root Dockerfile.lint that runs `hugo --minify --printPathWarnings`
as a build step, so a successful build IS a clean lint, and reduce
script/lint to building that file. There is no host lint path and
deliberately no "am I already inside a container?" branch, which would
be a host lint path in disguise.

The containerisation boundary is lint only, per the owner ruling on the
issue: formatting is not a lint, so script/fmt and script/fmt-check stay
on the host, unchanged in version, scope and flags. That also removes
the forced duplication of prettier's settings between a script and a
Dockerfile, and with it the keep-in-sync notes that duplication needed.

Dockerfile.lint has exactly one stage on purpose. 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 a second
stage beside the lint would be silently skipped by exactly the
invocation the canonical org-wide script/lint uses -- a green that
linted nothing, which the per-stage CHECK_EPOCH guard cannot catch
because the stage that did run satisfies it. With one stage there is
nothing to skip and script/lint needs no --target. A comment in the file
says that any second check added here must be chained or carry an
explicit ordering edge, never left as a sibling.

Its first four instructions are byte-identical to the main Dockerfile's
and in the same order, so the expensive `RUN script/bootstrap` layer
that compiles the pinned Hugo from source is shared between the two
images rather than paid twice.

Resolve the recursion by direction, not detection. `make check` calls
script/lint, and script/lint is now a `docker build`, so `RUN make
check` in an image would attempt a docker build inside a build step
where there is no daemon. The main Dockerfile therefore runs the
individual non-lint checks -- script/test and script/fmt-check, as
separate RUN lines under the CHECK_EPOCH guard -- matching the canonical
shape, and only the lint is absent from it. script/cibuild runs
script/lint first, for fail-fast feedback: on a runner with no cached
bootstrap layer a lint failure should not wait behind a Hugo build from
source. CI coverage is therefore unchanged, and it runs the same scripts
a developer runs.

Caching is waived for the lint in the shape this repo already settled:
ARG CHECK_EPOCH with no default, guarded with
`[ -n "$CHECK_EPOCH" ] || exit 1`, and the value expanded into the
linted command as well as the guard, so invalidation never rests on
BuildKit's treatment of an unreferenced ARG. Every image-building
entrypoint generates and passes it -- script/cibuild, script/docker,
script/lint -- each as a whole assignment rather than inline, for the
`set -e` reason script/cibuild documents.

script/lint builds with `--output type=cacheonly`: the build is run for
its exit status, not for an image, and because the lint layer is
cache-busted on every invocation an exporting build leaves one dangling
image per lint run. On a host shared with other work that accumulates.
The build cache is unaffected, so script/bootstrap still hits, and
failures still propagate.

Two divergences from REPO_POLICIES.md, stated rather than buried:

  - REPO_POLICIES.md:92, "all Dockerfiles must run `make check`". That
    rule and "every lint run happens in Docker" cannot both hold once
    `make check` contains the lint.
  - REPO_POLICIES.md:102-168, which requires a separate lint stage whose
    result the build stage depends on through
    `COPY --from=lint /src/go.sum /dev/null`, on the stated grounds that
    without the edge "the build stage would not wait for lint to finish
    and a lint failure might not fail the overall build". No such edge
    exists here: the lint is its own file and its own build, sequenced
    by script/cibuild rather than by BuildKit. Both sections are
    superseded upstream by 12e8db8 in sneak/prompts, which deletes the
    Go multistage lint stage and its ordering trick for the same reason
    -- that stage ran `make lint`, which is now a docker build.

Verified: two consecutive script/lint runs on an unchanged tree both
executed hugo for real, distinct epochs echoed, script/bootstrap CACHED,
second run 0.85s; a whole-file `docker build -f Dockerfile.lint .` with
the argument and no --target ran the lint for real; 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 non-zero
in 0.6s with the main image build never starting; a planted over-long
line failed the host script/fmt-check; both reverted and re-run clean;
`make check`, script/docker and script/cibuild all green with every
check layer observed executing rather than served from cache, and the
bootstrap layer CACHED in both images. The deploy path is byte-identical
to main: .gitea/, script/bootstrap, script/test and .dockerignore are
untouched.
2026-08-10 13:20:40 +00:00
9 changed files with 195 additions and 202 deletions

View File

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

View File

@@ -1,85 +1,61 @@
# Lint image. Every lint-class check for this repo runs here and # Lint-only image. `script/lint` builds this file and nothing else: the
# nowhere else: the checks are build steps, so a successful build IS a # lint runs as a build step, so a successful build IS a clean lint.
# 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.
# #
# Build this only via script/lint or script/fmt-check: both pass the # One stage, deliberately. A whole-file `docker build -f Dockerfile.lint .`
# CHECK_EPOCH build argument that the stages here require, and a bare # builds only the file's LAST stage, and sibling stages off a shared base
# `docker build -f Dockerfile.lint .` fails by design. See the guards. # have no ordering edge between them, so a second stage sitting beside
# 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.
# #
# Why this is a separate file from the main Dockerfile, and why that # Only linting is containerised (owner ruling, 2026-08-10: "fmt and fmt
# one no longer runs `make check`: `make check` runs script/lint, and # check arent docker, just linting"). script/fmt and script/fmt-check run
# script/lint is now a `docker build`. A `RUN make check` in an image # on the host, and the main Dockerfile runs the production build and the
# would therefore be docker-in-docker inside a bare alpine with no # format check directly -- see the comment there.
# docker client and no daemon socket. The checks are split by where #
# they run instead -- the main Dockerfile runs the production build, # The lint is invoked directly below rather than through `make lint` or
# this file runs lint and the format check -- so no image ever shells # `script/lint`. That is not a style choice: `script/lint` IS this build,
# back into `make check`. script/cibuild drives all of them, so CI # so calling it from inside would recurse into a docker build with no
# coverage is unchanged. # daemon.
#
# 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 AS base FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
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 stage is a # the file they live in, so an identical prefix means this build 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. # here, and it must not be paid twice. The dependency layer is also
# 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 check layer from cache: the lint never # it an unchanged tree serves the lint 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 checked # explicitly waived for lint, so the value is expanded into the linted
# 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. Declared with no default: a default # treatment of an unreferenced ARG, and the epoch is visible in the build
# is a constant, and a constant is a stable cache key. ARG is # log. Declared with no default: a default is a constant, and a constant
# stage-scoped, so the fmt-check stage below declares its own. # is a stable cache key. The guard makes a bare
# # `docker build -f Dockerfile.lint .` fail loudly instead of silently
# Everything above this line still caches, so script/bootstrap is not # reusing the empty (and therefore stable) cache key. Keep both
# rebuilt. # references.
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,12 +39,13 @@ build, and the formatting check:
make check make check
``` ```
The lint build and the formatting check run inside Docker, so `make check` needs The lint runs inside Docker, so `make check` needs a working Docker daemon;
a working Docker daemon; there is no host fallback. there is no host fallback. On a machine that has never built the image, the
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. It runs on the host, settings; run it if `make check` fails on formatting.
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.
@@ -66,40 +67,36 @@ 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 the `lint` stage of `Dockerfile.lint`, where the run inside Docker: it builds `Dockerfile.lint`, where the lint is a build
check is a build step, so a successful build is a clean lint 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 one the exclusions live in `.prettierignore` with the reason for each
prettier entrypoint that runs on the host, because it writes to your working - `script/fmt-check` — check that formatting (read-only)
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: the main image (the production build), then - `script/cibuild` — the CI build: `script/lint` first, for fail-fast feedback,
`script/lint` and `script/fmt-check` then the main image, which runs the non-lint checks
- `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. `script/lint` and Every lint run for this repo happens inside a container, and only the lint does.
`script/fmt-check` have no host path and no "already inside a container?" `script/lint` has no host path and no "already inside a container?" branch, so
branch, so what a developer runs and what CI runs are the same build. what a developer runs and what CI runs are the same build. `script/fmt` and
`script/fmt-check` run on the host: a formatting check is not a lint.
That is also why the main `Dockerfile` runs `make test` rather than That is also why the main `Dockerfile` runs `script/test` and `script/fmt-check`
`make check`: `make check` calls `script/lint`, which is itself a 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 `docker build`, so a `make check` inside an image would attempt a docker build
bare Alpine with no docker client and no daemon socket. The checks are split by in a bare Alpine with no docker client and no daemon socket. The lint is not
where they run — the production build in `Dockerfile`, lint and the format check skipped — `script/cibuild` runs it first, in its own container, before the main
in `Dockerfile.lint` — and `script/cibuild` drives all three, so CI coverage is image build starts.
unchanged.
Build any image through `script/cibuild`, `script/docker`, `script/lint` or Build any image through `script/cibuild`, `script/docker` or `script/lint` only.
`script/fmt-check` only. All four pass a per-invocation `CHECK_EPOCH` build All three pass a per-invocation `CHECK_EPOCH` build argument that the
argument that the Dockerfiles require, so a check layer can never be served from Dockerfiles require, so a check layer can never be served from cache — without
cache — without it Docker returns a green it did not earn. A bare `docker build` it Docker returns a green it did not earn. A bare `docker build` fails closed on
fails closed on the `CHECK_EPOCH` guard rather than caching its way to a false the `CHECK_EPOCH` guard rather than caching its way to a false success.
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. Every the edge defaults to — unverified in production until the next deploy. The lint
lint-class check now runs inside a container and nowhere else: `script/lint` and now runs inside a container and nowhere else: `script/lint` is a build of
`script/fmt-check` build stages of `Dockerfile.lint`, with no host path to fall `Dockerfile.lint`, with no host path to fall back to. Formatting is not a lint
back to. and stays on the host.
# Next Step # Next Step
@@ -36,46 +36,53 @@ section matches.
# Completed Steps # Completed Steps
- 2026-08-10: moved every lint-class check into Docker - 2026-08-10: moved the lint 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`
carries the checks as build steps — a `lint` stage running runs `hugo --minify --printPathWarnings` as a build step, so a successful
`hugo --minify --printPathWarnings` and a `fmt-check` stage running the build is a clean lint, and `script/lint` is nothing but a build of that file —
prettier check — on a shared `base` stage whose first four instructions are no host path and deliberately no "already inside a container?" branch, which
byte-identical to the main `Dockerfile`'s, so the expensive would be a host lint path in disguise. The containerisation boundary is lint
`RUN script/bootstrap` layer that compiles Hugo from source is a cache hit only, per the owner ruling of the same day: formatting is not a lint, so
against the main image rather than a second build of the same thing. `script/fmt` and `script/fmt-check` stay on the host. `Dockerfile.lint` has
`script/lint` and `script/fmt-check` are now nothing but a `docker build` of exactly one stage on purpose. A whole-file `docker build -f Dockerfile.lint .`
their stage; there is no host path and deliberately no "already inside a builds only the file's last stage, and sibling stages off a shared base have
container?" branch, which would be a host lint path in disguise. The recursion no ordering edge, so any second stage beside the lint would be silently
this creates was resolved by splitting the checks by where they run rather skipped by the invocation the canonical org-wide `script/lint` uses — a green
than by adding an escape hatch: `make check` runs `script/lint`, so the main that linted nothing. With one stage there is nothing to skip and `script/lint`
`Dockerfile` can no longer `RUN make check` — that would be docker-in-docker needs no `--target`. Its first four instructions are byte-identical to the
inside a bare Alpine with no docker client and no daemon socket, and main `Dockerfile`'s, so the expensive `RUN script/bootstrap` layer that
`script/cibuild` is what CI runs on every push. The main `Dockerfile` compiles Hugo from source is shared between the two images rather than paid
therefore runs `make test`, the production build, and `script/cibuild` builds twice. The recursion this creates was resolved by direction, not detection:
it and then calls `script/lint` and `script/fmt-check`, so CI still covers all `make check` calls `script/lint`, so the main `Dockerfile` can no longer
three and cannot drift from what a developer runs. `script/fmt` stays on the `RUN make check` — that would attempt a docker build inside a build step, in a
host because it rewrites the working tree, which makes it the authoritative bare Alpine with no docker client and no daemon socket. It runs the individual
copy of the prettier version and flags that the `fmt-check` stage duplicates; non-lint checks instead, `script/test` and `script/fmt-check`, matching the
both sides carry a keep-in-sync note, and that duplication is forced, since canonical shape upstream, and `script/cibuild` runs `script/lint` first for
any `RUN script/fmt-check` inside the image is the recursion again. Caching is fail-fast feedback before the main image build starts. So CI still covers all
waived for the checks exactly as the main `Dockerfile` already does it: three checks and cannot drift from what a developer runs. Caching is waived
`ARG CHECK_EPOCH` with no default, declared and guarded separately in each for the lint exactly as the main `Dockerfile` already does it:
stage because `ARG` does not cross a `FROM`, with the value expanded into the `ARG CHECK_EPOCH` with no default, guarded with
checked command as well as the guard. All four image-building entrypoints now `[ -n "$CHECK_EPOCH" ] || exit 1`, and the value expanded into the linted
generate and pass it — `script/cibuild`, `script/docker`, `script/lint`, command as well as the guard, so invalidation never rests on BuildKit's
`script/fmt-check` — which is the failure mode this repo already hit once, a handling of an unreferenced `ARG`. Every image-building entrypoint generates
Dockerfile guard asserting a property one entrypoint did not supply. Verified and passes it — `script/cibuild`, `script/docker`, `script/lint` — which is
rather than assumed: two consecutive `script/lint` runs on an unchanged tree the failure mode this repo already hit once, a Dockerfile guard asserting a
both executed hugo for real (second run 2.9s wall, `RUN script/bootstrap` property one entrypoint did not supply. `script/lint` builds with
`CACHED`, distinct epoch echoed, `Total in 37 ms` printed), a constant-epoch `--output type=cacheonly`: the build is run for its exit status, not for an
counterfactual restored the false green (exit 0 in 0.25s, lint layer `CACHED`, image, and since the lint layer is cache-busted every run an exporting build
no hugo output at all), an empty epoch failed closed on the guard, a broken would leave one dangling image per lint on a host shared with other work.
template failed the lint stage with hugo's own render error, and an over-long Verified rather than assumed: two consecutive `script/lint` runs on an
line appended to `README.md` failed the fmt-check stage with unchanged tree both executed hugo for real (second run 0.85s wall,
`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 stage fails on hugo build errors but changed here, and still true: the lint fails on hugo build errors but not on
not on render-target collisions, which `--printPathWarnings` only prints 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,10 +4,13 @@
# 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 last two run inside Docker (they build stages of Dockerfile.lint), # The lint - and only the lint - runs inside Docker: it is a build of
# so this script needs a working docker daemon. That is deliberate: # Dockerfile.lint, so this script needs a working docker daemon and has
# every lint run for this repo happens in a container, and there is no # no host fallback to drop back to. Budget for the cold case: the first
# host fallback to drop back to. # lint on a machine with no cached script/bootstrap layer compiles the
# 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,28 +1,30 @@
#!/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: # push, and it is the single entrypoint that covers everything. Two
# container builds, in order:
# #
# 1. the main Dockerfile, which runs the clean `hugo --minify` # 1. script/lint, which builds Dockerfile.lint -- the lint runs as a
# production build (`make test`) # build step there
# 2. script/lint, which builds Dockerfile.lint's `lint` stage # 2. the main Dockerfile, which runs the non-lint checks: the clean
# 3. script/fmt-check, which builds Dockerfile.lint's `fmt-check` # `hugo --minify` production build (script/test) and the read-only
# stage # prettier check (script/fmt-check)
# #
# Steps 2 and 3 are delegated to the same scripts a developer runs, so # Lint goes first, for fail-fast feedback: on a runner with no cached
# CI cannot drift from `make check`. They are separate builds rather # script/bootstrap layer the main image compiles Hugo from source, and a
# than a `RUN make check` inside the main image because script/lint is # lint failure should not wait behind that. It is a separate build
# itself a `docker build`: shelling back into `make check` from an image # rather than a step inside the main image because script/lint is itself
# would be docker-in-docker inside a bare alpine with no docker client # a `docker build`, and a docker build cannot run a docker build. See
# and no daemon socket. See Dockerfile.lint for the full reasoning. # Dockerfile.lint for the full reasoning.
# #
# The main image build below only implies a passing production build # The lint is delegated to the same script a developer runs, so CI
# because of CHECK_EPOCH. Docker keys the `RUN make test` layer on # cannot drift from `make check`.
# content, so on an unchanged tree it is served from cache: the build #
# never executes and the image build still exits 0. Passing a value that # Neither build implies a passing check without CHECK_EPOCH. Docker keys
# differs on every invocation invalidates that layer and everything # the check layers on content, so on an unchanged tree they are served
# below it, while the script/bootstrap toolchain layer above it keeps # from cache: nothing executes and the build still exits 0. Passing a
# caching. script/lint and script/fmt-check each do the same for their # value that differs on every invocation invalidates those layers and
# own stage. # everything below them, while the script/bootstrap toolchain layer
# 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)"
@@ -30,6 +32,8 @@ 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
@@ -40,9 +44,6 @@ 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,11 +7,10 @@
# 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.
# #
# This runs on the host, unlike the read-only check: it rewrites the # Both prettier entrypoints run on the host: only linting is
# working tree, which a container build cannot do. It is therefore the # containerised (owner ruling, 2026-08-10), and formatting is not a
# authoritative copy of the prettier version, scope and flags -- the # lint. Keep the version, scope and flags here in sync with
# fmt-check stage of Dockerfile.lint duplicates them and must be kept in # script/fmt-check.
# sync with this file.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"

View File

@@ -1,27 +1,22 @@
#!/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). Like script/lint, it runs only in Docker: it builds # CSS (read-only). Same scope and same settings as script/fmt - keep
# the `fmt-check` stage of Dockerfile.lint, where prettier runs as a # the two in sync - but fails instead of writing.
# 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.
# #
# The version, scope and flags live in Dockerfile.lint and must stay in # This runs on the host, not in a container: only linting is
# sync with script/fmt, which is the authoritative copy and stays on the # containerised (owner ruling, 2026-08-10), and a formatting check is
# host because it writes to the working tree. # not a lint. Running here also keeps it usable offline, since npx
# # 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"
epoch="$(date +%s%N)$$" npx --yes "prettier@${PRETTIER_VERSION}" --check \
docker build -f Dockerfile.lint --target fmt-check \ '**/*.md' '**/*.css' --tab-width 4 --prose-wrap always
--build-arg CHECK_EPOCH="$epoch" .
} }
main "$@" main "$@"

View File

@@ -3,14 +3,18 @@
# 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 the `lint` stage of Dockerfile.lint. The check is # nothing except build Dockerfile.lint. The lint is a build step there,
# a build step there, so a successful build is a clean lint. There is # so a successful build is a clean lint. There is deliberately no host
# deliberately no host fallback and no "already inside a container?" # fallback and no "already inside a container?" branch: either would be
# branch: either would be a host lint path wearing a disguise. # 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 check layer must not be allowed to cache, and why the value is # the lint 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
@@ -19,8 +23,18 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
epoch="$(date +%s%N)$$" epoch="$(date +%s%N)$$"
docker build -f Dockerfile.lint --target lint \ # --output type=cacheonly: this build is run for its exit status,
--build-arg CHECK_EPOCH="$epoch" . # not for an image. Because the lint layer is cache-busted on every
# 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 "$@"