3a44e81cad
Five doc claims had rotted by v1.9.0, all the same shape: a value written once by hand, in a file nothing verifies, about a number that lives elsewhere and moved. README pin table wrong on all three rows; a "Planned" section describing something already shipped; DOCKER_HUB.md claiming Node v22 against Node 24. DOCKER_HUB.md is why this is a gate and not a resolution to be careful: it is PUBLISHED (update-description POSTs it as Docker Hub full_description on every tag), it had gone eight releases untouched, nothing generates it, and it is read from the TAG -- so the stale page shipped with v1.9.0 regardless. scripts/check-doc-drift.sh: seven checks, all repo-local (no network, token, image, or sibling clone). Exit 0/1/2 matching lint-shell.sh; a renamed ARG is a red 2, not a green tick. Wired as a fourth lint.yml job so "the docs lie" is its own red name. Verified with 15 controls, including two false-positive controls: the first placeholder check flagged README.md:900, a Go template in a legitimate `docker inspect --format` example. The gate was wrong, not the doc, so the pattern is now anchored to the UPPER_SNAKE convention CI substitutes. Not gated, deliberately: counts/sizes needing a running image (they belong in smoke-test.sh -- a guessing gate is worse than none), and Dockerfile.base BASE_REBUILD_DATE, because base_tag hashes that file content-wise and demanding it be current would force a ~60 min rebuild on releases that touch no base files. Free during a rebuild, expensive otherwise. AGENTS.md step 3 rewritten around the mechanism: checkout@v4 with no ref: means every job reads github.ref, the tag. Docs must be right BEFORE tagging.
247 lines
11 KiB
Bash
Executable File
247 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# check-doc-drift.sh — fail when a hand-maintained doc claim contradicts the
|
|
# build files it describes.
|
|
#
|
|
# THE DEFECT CLASS THIS EXISTS TO CATCH, measured 2026-09-10 while preparing
|
|
# v1.9.0. Five separate claims had rotted, all of them the same shape: a fact
|
|
# written once by hand, in a file nothing verifies, about a value that lives
|
|
# somewhere else and moved.
|
|
#
|
|
# 1..3. README.md's "Version pins" table was wrong on EVERY row — pi `0.84.4`
|
|
# vs ARG PI_VERSION=0.85.1, pi-atelier `v0.10.0` vs v0.10.1, mempalace
|
|
# `3.8.0` vs 3.9.0. That table is the worst possible place for this: it
|
|
# exists precisely to be the reviewable record of what is deliberately
|
|
# frozen, so when it lies, the review it enables is worthless.
|
|
# 4. README.md carried a "Planned for an upcoming minor release" section
|
|
# listing typst PDF export, which had ALREADY SHIPPED, tagged with a
|
|
# self-contradicting "(shipped in Unreleased/base)" marker. The
|
|
# CHANGELOG had already documented three earlier instances of exactly
|
|
# this stale-"Unreleased"-pointer class (see its v1.8.7 notes).
|
|
# 5. DOCKER_HUB.md claimed "Node.js v22" while this release ships Node 24.
|
|
# This one is the reason the gate exists at all: DOCKER_HUB.md is
|
|
# PUBLISHED. `update-description` in docker-publish.yml POSTs it to Hub
|
|
# as full_description on every tag, so unlike README.md — which no
|
|
# workflow or gate reads — a stale claim here is what users see.
|
|
#
|
|
# WHY A GATE AND NOT "REMEMBER TO CHECK". DOCKER_HUB.md had gone eight releases
|
|
# (v1.8.6 → v1.9.0) without a touch. Nothing generates it and nothing verifies
|
|
# it; the only mechanism keeping it true was whoever remembered. That is the
|
|
# same failure mode check-skill-floor.sh was written for, and the same fix:
|
|
# convert "someone remembers" into "CI refuses".
|
|
#
|
|
# WHY THESE FIVE CHECKS AND NOT MORE. Every check here compares a doc string to
|
|
# a value that EXISTS IN THIS REPO, so it can never be wrong about the world and
|
|
# needs no network, no token, and no built image. Claims that require a running
|
|
# container to verify (image sizes, the "N mempalace_* tools" count) are
|
|
# deliberately NOT gated: a check that cannot be evaluated honestly at lint time
|
|
# would either be skipped or guessed, and a guessing gate is worse than none.
|
|
# If you want those, assert them in scripts/smoke-test.sh where a real image is
|
|
# available.
|
|
#
|
|
# DELIBERATELY NOT GATED: Dockerfile.base's `# BASE_REBUILD_DATE:` comment, which
|
|
# is also stale (2026-07-13, three base rebuilds ago). base_tag is a hash of
|
|
# Dockerfile.base's CONTENT plus rootfs/, comments included, so a gate that
|
|
# demanded that comment be current would force a ~60 min base rebuild on any
|
|
# release that touched no base files at all. Fix it when you are already
|
|
# rebuilding the base — then it is free. This is a real cost asymmetry, not
|
|
# laziness.
|
|
#
|
|
# EXIT CODES (same contract as lint-shell.sh and check-skill-floor.sh):
|
|
# 0 every checked claim matches
|
|
# 1 at least one claim has drifted
|
|
# 2 cannot run (a file or ARG this gate reads is missing/unparseable)
|
|
# A gate that cannot run must not pass, so a missing input is 2, never 0.
|
|
|
|
set -euo pipefail
|
|
|
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
cd "$REPO_ROOT"
|
|
|
|
README="README.md"
|
|
HUB="DOCKER_HUB.md"
|
|
DF_VARIANT="Dockerfile.variant"
|
|
DF_BASE="Dockerfile.base"
|
|
|
|
# Docker Hub rejects a full_description longer than this. docker-publish.yml has
|
|
# no size check of its own; it only notices via a non-200 from the API, i.e.
|
|
# after paying the whole build. Catching it here makes it a 2-second failure.
|
|
HUB_MAX_CHARS=25000
|
|
|
|
WARN_ONLY=0
|
|
FAILURES=0
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
Usage: check-doc-drift.sh [--warn-only] [-h|--help]
|
|
|
|
Compares hand-written claims in README.md and DOCKER_HUB.md against the build
|
|
files they describe (Dockerfile.base, Dockerfile.variant).
|
|
|
|
--warn-only Report drift but exit 0 (advisory use, e.g. a local pre-push hook).
|
|
|
|
Exit: 0 = in sync, 1 = drift, 2 = cannot run.
|
|
EOF
|
|
}
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--warn-only) WARN_ONLY=1; shift ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) echo "::error::unknown argument: $1" >&2; usage >&2; exit 2 ;;
|
|
esac
|
|
done
|
|
|
|
for f in "$README" "$HUB" "$DF_VARIANT" "$DF_BASE"; do
|
|
if [ ! -f "$f" ]; then
|
|
echo "::error::$f not found (cwd $PWD). Cannot evaluate doc drift, so this is exit 2, not a pass."
|
|
exit 2
|
|
fi
|
|
done
|
|
|
|
# Read `ARG NAME=value` from a Dockerfile. Exit 2 when absent: if the ARG this
|
|
# gate is built around has been renamed, the gate is measuring nothing and must
|
|
# say so rather than silently comparing against an empty string.
|
|
read_arg() {
|
|
local file="$1" name="$2" value
|
|
value="$(sed -n "s/^ARG ${name}=\\(.*\\)\$/\\1/p" "$file" | head -1)"
|
|
if [ -z "$value" ]; then
|
|
echo "::error::ARG ${name} not found in ${file}. It was probably renamed;" >&2
|
|
echo "::error::update check-doc-drift.sh to match, because this gate is now blind." >&2
|
|
exit 2
|
|
fi
|
|
printf '%s' "$value"
|
|
}
|
|
|
|
# One row of README's "Version pins" table: `| pi | `0.85.1` | ... |`
|
|
read_pin_row() {
|
|
sed -n "s/^| $1 | \`\\([^\`]*\`*\\)\` |.*/\\1/p" "$README" | head -1
|
|
}
|
|
|
|
fail() {
|
|
FAILURES=$((FAILURES + 1))
|
|
echo "::error::$1"
|
|
}
|
|
|
|
ok() { printf ' OK %s\n' "$1"; }
|
|
|
|
echo "Checking hand-maintained doc claims against the build files they describe."
|
|
echo
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1-3. README's version-pin table vs the ARGs it names by name.
|
|
# ---------------------------------------------------------------------------
|
|
check_pin() {
|
|
local label="$1" documented="$2" actual="$3" where="$4"
|
|
if [ -z "$documented" ]; then
|
|
fail "README.md: no '| $label |' row found in the version-pin table. Either the
|
|
table was restructured (update this gate) or the row was dropped (restore it)."
|
|
return
|
|
fi
|
|
if [ "$documented" != "$actual" ]; then
|
|
fail "README.md version-pin table is stale for $label: says '$documented',
|
|
$where says '$actual'. Fix the table — it is the reviewable record of what
|
|
this repo deliberately freezes, so a wrong row defeats its only purpose."
|
|
return
|
|
fi
|
|
ok "README pin $label = $actual"
|
|
}
|
|
|
|
PI_ACTUAL="$(read_arg "$DF_VARIANT" PI_VERSION)"
|
|
ATELIER_ACTUAL="$(read_arg "$DF_VARIANT" PI_ATELIER_REF)"
|
|
MEMPALACE_ACTUAL="$(read_arg "$DF_BASE" MEMPALACE_VERSION)"
|
|
|
|
check_pin pi "$(read_pin_row pi)" "$PI_ACTUAL" "ARG PI_VERSION in $DF_VARIANT"
|
|
check_pin pi-atelier "$(read_pin_row pi-atelier)" "$ATELIER_ACTUAL" "ARG PI_ATELIER_REF in $DF_VARIANT"
|
|
check_pin mempalace "$(read_pin_row mempalace)" "$MEMPALACE_ACTUAL" "ARG MEMPALACE_VERSION in $DF_BASE"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. DOCKER_HUB.md's Node claim vs ARG NODE_VERSION. This is the published page,
|
|
# so it is the one whose staleness reaches users.
|
|
# ---------------------------------------------------------------------------
|
|
NODE_ACTUAL="$(read_arg "$DF_BASE" NODE_VERSION)"
|
|
NODE_DOCUMENTED="$(sed -n 's/.*\*\*Node\.js\*\* v\([0-9][0-9]*\).*/\1/p' "$HUB" | head -1)"
|
|
if [ -z "$NODE_DOCUMENTED" ]; then
|
|
fail "$HUB: could not find a '**Node.js** vNN' claim. If the wording changed,
|
|
update this gate; do not leave the published page unverified."
|
|
elif [ "$NODE_DOCUMENTED" != "$NODE_ACTUAL" ]; then
|
|
fail "$HUB claims Node v$NODE_DOCUMENTED but ARG NODE_VERSION=$NODE_ACTUAL.
|
|
This file is PUBLISHED to Docker Hub by update-description on every tag,
|
|
and it is read from the TAG — so fix it before tagging, not after."
|
|
else
|
|
ok "$HUB Node claim = v$NODE_ACTUAL"
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. Placeholders CI will not substitute. docker-publish.yml substitutes exactly
|
|
# {{PI_VERSION}} and then greps for leftovers of that ONE token, so any other
|
|
# {{...}} sails through the guard and is published literally.
|
|
# ---------------------------------------------------------------------------
|
|
UNKNOWN_PLACEHOLDERS="$(grep -o '{{[A-Za-z0-9_]*}}' "$HUB" | sort -u | grep -v '^{{PI_VERSION}}$' || true)"
|
|
if [ -n "$UNKNOWN_PLACEHOLDERS" ]; then
|
|
fail "$HUB contains placeholders CI does not substitute, which would be
|
|
published verbatim: $(echo "$UNKNOWN_PLACEHOLDERS" | tr '\n' ' ')
|
|
docker-publish.yml only fills {{PI_VERSION}}; add substitution there first."
|
|
else
|
|
ok "$HUB has no placeholders beyond {{PI_VERSION}}"
|
|
fi
|
|
|
|
# Match only the UPPER_SNAKE placeholder convention CI uses. A bare '{{' search
|
|
# is WRONG here, and the first version of this check proved it by failing on
|
|
# README.md:900 — `docker inspect --format '{{json .Config.Labels}}'`, a Go
|
|
# template in a legitimate example, not a placeholder. The gate was wrong, not
|
|
# the doc. Keep this anchored to [A-Z] so Go/Jinja/Handlebars examples pass.
|
|
README_PLACEHOLDERS="$(grep -o '{{[A-Z][A-Z0-9_]*}}' "$README" | sort -u || true)"
|
|
if [ -n "$README_PLACEHOLDERS" ]; then
|
|
fail "$README contains placeholder(s) nothing substitutes, so they would render
|
|
literally for every reader: $(echo "$README_PLACEHOLDERS" | tr '\n' ' ')
|
|
Only DOCKER_HUB.md gets substitution, and only for {{PI_VERSION}}."
|
|
else
|
|
ok "$README has no unsubstituted placeholders"
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. Hub full_description length.
|
|
# ---------------------------------------------------------------------------
|
|
HUB_CHARS="$(wc -c < "$HUB" | tr -d ' ')"
|
|
if [ "$HUB_CHARS" -gt "$HUB_MAX_CHARS" ]; then
|
|
fail "$HUB is $HUB_CHARS chars, over Docker Hub's $HUB_MAX_CHARS-char
|
|
full_description limit. update-description would fail with a non-200 AFTER
|
|
the full build. Trim it — this file is the essentials-only page, and
|
|
README.md is the long form on purpose."
|
|
else
|
|
ok "$HUB is $HUB_CHARS chars (limit $HUB_MAX_CHARS)"
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7. Stale "Unreleased" pointers. "Unreleased" is a CHANGELOG-only concept; in
|
|
# a user-facing doc it is always a pointer that outlived what it pointed at.
|
|
# This class has now bitten five times, hence a gate rather than vigilance.
|
|
# ---------------------------------------------------------------------------
|
|
STALE_MARKERS="$(grep -n 'Unreleased' "$README" "$HUB" || true)"
|
|
if [ -n "$STALE_MARKERS" ]; then
|
|
fail "'Unreleased' appears in a user-facing doc, which is always a stale
|
|
pointer once the thing ships (it has happened five times here):
|
|
${STALE_MARKERS//$'\n'/$'\n' }
|
|
State the fact directly, or move it to CHANGELOG.md where 'Unreleased' means something."
|
|
else
|
|
ok "no stale 'Unreleased' pointers in $README or $HUB"
|
|
fi
|
|
|
|
echo
|
|
if [ "$FAILURES" -eq 0 ]; then
|
|
echo "OK: every checked doc claim matches the build files."
|
|
exit 0
|
|
fi
|
|
|
|
echo "::error::$FAILURES doc claim(s) have drifted from the build files."
|
|
echo
|
|
echo "Docs are read from the TAG, not from main: docker-publish.yml checks out"
|
|
echo "github.ref, so a fix pushed after tagging does not reach the release or the"
|
|
echo "Hub page. Update the docs BEFORE you tag."
|
|
|
|
if [ "$WARN_ONLY" -eq 1 ]; then
|
|
echo "(--warn-only: exiting 0 anyway)"
|
|
exit 0
|
|
fi
|
|
exit 1
|