From 25b6c0a9de790273c0e5a5eb3f49e36a9a4485d1 Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 10 Aug 2026 12:52:56 +0000 Subject: [PATCH] Run the lint inside Docker via Dockerfile.lint (closes #38) 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. --- Dockerfile | 25 ++++++++--- Dockerfile.lint | 61 ++++++++++++++++++++++++++ README.md | 34 ++++++++++++--- TODO.md | 108 +++++++++++++++++++++++++++++++++++++++++------ script/check | 8 ++++ script/cibuild | 39 ++++++++++++----- script/fmt | 5 +++ script/fmt-check | 5 +++ script/lint | 35 ++++++++++++--- 9 files changed, 279 insertions(+), 41 deletions(-) create mode 100644 Dockerfile.lint diff --git a/Dockerfile b/Dockerfile index ba7dd74..8f20d43 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,16 @@ -# 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 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`, 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 @@ -41,5 +49,8 @@ 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 +# 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 diff --git a/Dockerfile.lint b/Dockerfile.lint new file mode 100644 index 0000000..898ee34 --- /dev/null +++ b/Dockerfile.lint @@ -0,0 +1,61 @@ +# 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. +# +# 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. +# +# 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 + +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 +# 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. +COPY script/ script/ +RUN script/bootstrap + +COPY . . + +# 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 +# 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 +# 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. +ARG CHECK_EPOCH +RUN [ -n "$CHECK_EPOCH" ] || exit 1 +RUN echo "lint epoch: ${CHECK_EPOCH}" && hugo --minify --printPathWarnings diff --git a/README.md b/README.md index 0b38186..0d45073 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,11 @@ 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. + `make fmt` rewrites the repo's markdown and CSS to the project's prettier settings; run it if `make check` fails on formatting. @@ -61,22 +66,37 @@ 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 `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 - `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 Dockerfile runs `make 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` -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, 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. diff --git a/TODO.md b/TODO.md index b00c00e..2c2dfc8 100644 --- a/TODO.md +++ b/TODO.md @@ -20,17 +20,71 @@ 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. 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. # 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 the lint 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 + `[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 + (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 +280,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 +323,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..51cb839 100755 --- a/script/check +++ b/script/check @@ -3,6 +3,14 @@ # 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 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)" diff --git a/script/cibuild b/script/cibuild index 1f7a945..9a0ce4f 100755 --- a/script/cibuild +++ b/script/cibuild @@ -1,20 +1,39 @@ #!/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. Two +# container builds, in order: # -# 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. 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) +# +# 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 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 -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +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 diff --git a/script/fmt b/script/fmt index 0e1d5b2..25514c7 100755 --- a/script/fmt +++ b/script/fmt @@ -6,6 +6,11 @@ # 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. +# +# 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)" diff --git a/script/fmt-check b/script/fmt-check index 3af9cb0..ce79853 100755 --- a/script/fmt-check +++ b/script/fmt-check @@ -2,6 +2,11 @@ # 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. +# +# 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)" diff --git a/script/lint b/script/lint index ac4760e..605d67f 100755 --- a/script/lint +++ b/script/lint @@ -1,15 +1,40 @@ #!/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 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 lint 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)$$" + # --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 "$@"