Run every lint-class check inside Docker (closes #38)
All checks were successful
check / check (push) Successful in 1m9s
All checks were successful
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.
This commit is contained in:
85
Dockerfile.lint
Normal file
85
Dockerfile.lint
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user