Compare commits

..

3 Commits

Author SHA1 Message Date
clawbot
fd3cd4c18c Add Makefile shims for cibuild and precommit (closes #34)
All checks were successful
check / check (push) Successful in 16s
script/cibuild and script/precommit both existed and were already the
documented CI and pre-commit entrypoints, but neither had a Makefile
target, so the standing rule to drive the repo through make targets
rather than the underlying tool could not be followed for either.

It matters most for the build. A bare `docker build .` fails closed on
the CHECK_EPOCH guard by design, so script/cibuild is one of only three
supported ways to build an image here, and it was the only one of the
three without a target while `make docker` had one.

The two targets are thin shims in the same style as every other target
and change nothing about what the scripts do. .PHONY was already
complete for the targets that existed and now lists both new ones.

README.md's Entrypoints section gains the script-to-target mapping so
the two documents agree, including the two names that do not match:
script/install-precommit is `make hooks`, and script/precommit is
`make precommit`. It also notes 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, since Dockerfile.lint's first four instructions are
byte-identical to the main Dockerfile's and the pinned-Hugo compile is
therefore paid once per machine rather than twice.

Two accuracy fixes to text the same section already carried. The
script/install-precommit bullet said the installed hook runs
script/check; the script writes script/precommit into
.git/hooks/pre-commit, and its own header comment says so. And the
Makefile is described as listing the operations you are expected to run
rather than as the authoritative list of everything the repo can do,
which is not literally true: script/projectname is an internal helper
that script/docker calls to compute a tag, and it has no target
deliberately -- a target for it would be noise in the `make<tab>`
listing this change exists to make useful.

TODO.md also loses the stale Future Step asking someone to confirm the
static/_headers file took effect in production. That was confirmed live
on both hostnames on 2026-08-10 and recorded at
#14 , so the item is work
already done. The Status paragraph's matching "unverified in production
until the next deploy" clause is corrected for the same reason: a commit
that edits TODO.md should not leave a known-false statement in it.
2026-08-10 13:57:34 +00:00
910f343263 Merge pull request '#39: MIT LICENSE and containerised lint (closes #10, closes #38)'
All checks were successful
check / check (push) Successful in 24s
Build and Deploy to Cloudflare Pages / build (push) Successful in 50s
Build and Deploy to Cloudflare Pages / deploy (push) Successful in 18s
2026-08-10 15:35:21 +02:00
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
10 changed files with 253 additions and 223 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

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

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,50 @@ 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/precommit`
Every lint run for this repo happens inside a container. `script/lint` and Each of those has a Makefile shim of the same name — `make bootstrap`,
`script/fmt-check` have no host path and no "already inside a container?" `make setup`, `make test`, `make lint`, `make fmt`, `make fmt-check`,
branch, so what a developer runs and what CI runs are the same build. `make check`, `make docker`, `make cibuild` — with one exception:
`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.
That is also why the main `Dockerfile` runs `make test` rather than `make cibuild` is the slowest target: it is the only one that runs two container
`make check`: `make check` calls `script/lint`, which is itself a builds, the lint image first and then the main image. The two share the
`docker build`, so a `make check` inside an image would be docker-in-docker in a `script/bootstrap` layer byte-for-byte, so the pinned-Hugo compile described
bare Alpine with no docker client and no daemon socket. The checks are split by above is paid once per machine rather than twice, and once that layer is cached
where they run — the production build in `Dockerfile`, lint and the format check a full `make cibuild` takes seconds. That is the cost of the CI build, not a
in `Dockerfile.lint` — and `script/cibuild` drives all three, so CI coverage is sign of a problem.
unchanged.
Build any image through `script/cibuild`, `script/docker`, `script/lint` or Every lint run for this repo happens inside a container, and only the lint does.
`script/fmt-check` only. All four pass a per-invocation `CHECK_EPOCH` build `script/lint` has no host path and no "already inside a container?" branch, so
argument that the Dockerfiles require, so a check layer can never be served from what a developer runs and what CI runs are the same build. `script/fmt` and
cache — without it Docker returns a green it did not earn. A bare `docker build` `script/fmt-check` run on the host: a formatting check is not a lint.
fails closed on the `CHECK_EPOCH` guard rather than caching its way to a false
success. That is also why the main `Dockerfile` runs `script/test` and `script/fmt-check`
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,62 +20,99 @@ 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, and confirmed live in production on both hostnames. The
lint-class check now runs inside a container and nowhere else: `script/lint` and lint 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. Every entrypoint the README documents now has a
`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
Add the missing `cibuild` and `precommit` shims to the `Makefile`, so that every Make the prettier scope's exclusion of dot-directories explicit instead of
documented entrypoint has a make target and the documented "always use make leaning on `.gitignore` (https://git.eeqj.de/sneak/lora.vegas/issues/33).
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: moved every lint-class check into Docker - 2026-08-10: added the `cibuild` and `precommit` targets to the `Makefile`
(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`
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
@@ -276,8 +313,6 @@ section matches.
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)
@@ -307,17 +342,6 @@ 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,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 "$@"