#!/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)" # pi-obsmem became a PIN in v1.9.5 (was the floating `master`), so it joins the # reviewable table. It is also covered by the ref-move check below, but that one # can only ever report "unchanged" for a pinned SHA -- it answers "did upstream # move?", never "does the table still say what we bake?", which is this check. OBSMEM_PIN_ACTUAL="$(read_arg "$DF_VARIANT" PI_OBSMEM_REF)" check_pin pi "$(read_pin_row pi)" "$PI_ACTUAL" "ARG PI_VERSION in $DF_VARIANT" check_pin pi-obsmem "$(read_pin_row pi-obsmem)" "$OBSMEM_PIN_ACTUAL" "ARG PI_OBSMEM_REF 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- 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.-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 -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 :'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) # -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