Compare commits

..
1 Commits
Author SHA1 Message Date
clawbot f5761b6227 Run every lint-class check inside Docker (closes #38)
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
11 changed files with 233 additions and 321 deletions
-40
View File
@@ -108,43 +108,3 @@ jobs:
run: wrangler pages deploy public --project-name=lora-vegas --branch=${{ github.ref_name }}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
notify:
runs-on: ubuntu-latest
needs: [build, deploy]
# Run whether the build and deploy passed or failed. The Notify step
# carries continue-on-error: true so a notification that cannot be sent
# leaves the deploy's result exactly as it was.
if: always()
container:
# node 20.20.2-bookworm, 2026-08-09. Same pinned image as the deploy
# job, chosen because it ships curl; no extra packages to install.
image: node@sha256:8f693eaa7e0a8e71560c9a82b55fd54c2ae920a2ba5d2cde28bac7d1c01c9ba5
steps:
# needs.deploy.result is "success" only when the deploy job actually
# published; anything else -- a failed build or a failed deploy -- is
# reported as a failure with a link to the run.
- name: Notify
# curl failing (ntfy unreachable or erroring, or NTFY_TOPIC
# absent) must not fail the run, so continue-on-error lives on
# this step and not on the job: on this Gitea a job-level flag
# still marks the job failed and reddens the commit.
continue-on-error: true
env:
RESULT: ${{ needs.deploy.result }}
NTFY_TOPIC: ${{ secrets.NTFY_TOPIC }}
run: |
short="$(printf '%.7s' "$GITHUB_SHA")"
run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
if [ "$RESULT" = success ]; then
curl -fsS --max-time 10 \
-H "Title: lora.vegas deployed from $GITHUB_REF_NAME" \
-d "$short deployed to https://lora.vegas/" \
"https://ntfy.sh/$NTFY_TOPIC"
else
curl -fsS --max-time 10 \
-H "Title: lora.vegas deploy from $GITHUB_REF_NAME failed" \
-H "Priority: high" \
-d "Run: $run_url" \
"https://ntfy.sh/$NTFY_TOPIC"
fi
+16 -17
View File
@@ -1,16 +1,18 @@
# 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.
# 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.
#
# 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.
# 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.
#
# Build this only via script/cibuild or script/docker: both pass the
# CHECK_EPOCH build argument that this file requires, and a bare
@@ -49,8 +51,5 @@ COPY . .
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
# 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
# Run the production build - build fails if the site does not build.
RUN echo "check epoch: ${CHECK_EPOCH}" && make test
+62 -41
View File
@@ -1,64 +1,85 @@
# 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.
# 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.
#
# 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.
# 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.
#
# 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 Hugo build that fails on any build error and on
# any Hugo warning. --printPathWarnings makes Hugo warn when two pages
# write the same output path, and --panicOnWarning turns that warning,
# and every other one, into a failed build. Only the lint is this strict:
# script/test and the deploy build stay plain `hugo --minify`.
# 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.
# alpine 3.21, 2026-02-28
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 AS base
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 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
# 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
# deliberately above the ARG below, so it stays cached and only the lint
# step re-runs on every invocation.
# here, and it must not be paid twice.
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 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
# 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
# invalidation points, so a cache miss never depends on BuildKit's
# 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.
# 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.
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "lint epoch: ${CHECK_EPOCH}" && \
hugo --minify --printPathWarnings --panicOnWarning
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
+1 -7
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:
@script/bootstrap
@@ -24,12 +24,6 @@ check:
docker:
@script/docker
cibuild:
@script/cibuild
precommit:
@script/precommit
hooks:
@script/install-precommit
+31 -43
View File
@@ -39,13 +39,12 @@ build, and the formatting check:
make check
```
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.
The lint build and the formatting check run inside Docker, so `make check` needs
a working Docker daemon; there is no host fallback.
`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
access.
@@ -66,52 +65,41 @@ provide:
git pre-commit hook
- `script/test` — the correctness check: a clean `hugo --minify` production
build
- `script/lint` — a Hugo build that fails on any Hugo warning, including two
pages that write the same output path, run inside Docker: it builds
`Dockerfile.lint`, where the lint is a build step, so a successful build is a
clean lint
- `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
- `script/fmt` — format every markdown and CSS file in the repo with prettier;
the exclusions live in `.prettierignore` with the reason for each
- `script/fmt-check` — check that formatting (read-only)
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`
- `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: `script/lint` first, for fail-fast feedback,
then the main image, which runs the non-lint checks
- `script/cibuild` — the CI build: the main image (the production build), then
`script/lint` and `script/fmt-check`
- `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`,
`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.
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.
`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.
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.
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.
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.
A convenience `make serve` target runs `hugo server` for local preview.
+65 -102
View File
@@ -20,116 +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
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, 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.
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.
# 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).
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.
# Completed Steps
- 2026-11-05: added a `notify` job to the deploy workflow that posts an ntfy
message on every deploy, success or failure (closes
https://git.eeqj.de/sneak/lora.vegas/issues/48). The job runs `if: always()`
so it fires whether the build and deploy passed or failed, and the Notify step
carries `continue-on-error: true` so a notification that cannot be sent leaves
the deploy's result exactly as it was. On success the message body is the
short commit plus the site URL, and on failure it is a link to the run. The
topic comes from the `NTFY_TOPIC` Actions secret, which sneak must add before
anything is sent; the topic is never written in the repository. Both curl
calls carry `--max-time 10` so a stalled ntfy cannot hold the run open
- 2026-10-21: made `script/lint` fail when two pages write the same output path
(https://git.eeqj.de/sneak/lora.vegas/issues/25). `Dockerfile.lint` now runs
`hugo --minify --printPathWarnings --panicOnWarning`. `--printPathWarnings`
makes Hugo warn about the collision, and `--panicOnWarning` turns that warning
and every other Hugo warning into a failed lint; until now the warning was
printed and the lint still exited 0. `script/test` and the deploy build stay
plain `hugo --minify`
- 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
- 2026-08-10: moved every lint-class check into Docker
(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
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
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
`[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
render-target collisions, which `--printPathWarnings` only prints
changed here, and still true: the lint stage 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
@@ -330,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
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)
@@ -345,6 +293,10 @@ outside this repo, so nothing there may be picked up as the Next Step.
## Blocked
- Decide whether `script/lint` should fail on render-target collisions rather
than only print them; `--printPathWarnings` exits 0 today, so the signal is
reported and not enforced. Owner call, since it changes what the gate rejects
(https://git.eeqj.de/sneak/lora.vegas/issues/25)
- Move the artifact actions in `.gitea/workflows/deploy.yml` to v4 once this
Gitea Actions instance serves the v4 artifact protocol; they are pinned on the
deprecated v3 line because v4 fails here
@@ -355,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`;
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
+5 -8
View File
@@ -2,15 +2,12 @@
# script/check: run all checks. Our own extension to
# scripts-to-rule-them-all. Must not modify any tracked files. Runs the
# canonical order: the clean production build, then the lint build that
# fails on any Hugo warning, 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
# 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.
# 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.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
+23 -24
View File
@@ -1,30 +1,28 @@
#!/bin/sh
# script/cibuild: run the CI build. The Gitea workflow runs this on
# push, and it is the single entrypoint that covers everything. Two
# container builds, in order:
# push, and it is the single entrypoint that covers everything:
#
# 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)
# 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
#
# 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.
# 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.
#
# 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.
# 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.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -32,8 +30,6 @@ 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
@@ -44,6 +40,9 @@ 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 "$@"
+5 -4
View File
@@ -7,10 +7,11 @@
# Go templates and not HTML, and content/, whose reformatting was
# measured to change the rendered page.
#
# 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.
# 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.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
+15 -10
View File
@@ -1,22 +1,27 @@
#!/bin/sh
# script/fmt-check: check the formatting of this repo's markdown and
# CSS (read-only). Same scope and same settings as script/fmt - keep
# the two in sync - but fails instead of writing.
# 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.
#
# 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.
# 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.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
PRETTIER_VERSION="3.4.2"
main() {
cd "$ROOT"
npx --yes "prettier@${PRETTIER_VERSION}" --check \
'**/*.md' '**/*.css' --tab-width 4 --prose-wrap always
epoch="$(date +%s%N)$$"
docker build -f Dockerfile.lint --target fmt-check \
--build-arg CHECK_EPOCH="$epoch" .
}
main "$@"
+10 -25
View File
@@ -1,21 +1,16 @@
#!/bin/sh
# script/lint: run the lint. This Hugo site has no dedicated linter, so
# the lint is a Hugo build that fails on any build error and on any Hugo
# warning, including two pages that write the same output path. Where
# it runs is not negotiable. Every lint run happens inside a Docker
# container, so this script does 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.
# 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.
#
# Dockerfile.lint requires the CHECK_EPOCH build argument, generated
# 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.
set -eu
@@ -24,18 +19,8 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
epoch="$(date +%s%N)$$"
# --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 \
.
docker build -f Dockerfile.lint --target lint \
--build-arg CHECK_EPOCH="$epoch" .
}
main "$@"