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
10 changed files with 223 additions and 253 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

@@ -1,4 +1,4 @@
.PHONY: bootstrap setup test lint fmt fmt-check check docker cibuild precommit hooks serve .PHONY: bootstrap setup test lint fmt fmt-check check docker hooks serve
bootstrap: bootstrap:
@script/bootstrap @script/bootstrap
@@ -24,12 +24,6 @@ check:
docker: docker:
@script/docker @script/docker
cibuild:
@script/cibuild
precommit:
@script/precommit
hooks: hooks:
@script/install-precommit @script/install-precommit

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,50 +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/precommit` `script/check`
Each of those has a Makefile shim of the same name — `make bootstrap`, Every lint run for this repo happens inside a container. `script/lint` and
`make setup`, `make test`, `make lint`, `make fmt`, `make fmt-check`, `script/fmt-check` have no host path and no "already inside a container?"
`make check`, `make docker`, `make cibuild` — with one exception: branch, so what a developer runs and what CI runs are the same build.
`script/install-precommit` is `make hooks`. `script/precommit`, which is what
the installed hook runs, is `make precommit`. Prefer the make targets; the
`Makefile` lists the operations you are expected to run.
`make cibuild` is the slowest target: it is the only one that runs two container That is also why the main `Dockerfile` runs `make test` rather than
builds, the lint image first and then the main image. The two share the `make check`: `make check` calls `script/lint`, which is itself a
`script/bootstrap` layer byte-for-byte, so the pinned-Hugo compile described `docker build`, so a `make check` inside an image would be docker-in-docker in a
above is paid once per machine rather than twice, and once that layer is cached bare Alpine with no docker client and no daemon socket. The checks are split by
a full `make cibuild` takes seconds. That is the cost of the CI build, not a where they run — the production build in `Dockerfile`, lint and the format check
sign of a problem. in `Dockerfile.lint` — and `script/cibuild` drives all three, so CI coverage is
unchanged.
Every lint run for this repo happens inside a container, and only the lint does. Build any image through `script/cibuild`, `script/docker`, `script/lint` or
`script/lint` has no host path and no "already inside a container?" branch, so `script/fmt-check` only. All four pass a per-invocation `CHECK_EPOCH` build
what a developer runs and what CI runs are the same build. `script/fmt` and argument that the Dockerfiles require, so a check layer can never be served from
`script/fmt-check` run on the host: a formatting check is not a lint. cache — without it Docker returns a green it did not earn. A bare `docker build`
fails closed on the `CHECK_EPOCH` guard rather than caching its way to a false
That is also why the main `Dockerfile` runs `script/test` and `script/fmt-check` success.
rather than `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
in a bare Alpine with no docker client and no daemon socket. The lint is not
skipped — `script/cibuild` runs it first, in its own container, before the main
image build starts.
Build any image through `script/cibuild`, `script/docker` or `script/lint` only.
All three pass a per-invocation `CHECK_EPOCH` build argument that the
Dockerfiles require, so a check layer can never be served from cache — without
it Docker returns a green it did not earn. A bare `docker build` fails closed on
the `CHECK_EPOCH` guard rather than caching its way to a false success.
A convenience `make serve` target runs `hugo server` for local preview. A convenience `make serve` target runs `hugo server` for local preview.

146
TODO.md
View File

@@ -20,99 +20,62 @@ 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, and confirmed live in production on both hostnames. The the edge defaults to — unverified in production until the next deploy. Every
lint 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. Every entrypoint the README documents now has a back to.
`Makefile` target, so the org-wide "use make targets, never the underlying tool"
rule is satisfiable for the CI build as well as for the everyday checks.
# Next Step # Next Step
Make the prettier scope's exclusion of dot-directories explicit instead of Add the missing `cibuild` and `precommit` shims to the `Makefile`, so that every
leaning on `.gitignore` (https://git.eeqj.de/sneak/lora.vegas/issues/33). documented entrypoint has a make target and the documented "always use make
targets" rule is actually satisfiable
(https://git.eeqj.de/sneak/lora.vegas/issues/34). Done when `make cibuild` and
`make precommit` exist, are declared `.PHONY`, and the README Entrypoints
section matches.
# Completed Steps # Completed Steps
- 2026-08-10: added the `cibuild` and `precommit` targets to the `Makefile` - 2026-08-10: moved every lint-class check into Docker
(https://git.eeqj.de/sneak/lora.vegas/issues/34). Both scripts already
existed, were already documented, and are the two entrypoints a contributor is
most likely to be told to run — the CI build and what the pre-commit hook runs
— yet neither had a make target, so the standing rule to use make targets
rather than the underlying tool could not be followed for either. It bit
reviewers twice, most recently on
https://git.eeqj.de/sneak/lora.vegas/pulls/32, and it bit hardest for the
build: since https://git.eeqj.de/sneak/lora.vegas/issues/30 a bare
`docker build .` fails closed on the `CHECK_EPOCH` guard, so `script/cibuild`
is one of only three supported ways to build an image and was the only one
without a target. The two targets are thin shims in the existing style and
change nothing about what the scripts do. `.PHONY` was already complete and
now lists both. `README.md`'s Entrypoints section gained the script-to-target
mapping, including the two names that do not match —
`script/install-precommit` is `make hooks`, and `script/precommit` is
`make precommit` — plus a note that `make cibuild` is the slowest target
because it is the only one that runs two container builds, while still taking
seconds once the shared `script/bootstrap` layer is cached. The section's
pre-existing `script/install-precommit` bullet, which claimed the installed
hook runs `script/check`, now says `script/precommit`, which is what the
script actually writes into `.git/hooks/pre-commit`. The stale Future Step
asking for post-deploy confirmation of `static/_headers` is dropped here: it
was confirmed live on both hostnames
(https://git.eeqj.de/sneak/lora.vegas/issues/14). Verified that `make cibuild`
earns its green rather than replaying a warm cache: one invocation ran both
builds for real in sequence, each with its own distinct `CHECK_EPOCH`, with
`RUN script/bootstrap` `CACHED` above and no check layer cached below it — the
`Dockerfile.lint` build echoed its epoch and printed hugo's own build table,
then the main image build echoed a different epoch, ran `script/test` for the
production build and `script/fmt-check` for the formatting check.
`make precommit` passes on a clean tree, and `make check` passes
- 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`
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
@@ -313,6 +276,8 @@ leaning on `.gitignore` (https://git.eeqj.de/sneak/lora.vegas/issues/33).
Startable work first. Everything under "Blocked" waits on somebody or something Startable work first. Everything under "Blocked" waits on somebody or something
outside this repo, so nothing there may be picked up as the Next Step. outside this repo, so nothing there may be picked up as the Next Step.
- Make the prettier scope's exclusion of dot-directories explicit instead of
leaning on `.gitignore` (https://git.eeqj.de/sneak/lora.vegas/issues/33)
- Fix the README's SSH-only clone URL, and add the two entrypoints the - Fix the README's SSH-only clone URL, and add the two entrypoints the
Entrypoints section omits, `script/precommit` and `script/projectname` Entrypoints section omits, `script/precommit` and `script/projectname`
(https://git.eeqj.de/sneak/lora.vegas/issues/36) (https://git.eeqj.de/sneak/lora.vegas/issues/36)
@@ -342,6 +307,17 @@ outside this repo, so nothing there may be picked up as the Next Step.
- Delete the stale remote branches `feat/initial-site` and `security-audit`; - Delete the stale remote branches `feat/initial-site` and `security-audit`;
only the owner can remove them only the owner can remove them
(https://git.eeqj.de/sneak/lora.vegas/issues/15) (https://git.eeqj.de/sneak/lora.vegas/issues/15)
- After the next deploy, confirm the `_headers` file actually took effect, on
both `https://lora.vegas/` and `https://www.lora.vegas/`: `curl -sSI` against
each must show `strict-transport-security` or `content-security-policy`.
Cloudflare Pages silently ignores a malformed `_headers`, and checking
`x-content-type-options` would pass either way because the edge sends it
regardless. `www` has to be checked too and not just the apex: dropping
`includeSubDomains` rests on `www.lora.vegas` being served by this same Pages
project, which was established behaviourally from identical response bodies
rather than from the Cloudflare dashboard. If `www` turns out not to be
covered, the `includeSubDomains` decision has to be revisited
(https://git.eeqj.de/sneak/lora.vegas/issues/14)
- Decide the HSTS `includeSubDomains` and `preload` posture for `lora.vegas`. - Decide the HSTS `includeSubDomains` and `preload` posture for `lora.vegas`.
Both are owner calls: neither can be walked back inside the max-age window, Both are owner calls: neither can be walked back inside the max-age window,
and `includeSubDomains` binds hostnames this repo does not control and `includeSubDomains` binds hostnames this repo does not control

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 "$@"