b9057fdc8c
Closes the blind spot check 9 (cb6d9e5) named: no label recorded the palace pin, so a MEMPALACE_VERSION bump — the one component whose skew against the shared central palace is fleet-wide — could ship without a CHANGELOG line. In Dockerfile.base, not Dockerfile.variant, deliberately: the value sits next to the ARG that defines it (a copy in the variant is one more pin able to drift); labels are inherited by every image built FROM the base, so no build-arg to plumb through the variant's four call sites; and inheritance means the label states the pin of the base the image ACTUALLY built on, which is the question when base-decide cache-hits an older base. Both mechanisms measured on the published v1.9.2 config blob rather than assumed: maintainer + image.source (set only in Dockerfile.base) are present on the variant image, and pi-version=0.85.1 is an ARG expanded inside a LABEL. Intent, like every se.jordbo.pi-devbox.* label; the manifest's mempalace_version (read from the installed binary) stays the ground truth, and smoke-test.sh now asserts label == installed core — the one way they diverge is a base built with INSTALL_MEMPALACE=false or an off-pin install, both invisible to a label-only check. check-doc-drift check 9 gains the component (literal, against ARG MEMPALACE_VERSION in Dockerfile.base); the label-key rule generalises to "names ending in -version are the label itself". Until a release carries the label it reports a counted SKIP, not OK — measured: "v1.9.2 carries no se.jordbo.pi-devbox.mempalace-version label", summary says 1 SKIPPED. Costs nothing extra: this Unreleased already forces a base rebuild (50153e6rootfs/ skill floor). check-base-hash unchanged (no new *_REF).
699 lines
33 KiB
Bash
Executable File
699 lines
33 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".
|
|
#
|
|
# TWO CLASSES OF CHECK, DELIBERATELY. Checks 1-7 compare a doc string to a
|
|
# value that EXISTS IN THIS REPO, so they can never be wrong about the world and
|
|
# need no network, no token, and no built image. Checks 8-9 compare against what
|
|
# is PUBLISHED (Docker Hub's measured sizes; the ref labels baked into the last
|
|
# released image), because those claims have no in-repo anchor at all and had
|
|
# rotted for exactly that reason. They need the network and therefore SKIP,
|
|
# loudly and counted, when it is absent -- a skip is neither OK nor a failure,
|
|
# because printing an unverified claim as OK is the habit this file exists to
|
|
# break, while failing on a third party's uptime would make every release
|
|
# hostage to it. Claims that need a RUNNING CONTAINER (the "N mempalace_* tools"
|
|
# count, uncompressed on-disk sizes) are still not gated here; 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
|
|
SKIPS=0
|
|
|
|
# Tolerance for the published size claims (check 8), as a percentage OF THE
|
|
# MEASURED SIZE. The denominator matters: against the claim instead, the same
|
|
# drift reads as a different number, and an early draft of this gate took 20%
|
|
# from the claim-relative figure and would therefore have MISSED its own
|
|
# motivating case. Both bounds are measured, not guessed:
|
|
# - the rot that motivated this check: claimed 1.1 GB vs measured 1.37 GB
|
|
# = 19.7% off, so the threshold must sit BELOW that or the gate is theatre.
|
|
# - the largest legitimate skew, i.e. a claim describing the currently-published
|
|
# release while the next tag changes the size: v1.9.1's 1.37 GB against
|
|
# v1.9.2's measured 1.23 GB = 11.4% off, so the threshold must sit ABOVE that
|
|
# or every size-changing release trips it.
|
|
# 15% sits in that 11.4%-19.7% window. Widen it only with a measured reason, and
|
|
# re-derive both bounds if you do.
|
|
SIZE_TOLERANCE_PCT="${SIZE_TOLERANCE_PCT:-15}"
|
|
|
|
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).
|
|
|
|
Environment:
|
|
SKIP_SIZE_CHECK=1 skip check 8 (published size claims vs Docker Hub)
|
|
SKIP_REF_CHECK=1 skip check 9 (refs moved since the last release are named)
|
|
SIZE_TOLERANCE_PCT check 8 tolerance, default 15 (see comment for its bounds)
|
|
|
|
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"; }
|
|
|
|
# A check that could not be EVALUATED, as distinct from one that passed.
|
|
# Deliberately neither ok() nor fail(): printing it as OK would launder an
|
|
# unmeasured claim into a passing one (the exact habit this file exists to
|
|
# break), while failing on a third party's uptime would make every release
|
|
# hostage to Docker Hub's API. Loud, counted, and surfaced in the summary.
|
|
skip() { SKIPS=$((SKIPS + 1)); printf ' SKIP %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
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 8. Published size claims vs Docker Hub's MEASURED full_size.
|
|
#
|
|
# Why this exists: every other claim in these docs is checked against a file
|
|
# in this repo, so it cannot rot without someone editing the thing it
|
|
# describes. The size claims had no such anchor -- nothing in the repo states
|
|
# the image size -- so they quietly went 24% wrong across eight releases
|
|
# (DOCKER_HUB.md said ~1.1 GB; :latest measured 1.37 GB on 2026-09-14).
|
|
# DOCKER_HUB.md is POSTed to Docker Hub by update-description, so that number
|
|
# is the first thing a stranger reads about this image.
|
|
#
|
|
# Hub's `full_size` tracks the FIRST manifest entry (amd64 here), NOT the sum
|
|
# across architectures -- measured: v1.9.2 full_size=1.228 GB, amd64=1.228,
|
|
# arm64=1.211, sum=2.439. That matches the table's per-arch "Size
|
|
# (compressed)" column, which is why full_size is the right field.
|
|
#
|
|
# NOT COVERED, deliberately: README.md's ~3.2 GB figures are UNCOMPRESSED
|
|
# on-disk sizes, and the registry API exposes compressed sizes only (layer
|
|
# sizes in a manifest are compressed; the config blob carries no uncompressed
|
|
# totals). Measuring them needs a real pull, so they are out of scope here --
|
|
# do not read a green check 8 as covering them.
|
|
# ---------------------------------------------------------------------------
|
|
# Shared by checks 8 and 9: which Hub repo, and its tag list (one request).
|
|
# Derive the repo from the doc's own rows rather than hardcoding it, so a
|
|
# rename cannot leave these checks silently probing a repo nobody publishes to.
|
|
# shellcheck disable=SC2016 # single quotes are deliberate: this is a sed
|
|
# script, and its \( \) groups and \1 backreference must reach sed unexpanded.
|
|
HUB_REPO_PATH="$(sed -n 's/^| `\([^:`]*\):[^`]*`.*/\1/p' "$HUB" | head -1)"
|
|
HUB_TAGS_JSON=""
|
|
HAVE_NET_TOOLS=0
|
|
if command -v curl >/dev/null 2>&1 && command -v python3 >/dev/null 2>&1; then
|
|
HAVE_NET_TOOLS=1
|
|
if [ -n "$HUB_REPO_PATH" ] && \
|
|
{ [ "${SKIP_SIZE_CHECK:-0}" != "1" ] || [ "${SKIP_REF_CHECK:-0}" != "1" ]; }; then
|
|
HUB_TAGS_JSON="$(curl -sS -m 20 \
|
|
"https://hub.docker.com/v2/repositories/${HUB_REPO_PATH}/tags/?page_size=100" \
|
|
2>/dev/null || true)"
|
|
fi
|
|
fi
|
|
|
|
if [ "${SKIP_SIZE_CHECK:-0}" = "1" ]; then
|
|
skip "size claims -- SKIP_SIZE_CHECK=1 was set"
|
|
elif [ "$HAVE_NET_TOOLS" = 0 ]; then
|
|
skip "size claims -- need both curl and python3 to measure them"
|
|
else
|
|
if [ -z "$HUB_REPO_PATH" ]; then
|
|
skip "size claims -- found no \`repo:tag\` image rows in $HUB to check"
|
|
else
|
|
if [ -z "$HUB_TAGS_JSON" ]; then
|
|
skip "size claims -- Docker Hub API unreachable (offline?); NOT verified"
|
|
else
|
|
SIZE_RC=0
|
|
# NO `|| true` on the python invocation: an early draft had one, and it
|
|
# swallowed the exit code so a printed DRIFT line still exited 0 -- a gate
|
|
# that reports the defect and passes anyway. The outer `|| SIZE_RC=$?` is
|
|
# what keeps `set -e` happy while preserving the code.
|
|
SIZE_OUT="$(HUB_MD="$HUB" HUB_JSON="$HUB_TAGS_JSON" TOL="$SIZE_TOLERANCE_PCT" \
|
|
python3 <<'PYEOF'
|
|
import json, os, re, sys
|
|
|
|
try:
|
|
data = json.loads(os.environ["HUB_JSON"])
|
|
except (ValueError, KeyError) as exc:
|
|
print(" SKIP size claims -- Hub API returned unparseable JSON (%s)" % exc)
|
|
sys.exit(3)
|
|
|
|
# full_size == first manifest entry (amd64), which is the per-arch number the
|
|
# table's "Size (compressed)" column claims. Verified against .images[] sizes.
|
|
sizes = {
|
|
r["name"]: r["full_size"] / 1e9
|
|
for r in data.get("results", [])
|
|
if isinstance(r.get("full_size"), int) and r.get("name")
|
|
}
|
|
if not sizes:
|
|
print(" SKIP size claims -- Hub API returned no usable tags")
|
|
sys.exit(3)
|
|
|
|
tol = float(os.environ["TOL"])
|
|
row = re.compile(r"^\|\s*`([^`:]+):([^`]+)`\s*\|[^|]*\|\s*~?([0-9]+(?:\.[0-9]+)?)\s*GB\s*\|")
|
|
checked = drift = 0
|
|
|
|
with open(os.environ["HUB_MD"], encoding="utf-8") as fh:
|
|
for line in fh:
|
|
m = row.match(line)
|
|
if not m:
|
|
continue # rows saying "same", and every non-image row
|
|
_repo, tag, claimed = m.group(1), m.group(2), float(m.group(3))
|
|
if "X.Y.Z" in tag:
|
|
continue # placeholder row; the concrete tag is checked instead
|
|
# base-<hash> is content-addressed and immutable, so its size is
|
|
# base-latest's by construction -- probe the alias that always exists.
|
|
probe = "base-latest" if tag.startswith("base-") else tag
|
|
actual = sizes.get(probe)
|
|
if actual is None:
|
|
print(" SKIP size %s -- tag '%s' not present on Hub" % (tag, probe))
|
|
continue
|
|
checked += 1
|
|
off = abs(claimed - actual) / actual * 100
|
|
if off <= tol:
|
|
print(" OK size %s claims ~%.2f GB, Hub measures %.2f GB (%.0f%% off)"
|
|
% (tag, claimed, actual, off))
|
|
else:
|
|
drift += 1
|
|
print(" DRIFT size %s claims ~%.2f GB but Hub measures %.2f GB"
|
|
" (%.0f%% off, tolerance %.0f%%)" % (tag, claimed, actual, off, tol))
|
|
|
|
if checked == 0:
|
|
print(" SKIP size claims -- no checkable rows resolved to a published tag")
|
|
sys.exit(3)
|
|
sys.exit(1 if drift else 0)
|
|
PYEOF
|
|
)" || SIZE_RC=$?
|
|
printf '%s\n' "$SIZE_OUT"
|
|
case "$SIZE_RC" in
|
|
0) : ;;
|
|
3) SKIPS=$((SKIPS + 1)) ;;
|
|
*)
|
|
fail "a published size claim in $HUB has drifted from what Docker Hub
|
|
actually serves (see DRIFT above). This page is POSTed to Docker Hub by
|
|
update-description, so it is the first size a stranger sees. Re-measure and
|
|
update the table:
|
|
curl -sS 'https://hub.docker.com/v2/repositories/${HUB_REPO_PATH}/tags/?page_size=100' |
|
|
jq -r '.results[] | \"\\(.name) \\(.full_size/1e9)\"'"
|
|
;;
|
|
esac
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 9. Everything the NEXT build would bake differently from the LAST PUBLISHED
|
|
# release must be named in the CHANGELOG text above that release's heading.
|
|
#
|
|
# Why this exists, measured 2026-09-19: pi-extensions 25c1265 (a new `task`
|
|
# tool and a hook that blocks certain `fork` calls -- a change to how every
|
|
# agent in the container delegates work) and mempalace-toolkit 817b3a8 (the
|
|
# feed's mine deadline had never reached the transport) both reached this
|
|
# image through floating `*_REF=main` ARGs. Neither produced a diff in this
|
|
# repo, so nothing here asked for a CHANGELOG entry, and neither had one
|
|
# until a reader asked. This is the same shape as check 8: a fact with no
|
|
# in-repo anchor rots. The hand practice that existed for it -- the
|
|
# "Dependency audit" table in each release's notes ("Baked in vN | Upstream
|
|
# now") -- is precisely a "someone remembers" mechanism, and it had lapsed.
|
|
#
|
|
# How it measures, with no docker/crane/token: the last published `vX.Y.Z`
|
|
# is the highest such tag in Hub's tag list (shared with check 8); its
|
|
# amd64 config blob is read through the anonymous registry API (token ->
|
|
# manifest index -> per-arch manifest -> config) and carries one
|
|
# `se.jordbo.pi-devbox.<name>-ref` label per component, each holding the
|
|
# SHA that build-args actually baked (resolve-versions in docker-publish.yml
|
|
# turns every ref into a SHA before `docker build`). "What the next build
|
|
# would bake" is resolved the way that job does it: a 40-hex ARG is itself,
|
|
# a tag or branch is `git ls-remote`d (peeled `^{}` first -- an annotated
|
|
# tag's un-dereferenced SHA is the tag object, a false alarm this repo has
|
|
# already fallen for once), pi-studio is the highest semver tag, and
|
|
# `PI_VERSION` / `MEMPALACE_VERSION` are compared as literals against the
|
|
# `pi-version` / `mempalace-version` labels (the latter set in Dockerfile.base
|
|
# and inherited; absent on releases before it shipped, which reports SKIP).
|
|
#
|
|
# The rule: baked == would-bake is OK with no mention required. If they
|
|
# differ, the text ABOVE the last published version's `## ` heading -- i.e.
|
|
# `## Unreleased` plus any not-yet-published `## vX.Y.Z` section, which is
|
|
# what the release commit turns Unreleased into -- must contain the
|
|
# would-bake value's 7-char SHA prefix (or, for pi-studio, the tag name; for
|
|
# pi, the version string). Naming the SHA, not just the repo, is the point:
|
|
# it is what the audit table always recorded, and it makes the failure
|
|
# message's compare URL a copy-paste away from knowing what moved.
|
|
#
|
|
# Every upstream commit therefore re-reds this gate until the CHANGELOG
|
|
# names the new head. That is the intended cost: the thing that gets baked
|
|
# is the thing that gets named, and a typo-fix upstream costs one edited
|
|
# SHA here. Read from the TAG like everything else in these docs -- the
|
|
# release commit renames Unreleased, so the pending text still covers it.
|
|
#
|
|
# SKIPs, each counted: SKIP_REF_CHECK=1; no curl/python3; Hub unreachable;
|
|
# the release's labels unreadable; one component's upstream unreachable
|
|
# (that component only). A published tag whose heading is MISSING from the
|
|
# CHANGELOG is a failure, not a skip: that is drift in its own right.
|
|
# ---------------------------------------------------------------------------
|
|
if [ "${SKIP_REF_CHECK:-0}" = "1" ]; then
|
|
skip "ref moves -- SKIP_REF_CHECK=1 was set"
|
|
elif [ "$HAVE_NET_TOOLS" = 0 ]; then
|
|
skip "ref moves -- need both curl and python3 to read the published labels"
|
|
elif ! command -v git >/dev/null 2>&1; then
|
|
skip "ref moves -- need git (ls-remote) to resolve what the next build would bake"
|
|
elif [ -z "$HUB_REPO_PATH" ]; then
|
|
skip "ref moves -- found no \`repo:tag\` image rows in $HUB to locate the published image"
|
|
elif [ -z "$HUB_TAGS_JSON" ]; then
|
|
skip "ref moves -- Docker Hub API unreachable (offline?); NOT verified"
|
|
else
|
|
# One plain top-level assignment per ARG, on purpose: read_arg exits 2 on a
|
|
# missing ARG, and under `set -e` that only propagates from a bare
|
|
# `VAR="$(...)"`. Nested inside a heredoc's $(...) the exit would be swallowed
|
|
# by `cat`, and a renamed ARG would leave this check comparing a label against
|
|
# an empty string and reporting the component "unchanged".
|
|
TOOLKIT_REPO="$(read_arg "$DF_VARIANT" PI_TOOLKIT_REPO)"; TOOLKIT_REF="$(read_arg "$DF_VARIANT" PI_TOOLKIT_REF)"
|
|
EXTENSIONS_REPO="$(read_arg "$DF_VARIANT" PI_EXTENSIONS_REPO)"; EXTENSIONS_REF="$(read_arg "$DF_VARIANT" PI_EXTENSIONS_REF)"
|
|
FORK_REPO="$(read_arg "$DF_VARIANT" PI_FORK_REPO)"; FORK_REF="$(read_arg "$DF_VARIANT" PI_FORK_REF)"
|
|
OBSMEM_REPO="$(read_arg "$DF_VARIANT" PI_OBSMEM_REPO)"; OBSMEM_REF="$(read_arg "$DF_VARIANT" PI_OBSMEM_REF)"
|
|
ATELIER_REPO="$(read_arg "$DF_VARIANT" PI_ATELIER_REPO)"
|
|
MPTK_REPO="$(read_arg "$DF_BASE" MEMPALACE_TOOLKIT_REPO)"; MPTK_REF="$(read_arg "$DF_BASE" MEMPALACE_TOOLKIT_REF)"
|
|
STUDIO_REPO="$(read_arg "$DF_VARIANT" PI_STUDIO_REPO)"
|
|
SKILLSET_SNAPSHOT="$(read_arg "$DF_VARIANT" SKILLSET_SNAPSHOT_REF)"
|
|
# name|kind|repo|ref -- one line per label the variant image carries.
|
|
# kinds: ref = branch/tag/SHA resolved like resolve-versions does;
|
|
# studio = highest semver tag of the repo (label lives on <tag>-studio);
|
|
# literal = the ARG value IS the baked value (a SHA pin, a version).
|
|
REF_COMPONENTS="pi-toolkit|ref|$TOOLKIT_REPO|$TOOLKIT_REF
|
|
pi-extensions|ref|$EXTENSIONS_REPO|$EXTENSIONS_REF
|
|
pi-fork|ref|$FORK_REPO|$FORK_REF
|
|
pi-obsmem|ref|$OBSMEM_REPO|$OBSMEM_REF
|
|
pi-atelier|ref|$ATELIER_REPO|$ATELIER_ACTUAL
|
|
mempalace-toolkit|ref|$MPTK_REPO|$MPTK_REF
|
|
pi-studio|studio|$STUDIO_REPO|
|
|
skillset-snapshot|literal||$SKILLSET_SNAPSHOT
|
|
pi-version|literal||$PI_ACTUAL
|
|
mempalace-version|literal||$MEMPALACE_ACTUAL"
|
|
REF_RC=0
|
|
# Same discipline as check 8: no `|| true` on the python, or a printed DRIFT
|
|
# exits 0. Per-component SKIP lines are counted afterwards by grep, so a run
|
|
# that evaluated eight components and could not reach the ninth reports one
|
|
# skip, not a green tick over the ninth.
|
|
REF_OUT="$(HUB_REPO="$HUB_REPO_PATH" HUB_JSON="$HUB_TAGS_JSON" CHANGELOG="CHANGELOG.md" \
|
|
COMPONENTS="$REF_COMPONENTS" python3 <<'PYEOF'
|
|
import json, os, re, subprocess, sys, urllib.request, urllib.parse
|
|
|
|
SHA40 = re.compile(r"^[0-9a-f]{40}$")
|
|
SEMVER = re.compile(r"^v?[0-9]+\.[0-9]+\.[0-9]+$")
|
|
LABEL = "se.jordbo.pi-devbox."
|
|
|
|
|
|
def ver_key(tag):
|
|
return tuple(int(x) for x in tag.lstrip("v").split("."))
|
|
|
|
|
|
def http_json(url, headers=None, timeout=30):
|
|
req = urllib.request.Request(url, headers=headers or {})
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
return json.loads(resp.read().decode("utf-8"))
|
|
|
|
|
|
def labels_of(repo, tag):
|
|
"""Config labels of <repo>:<tag>'s amd64 image via the anonymous registry API."""
|
|
tok = http_json(
|
|
"https://auth.docker.io/token?service=registry.docker.io&scope="
|
|
+ urllib.parse.quote(f"repository:{repo}:pull", safe=":")
|
|
)["token"]
|
|
hdr = {
|
|
"Authorization": f"Bearer {tok}",
|
|
"Accept": ", ".join([
|
|
"application/vnd.oci.image.index.v1+json",
|
|
"application/vnd.docker.distribution.manifest.list.v2+json",
|
|
"application/vnd.oci.image.manifest.v1+json",
|
|
"application/vnd.docker.distribution.manifest.v2+json",
|
|
]),
|
|
}
|
|
base = f"https://registry-1.docker.io/v2/{repo}"
|
|
man = http_json(f"{base}/manifests/{tag}", hdr)
|
|
if "manifests" in man: # multi-arch index: pick linux/amd64, as check 8 does
|
|
cands = [m for m in man["manifests"]
|
|
if m.get("platform", {}).get("architecture") == "amd64"
|
|
and m.get("platform", {}).get("os") == "linux"]
|
|
if not cands:
|
|
raise RuntimeError("no linux/amd64 entry in the manifest index")
|
|
man = http_json(f"{base}/manifests/{cands[0]['digest']}", hdr)
|
|
cfg = http_json(f"{base}/blobs/{man['config']['digest']}", hdr)
|
|
return cfg.get("config", {}).get("Labels") or {}
|
|
|
|
|
|
def ls_remote(repo, *patterns):
|
|
# GIT_TERMINAL_PROMPT=0: a repo flipped private must fail fast as a SKIP,
|
|
# not sit waiting for a username on a CI runner until the job times out.
|
|
env = dict(os.environ, GIT_TERMINAL_PROMPT="0")
|
|
out = subprocess.run(["git", "ls-remote", repo, *patterns], env=env,
|
|
capture_output=True, text=True, timeout=60, check=True).stdout
|
|
return {line.split("\t")[1]: line.split("\t")[0] for line in out.splitlines() if "\t" in line}
|
|
|
|
|
|
def resolve_ref(repo, ref):
|
|
"""What docker-publish.yml's resolve-versions would pass as the build-arg."""
|
|
if SHA40.match(ref):
|
|
return ref, ref
|
|
refs = ls_remote(repo, f"refs/heads/{ref}", f"refs/tags/{ref}", f"refs/tags/{ref}^{{}}")
|
|
for key in (f"refs/tags/{ref}^{{}}", f"refs/heads/{ref}", f"refs/tags/{ref}"):
|
|
if key in refs:
|
|
return refs[key], ref
|
|
raise RuntimeError(f"'{ref}' is neither a branch nor a tag of {repo}")
|
|
|
|
|
|
def resolve_studio(repo):
|
|
refs = ls_remote(repo, "refs/tags/*")
|
|
tags = {k[len("refs/tags/"):]: v for k, v in refs.items()}
|
|
names = sorted((t for t in tags if SEMVER.match(t)), key=ver_key)
|
|
if not names:
|
|
raise RuntimeError(f"no semver tag at {repo}")
|
|
tag = names[-1]
|
|
return tags.get(tag + "^{}", tags[tag]), tag
|
|
|
|
|
|
def compare_url(repo, a, b):
|
|
root = repo[:-4] if repo.endswith(".git") else repo
|
|
return f"{root}/compare/{a}...{b}"
|
|
|
|
|
|
try:
|
|
hub = json.loads(os.environ["HUB_JSON"])
|
|
except (ValueError, KeyError) as exc:
|
|
print(" SKIP ref moves -- Hub API returned unparseable JSON (%s)" % exc)
|
|
sys.exit(3)
|
|
released = sorted((r["name"] for r in hub.get("results", [])
|
|
if isinstance(r.get("name"), str) and re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", r["name"])),
|
|
key=ver_key)
|
|
if not released:
|
|
print(" SKIP ref moves -- Hub lists no published vX.Y.Z tag to compare against")
|
|
sys.exit(3)
|
|
last = released[-1]
|
|
repo = os.environ["HUB_REPO"]
|
|
|
|
# The text every not-yet-published change lives in: everything above the last
|
|
# published version's heading. Its absence is drift, not a skip.
|
|
text = open(os.environ["CHANGELOG"], encoding="utf-8").read()
|
|
# (\s|$) rather than \b: a word boundary would accept "## v1.9.2-rc1" or
|
|
# "## v1.9.2-typo" as v1.9.2's heading. Caught by the sabotage test, not review.
|
|
m = re.search(r"^## v?%s(\s|$)" % re.escape(last.lstrip("v")), text, re.M)
|
|
if not m:
|
|
print(" DRIFT ref moves -- %s is the last PUBLISHED tag on Hub but %s has no '## %s' heading"
|
|
% (last, os.environ["CHANGELOG"], last))
|
|
sys.exit(1)
|
|
pending = text[:m.start()].lower()
|
|
|
|
try:
|
|
labels = labels_of(repo, last)
|
|
except Exception as exc: # network, auth, shape -- all "could not measure"
|
|
print(" SKIP ref moves -- could not read %s:%s's labels from the registry (%s); NOT verified"
|
|
% (repo, last, exc))
|
|
sys.exit(3)
|
|
studio_labels = None
|
|
|
|
checked = drift = 0
|
|
problems = []
|
|
for line in os.environ["COMPONENTS"].splitlines():
|
|
if not line.strip():
|
|
continue
|
|
name, kind, url, ref = line.split("|", 3)
|
|
# <name>-ref labels hold SHAs; names that already end in -version are the
|
|
# label (pi-version, mempalace-version) -- a version string, compared literally.
|
|
key = LABEL + name if name.endswith("-version") else LABEL + name + "-ref"
|
|
try:
|
|
if kind == "studio":
|
|
if studio_labels is None:
|
|
studio_labels = labels_of(repo, last + "-studio")
|
|
baked = studio_labels.get(key)
|
|
else:
|
|
baked = labels.get(key)
|
|
except Exception as exc:
|
|
print(" SKIP %-18s -- could not read %s:%s-studio's labels (%s)" % (name, repo, last, exc))
|
|
continue
|
|
if not baked:
|
|
print(" SKIP %-18s -- %s carries no %s label" % (name, last, key))
|
|
continue
|
|
try:
|
|
if kind == "ref":
|
|
now, shown = resolve_ref(url, ref)
|
|
elif kind == "studio":
|
|
now, shown = resolve_studio(url)
|
|
else:
|
|
now, shown = ref, ref
|
|
except Exception as exc:
|
|
print(" SKIP %-18s -- could not resolve what the next build would bake (%s)" % (name, exc))
|
|
continue
|
|
checked += 1
|
|
is_sha = bool(SHA40.match(now))
|
|
short = (lambda s: s[:7] if SHA40.match(s) else s)
|
|
if baked == now:
|
|
print(" OK %-18s unchanged since %s (%s)" % (name, last, short(now)))
|
|
continue
|
|
names = [now[:7].lower()] if is_sha else [now.lower()]
|
|
if kind == "studio":
|
|
names.append(shown.lower())
|
|
if any(n in pending for n in names):
|
|
print(" OK %-18s %s -> %s since %s, named above the %s heading"
|
|
% (name, short(baked), short(now), last, last))
|
|
continue
|
|
drift += 1
|
|
hint = compare_url(url, baked, now) if (url and is_sha and SHA40.match(baked)) else ""
|
|
problems.append(" %-18s %s -> %s%s" % (name, short(baked), short(now), (" " + hint) if hint else ""))
|
|
print(" DRIFT %-18s %s -> %s since %s, NOT named above the %s heading"
|
|
% (name, short(baked), short(now), last, last))
|
|
|
|
if problems:
|
|
print(" Name each new value (7-char SHA prefix, or the tag/version) in CHANGELOG.md above '## %s':" % last)
|
|
print("\n".join(problems))
|
|
if checked == 0 and drift == 0:
|
|
print(" SKIP ref moves -- no component could be evaluated")
|
|
sys.exit(3)
|
|
sys.exit(1 if drift else 0)
|
|
PYEOF
|
|
)" || REF_RC=$?
|
|
printf '%s\n' "$REF_OUT"
|
|
REF_SKIPS="$(printf '%s\n' "$REF_OUT" | grep -c '^ SKIP ' || true)"
|
|
case "$REF_RC" in
|
|
0) SKIPS=$((SKIPS + REF_SKIPS)) ;;
|
|
3) SKIPS=$((SKIPS + 1)) ;;
|
|
*)
|
|
SKIPS=$((SKIPS + REF_SKIPS))
|
|
fail "a component the next build would bake differently from the last published
|
|
release is not named in CHANGELOG.md (see DRIFT above). These reach the image
|
|
through floating refs, so nothing else in this repo records that they moved;
|
|
the CHANGELOG entry is the only place a reader of the next tag can learn it.
|
|
Name the new SHA (7 chars is enough) where you describe the change -- the
|
|
compare URL above shows what moved."
|
|
;;
|
|
esac
|
|
fi
|
|
|
|
echo
|
|
if [ "$FAILURES" -eq 0 ]; then
|
|
if [ "$SKIPS" -gt 0 ]; then
|
|
echo "OK: every checked doc claim matches the build files" \
|
|
"($SKIPS check(s) SKIPPED and therefore NOT verified -- see SKIP above)."
|
|
else
|
|
echo "OK: every checked doc claim matches the build files."
|
|
fi
|
|
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
|