Compare commits

..

4 Commits

Author SHA1 Message Date
38b0bcf11d Merge pull request '#41: add cibuild and precommit make shims (closes #34)'
All checks were successful
check / check (push) Successful in 11s
Build and Deploy to Cloudflare Pages / build (push) Successful in 45s
Build and Deploy to Cloudflare Pages / deploy (push) Successful in 18s
2026-08-10 16:14:53 +02:00
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 --minify` production build -- so the image build fails on any
# template, content or config error.
# Hugo static-site build image. The build runs the individual non-lint
# checks -- script/test, a clean `hugo --minify` production build, and
# 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
# script/lint, and script/lint is a `docker build` of Dockerfile.lint,
# so `RUN make check` here would be docker-in-docker inside a bare
# alpine with no docker client and no daemon socket. The checks are
# therefore split by where they run: the production build here, lint and
# the prettier format check in Dockerfile.lint. Do not reintroduce a
# `make check` (or a `make lint` / `make fmt-check`) line in this file.
#
# 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.
# It deliberately does NOT run `make check`, and only the lint is
# missing from what it does run. `make check` calls script/lint, and
# script/lint is a `docker build` of Dockerfile.lint, so `RUN make
# check` here would attempt a docker build inside a build step, in a
# bare alpine with no docker client and no daemon socket. Putting
# `make check` (or a `make lint`) back reintroduces exactly that
# recursion. The lint is not skipped: script/cibuild runs script/lint
# first, in its own container, before this build starts.
#
# Build this only via script/cibuild or script/docker: both pass the
# CHECK_EPOCH build argument that this file requires, and a bare
@@ -51,5 +49,8 @@ COPY . .
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
# Run the production build - build fails if the site does not build.
RUN echo "check epoch: ${CHECK_EPOCH}" && make test
# The individual non-lint checks - build fails if either fails. Invoked
# 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
# 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.
# Lint-only image. `script/lint` builds this file and nothing else: the
# lint runs as a build step, so a successful build IS a clean lint.
#
# Build this only via script/lint or script/fmt-check: both pass the
# CHECK_EPOCH build argument that the stages here require, and a bare
# `docker build -f Dockerfile.lint .` fails by design. See the guards.
# One stage, deliberately. A whole-file `docker build -f Dockerfile.lint .`
# builds only the file's LAST stage, and sibling stages off a shared base
# 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
# one no longer runs `make check`: `make check` runs script/lint, and
# script/lint is now a `docker build`. A `RUN make check` in an image
# would therefore be docker-in-docker inside a bare alpine with no
# docker client and no daemon socket. The checks are split by where
# they run instead -- the main Dockerfile runs the production build,
# this file runs lint and the format check -- so no image ever shells
# back into `make check`. script/cibuild drives all of them, so CI
# coverage is unchanged.
# Only linting is containerised (owner ruling, 2026-08-10: "fmt and fmt
# check arent docker, just linting"). script/fmt and script/fmt-check run
# on the host, and the main Dockerfile runs the production build and the
# format check directly -- see the comment there.
#
# The lint is invoked directly below rather than through `make lint` or
# `script/lint`. That is not a style choice: `script/lint` IS this build,
# so calling it from inside would recurse into a docker build with no
# 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
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 AS base
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
WORKDIR /src
# Keep these four instructions byte-identical to the main Dockerfile's,
# 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
# 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/
RUN script/bootstrap
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
# 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
# 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
# invalidation points, so a cache miss never depends on BuildKit's
# treatment of an unreferenced ARG. Declared with no default: a default
# is a constant, and a constant is a stable cache key. ARG is
# stage-scoped, so the fmt-check stage below declares its own.
#
# Everything above this line still caches, so script/bootstrap is not
# rebuilt.
# treatment of an unreferenced ARG, and the epoch is visible in the build
# log. Declared with no default: a default is a constant, and a constant
# is a stable cache key. The guard makes a bare
# `docker build -f Dockerfile.lint .` fail loudly instead of silently
# reusing the empty (and therefore stable) cache key. Keep both
# references.
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
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:
@script/bootstrap
@@ -24,6 +24,12 @@ check:
docker:
@script/docker
cibuild:
@script/cibuild
precommit:
@script/precommit
hooks:
@script/install-precommit

View File

@@ -39,12 +39,13 @@ build, and the formatting check:
make check
```
The lint build and the formatting check run inside Docker, so `make check` needs
a working Docker daemon; there is no host fallback.
The lint runs inside Docker, so `make check` needs a working Docker daemon;
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
settings; run it if `make check` fails on formatting. It runs on the host,
because it writes to your working tree.
settings; run it if `make check` fails on formatting.
To contribute to this site, contact **sneak@sneak.berlin** for git repository
access.
@@ -66,40 +67,50 @@ provide:
- `script/test` — the correctness check: a clean `hugo --minify` production
build
- `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
check is a build step, so a successful build is a clean lint
run inside Docker: it builds `Dockerfile.lint`, where the lint 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;
the exclusions live in `.prettierignore` with the reason for each. The one
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`
the exclusions live in `.prettierignore` with the reason for each
- `script/fmt-check` — check that formatting (read-only)
- `script/check` — run `script/test`, `script/lint`, then `script/fmt-check`;
modifies no tracked files
- `script/docker` — build the Docker image tagged with the project name
- `script/cibuild` — the CI build: the main image (the production build), then
`script/lint` and `script/fmt-check`
- `script/cibuild` — the CI build: `script/lint` first, for fail-fast feedback,
then the main image, which runs the non-lint checks
- `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
`script/fmt-check` have no host path and no "already inside a container?"
branch, so what a developer runs and what CI runs are the same build.
Each of those has a Makefile shim of the same name — `make bootstrap`,
`make setup`, `make test`, `make lint`, `make fmt`, `make fmt-check`,
`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 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
bare Alpine with no docker client and no daemon socket. The checks are split by
where they run — the production build in `Dockerfile`, lint and the format check
in `Dockerfile.lint` — and `script/cibuild` drives all three, so CI coverage is
unchanged.
`make cibuild` is the slowest target: it is the only one that runs two container
builds, the lint image first and then the main image. The two share the
`script/bootstrap` layer byte-for-byte, so the pinned-Hugo compile described
above is paid once per machine rather than twice, and once that layer is cached
a full `make cibuild` takes seconds. That is the cost of the CI build, not a
sign of a problem.
Build any image through `script/cibuild`, `script/docker`, `script/lint` or
`script/fmt-check` only. All four 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.
Every lint run for this repo happens inside a container, and only the lint does.
`script/lint` has no host path and no "already inside a container?" branch, so
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 `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.

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
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
the edge defaults to — unverified in production until the next deploy. Every
lint-class check now runs inside a container and nowhere else: `script/lint` and
`script/fmt-check` build stages of `Dockerfile.lint`, with no host path to fall
back to.
the edge defaults to, and confirmed live in production on both hostnames. The
lint now runs inside a container and nowhere else: `script/lint` is a build of
`Dockerfile.lint`, with no host path to fall back to. Formatting is not a lint
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
Add the missing `cibuild` and `precommit` shims to the `Makefile`, so that every
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.
Make the prettier scope's exclusion of dot-directories explicit instead of
leaning on `.gitignore` (https://git.eeqj.de/sneak/lora.vegas/issues/33).
# 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`
carries the checks as build steps — a `lint` stage running
`hugo --minify --printPathWarnings` and a `fmt-check` stage running the
prettier check — on a shared `base` stage whose first four instructions are
byte-identical to the main `Dockerfile`'s, so the expensive
`RUN script/bootstrap` layer that compiles Hugo from source is a cache hit
against the main image rather than a second build of the same thing.
`script/lint` and `script/fmt-check` are now nothing but a `docker build` of
their stage; there is no host path and deliberately no "already inside a
container?" branch, which would be a host lint path in disguise. The recursion
this creates was resolved by splitting the checks by where they run rather
than by adding 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`, so CI still covers all
three and cannot drift from what a developer runs. `script/fmt` stays on the
host because it rewrites the working tree, which makes it the authoritative
copy of the prettier version and flags that the `fmt-check` stage duplicates;
both sides carry a keep-in-sync note, and that duplication is forced, since
any `RUN script/fmt-check` inside the image is the recursion again. Caching is
waived for the checks exactly as the main `Dockerfile` already does it:
`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. All four image-building entrypoints now
generate and pass it — `script/cibuild`, `script/docker`, `script/lint`,
`script/fmt-check` — which is the failure mode this repo already hit once, a
Dockerfile guard asserting a property one entrypoint did not supply. Verified
rather than assumed: two consecutive `script/lint` runs on an unchanged tree
both executed hugo for real (second run 2.9s wall, `RUN script/bootstrap`
`CACHED`, distinct epoch echoed, `Total in 37 ms` printed), a constant-epoch
counterfactual restored the false green (exit 0 in 0.25s, lint layer `CACHED`,
no hugo output at all), an empty epoch failed closed on the guard, a broken
template failed the lint stage with hugo's own render error, and an over-long
line appended to `README.md` failed the fmt-check stage with
runs `hugo --minify --printPathWarnings` as a build step, so a successful
build is a clean lint, and `script/lint` is nothing but a build of that file —
no host path and deliberately no "already inside a container?" branch, which
would be a host lint path in disguise. The containerisation boundary is lint
only, per the owner ruling of the same day: formatting is not a lint, so
`script/fmt` and `script/fmt-check` stay on the host. `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 have
no ordering edge, so any second stage beside the lint would be silently
skipped by the invocation the canonical org-wide `script/lint` uses — a green
that linted nothing. With one stage there is nothing to skip and `script/lint`
needs no `--target`. Its first four instructions are byte-identical to the
main `Dockerfile`'s, so the expensive `RUN script/bootstrap` layer that
compiles Hugo from source is shared between the two images rather than paid
twice. The recursion this creates was resolved by direction, not detection:
`make check` calls `script/lint`, so the main `Dockerfile` can no longer
`RUN make check` — that would attempt a docker build inside a build step, in a
bare Alpine with no docker client and no daemon socket. It runs the individual
non-lint checks instead, `script/test` and `script/fmt-check`, matching the
canonical shape upstream, and `script/cibuild` runs `script/lint` first for
fail-fast feedback before the main image build starts. So CI still covers all
three checks and cannot drift from what a developer runs. Caching is waived
for the lint exactly as the main `Dockerfile` already does it:
`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
handling of an unreferenced `ARG`. Every image-building entrypoint generates
and passes it — `script/cibuild`, `script/docker`, `script/lint` — which is
the failure mode this repo already hit once, a Dockerfile guard asserting a
property one entrypoint did not supply. `script/lint` builds with
`--output type=cacheonly`: the build is run for its exit status, not for an
image, and since the lint layer is cache-busted every run an exporting build
would leave one dangling image per lint on a host shared with other work.
Verified rather than assumed: two consecutive `script/lint` runs on an
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
changed here, and still true: the lint stage fails on hugo build errors but
not on render-target collisions, which `--printPathWarnings` only prints
changed here, and still true: the lint fails on hugo build errors but not on
render-target collisions, which `--printPathWarnings` only prints
(https://git.eeqj.de/sneak/lora.vegas/issues/25) — containerising the run
neither fixes nor worsens that
- 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
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
Entrypoints section omits, `script/precommit` and `script/projectname`
(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`;
only the owner can remove them
(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`.
Both are owner calls: neither can be walked back inside the max-age window,
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
# reports path warnings, then the read-only formatting check.
#
# The last two run inside Docker (they build stages of Dockerfile.lint),
# so this script needs a working docker daemon. That is deliberate:
# every lint run for this repo happens in a container, and there is no
# host fallback to drop back to.
# The lint - and only the lint - runs inside Docker: it is a build of
# Dockerfile.lint, so this script needs a working docker daemon and has
# no host fallback to drop back to. Budget for the cold case: the first
# 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
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"

View File

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

View File

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

View File

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

View File

@@ -3,14 +3,18 @@
# the lint gate is a clean build that surfaces broken internal links and
# template path problems -- but where it runs is not negotiable: every
# lint run happens inside a Docker container, so this script does
# nothing except build the `lint` stage of Dockerfile.lint. The check is
# a build step there, so a successful build is a clean lint. There is
# deliberately no host fallback and no "already inside a container?"
# branch: either would be a host lint path wearing a disguise.
# nothing except build Dockerfile.lint. The lint is a build step there,
# so a successful build is a clean lint. There is deliberately no host
# fallback and no "already inside a container?" 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
# 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.
set -eu
@@ -19,8 +23,18 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
epoch="$(date +%s%N)$$"
docker build -f Dockerfile.lint --target lint \
--build-arg CHECK_EPOCH="$epoch" .
# --output type=cacheonly: this build is run for its exit status,
# 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 "$@"