diff --git a/Dockerfile b/Dockerfile index ba7dd74..853cee8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,18 @@ -# Hugo static-site build image. The build runs `make check` (a clean -# `hugo --minify` production build, the `--printPathWarnings` lint -# build, then the read-only prettier docs check), so the image build -# fails on any formatting or Hugo build error. This is what CI -# (script/cibuild) runs on every push. +# 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`. `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 @@ -41,5 +51,5 @@ COPY . . ARG CHECK_EPOCH RUN [ -n "$CHECK_EPOCH" ] || exit 1 -# Run all checks - build fails if any check fails. -RUN echo "check epoch: ${CHECK_EPOCH}" && make check +# Run the production build - build fails if the site does not build. +RUN echo "check epoch: ${CHECK_EPOCH}" && make test diff --git a/Dockerfile.lint b/Dockerfile.lint new file mode 100644 index 0000000..418d740 --- /dev/null +++ b/Dockerfile.lint @@ -0,0 +1,85 @@ +# 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. +# +# 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. +# +# 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 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 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. +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 +# 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 +# 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. +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 diff --git a/README.md b/README.md index 0b38186..b7b9a39 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,12 @@ 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. + `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. @@ -61,22 +65,41 @@ provide: git pre-commit hook - `script/test` — the correctness check: a clean `hugo --minify` production 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 + 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; the Dockerfile runs `make check` +- `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/check` -Build the image through `script/cibuild` or `script/docker` only. Both pass a -per-invocation `CHECK_EPOCH` build argument that the Dockerfile requires, so the -`make 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 Dockerfile's -`CHECK_EPOCH` guard rather than caching its way to a false success. +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. + +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. + +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. diff --git a/TODO.md b/TODO.md index b00c00e..b75b21a 100644 --- a/TODO.md +++ b/TODO.md @@ -20,17 +20,64 @@ 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. +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 -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 (#20). This touches the live deploy -path, so it needs a real workflow run to verify rather than a local check. +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-08-10: moved every lint-class check 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 + `[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 + (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 (closes #10). The repo is public (`private: false` on the Gitea API, verified rather than assumed), so the owner's standing policy — MIT on any public repo @@ -226,8 +273,40 @@ path, so it needs a real workflow run to verify rather than a local check. # Future Steps +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) +- Drop the Go toolchain and module cache from the check image's final layer; + they are needed to build hugo and dead weight afterwards + (https://git.eeqj.de/sneak/lora.vegas/issues/28) +- Add a timeout guard to `script/test` and `script/lint` so a wedged build fails + instead of hanging (https://git.eeqj.de/sneak/lora.vegas/issues/16) +- Sync the reformat of `REPO_POLICIES.md` back upstream to `prompts` so the + canonical copy is clean under the shared prettier settings and future syncs + are a straight byte copy +- Keep mesh channel and signal group listings current + +## 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 + (https://git.eeqj.de/sneak/lora.vegas/issues/20). This touches the live deploy + path, so it needs a real workflow run to verify rather than a local check - Move the deploy container to a pinned node 22 so the wrangler pin can advance - past 4.86.0 (#21) + past 4.86.0 (https://git.eeqj.de/sneak/lora.vegas/issues/21) +- 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`. @@ -237,12 +316,10 @@ path, so it needs a real workflow run to verify rather than a local check. `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 (#14) + 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 (#14) -- Sync the reformat of `REPO_POLICIES.md` back upstream to `prompts` so the - canonical copy is clean under the shared prettier settings and future syncs - are a straight byte copy + and `includeSubDomains` binds hostnames this repo does not control + (https://git.eeqj.de/sneak/lora.vegas/issues/14) - Verify the Cloudflare Pages deploy still works after the workflow changes -- Keep mesh channel and signal group listings current diff --git a/script/check b/script/check index f06027f..b26655e 100755 --- a/script/check +++ b/script/check @@ -3,6 +3,11 @@ # scripts-to-rule-them-all. Must not modify any tracked files. Runs the # 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. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" diff --git a/script/cibuild b/script/cibuild index 1f7a945..887e4d2 100755 --- a/script/cibuild +++ b/script/cibuild @@ -1,17 +1,32 @@ #!/bin/sh -# script/cibuild: run the CI build. The Dockerfile runs `make check`, -# so a successful build implies all checks pass. The Gitea workflow -# runs this on push. +# script/cibuild: run the CI build. The Gitea workflow runs this on +# push, and it is the single entrypoint that covers everything: # -# That implication only holds because of CHECK_EPOCH. Docker keys the -# `RUN make check` layer on content, so on an unchanged tree it is -# served from cache: the checks never execute and the 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. +# 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 +# +# 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 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 -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" @@ -25,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 "$@" diff --git a/script/fmt b/script/fmt index 0e1d5b2..9c99b0b 100755 --- a/script/fmt +++ b/script/fmt @@ -6,6 +6,12 @@ # the reason next to each entry: the Hugo layout templates, which are # 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. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" diff --git a/script/fmt-check b/script/fmt-check index 3af9cb0..02157c8 100755 --- a/script/fmt-check +++ b/script/fmt-check @@ -1,17 +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. +# +# 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 "$@" diff --git a/script/lint b/script/lint index ac4760e..996c464 100755 --- a/script/lint +++ b/script/lint @@ -1,15 +1,26 @@ #!/bin/sh -# script/lint: this Hugo site has no dedicated linter, so the lint gate -# is a clean build that surfaces broken internal links and template -# path problems. It is a real check: `hugo` fails on build errors, and -# --printPathWarnings reports render-target collisions. +# script/lint: run the lint. This Hugo site has no dedicated linter, so +# 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 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)" main() { cd "$ROOT" - hugo --minify --printPathWarnings + epoch="$(date +%s%N)$$" + docker build -f Dockerfile.lint --target lint \ + --build-arg CHECK_EPOCH="$epoch" . } main "$@"