check-doc-drift: check 9 — what the next build bakes differently must be named in the CHANGELOG
The two most recent Unreleased entries (pi-extensions 25c1265: task tool +
fork-gate; mempalace-toolkit 817b3a8: the mine deadline that never reached
the transport) reached this image through floating *_REF=main ARGs. Neither
produced a diff here, so nothing asked for a CHANGELOG line, and neither had
one until a reader asked. Same class as check 8 — a claim with no in-repo
anchor rots — and the hand practice that covered it (the per-release
"Dependency audit" table, Baked-in vs Upstream-now) is a someone-remembers
mechanism that had lapsed.
Method, no docker/crane/token: last published vX.Y.Z from Hub's tag list
(one request, now shared with check 8); its amd64 config blob via the
anonymous registry API carries one se.jordbo.pi-devbox.<name>-ref label per
component with the SHA the build-args baked. "Would bake" is resolved the way
resolve-versions does it: 40-hex ARG is itself, branch/tag via git ls-remote
with the peeled ^{} form preferred, pi-studio = highest semver tag (label on
<tag>-studio), PI_VERSION as a literal against the pi-version label. Nine
components, ~7.5 s. Unchanged: OK. Moved: the new value's 7-char prefix (tag
for studio, version for pi) must appear above the last published version's
"## " heading — Unreleased plus any not-yet-published section, which is what
the release commit turns Unreleased into, so the tag build passes on the same
text. A published tag with no heading is a failure, not a skip.
First run found a move nobody had recorded: pi-observational-memory 7b397f4
-> cba0334 (6 commits, 3.1.1 -> 3.1.3; memory workers now routed by the
model's exact provider instead of the first registered provider with a
matching api — upstream #70). Named in the CHANGELOG with the honest scope:
no expected effect on the shipped bedrock-only configuration, unmeasured.
Sabotage-tested, expectation written before each run:
obsmem SHA removed from CHANGELOG ............ rc=1 DRIFT
Unreleased renamed to "## v1.9.3 — …" ........ rc=0 (release-commit shape)
"## v1.9.2" heading mangled to v1.9.2-typo .... rc=1 — this one FAILED first:
\b accepted "v1.9.2-typo" as v1.9.2's heading; now (\s|$)
bare "## v1.9.2" (no date) ................... rc=0
ARG PI_OBSMEM_REPO renamed ................... rc=2 (blind gate must not pass;
read_arg calls are bare top-level assignments on purpose — nested in a
heredoc's $(...) the exit 2 is swallowed by cat)
offline (proxy to a closed port) ............. rc=0, 2 SKIPs counted
SKIP_REF_CHECK=1 ............................. rc=0, 1 SKIP counted
restored ..................................... rc=0, files byte-identical
GIT_TERMINAL_PROMPT=0 on ls-remote: a repo flipped private fails in 0.3 s as
a SKIP instead of waiting for a username until the job times out.
Header, lint.yml job comment and AGENTS.md all still said this gate "needs
no network" — false since check 8 (2026-09-14). Now: two classes, hermetic
1-7 and published-state 8-9 that SKIP loudly and counted when offline.
Considered and not added: compose <-> .env.example variable cross-check (the
four mismatches are commented-out lines, the mempalace-server compose file's
own variables, and entrypoint-consumed ones — it would fire on nothing
wrong); "documented tag exists on Hub" (check 8 already SKIPs by name; a
hard fail would misreport the tag-to-publish window).
This commit is contained in:
@@ -197,10 +197,17 @@ jobs:
|
||||
# someone remembering. It is also read from the TAG, so a fix pushed to main
|
||||
# after tagging never reaches the published page.
|
||||
#
|
||||
# Cheap and hermetic on purpose: every check compares a doc string against a
|
||||
# value that exists in this repo, so no network, no token, no built image,
|
||||
# and no sibling clone. Claims that genuinely need a running container (image
|
||||
# sizes, the "N mempalace_* tools" count) are deliberately left out — a gate
|
||||
# Two classes of check. 1-7 are hermetic: each compares a doc string against
|
||||
# a value that exists in this repo — no network, no token, no built image.
|
||||
# 8-9 compare against what is PUBLISHED, because those claims have no
|
||||
# in-repo anchor and rotted for exactly that reason: 8 reads Docker Hub's
|
||||
# measured sizes; 9 reads the ref labels baked into the last released image
|
||||
# (anonymous registry API, no docker/crane) and `git ls-remote`s each
|
||||
# floating upstream, then requires every component the next build would
|
||||
# bake differently to be NAMED in the CHANGELOG above that release's
|
||||
# heading. Both SKIP loudly and counted when offline — a skip is neither OK
|
||||
# nor a failure. Claims that genuinely need a running container (the "N
|
||||
# mempalace_* tools" count, uncompressed sizes) are still left out — a gate
|
||||
# that cannot evaluate a claim honestly would have to guess, and a guessing
|
||||
# gate is worse than none. Assert those in scripts/smoke-test.sh instead.
|
||||
#
|
||||
|
||||
@@ -103,7 +103,17 @@ re-brand of opencode-devbox's `pi-only` variant.
|
||||
It compares README.md's version-pin table against the ARGs it names, and
|
||||
DOCKER_HUB.md's Node claim against `ARG NODE_VERSION`, plus Hub's
|
||||
25 000-char limit, unsubstituted `{{PLACEHOLDERS}}`, and stale `Unreleased`
|
||||
pointers in user-facing docs.
|
||||
pointers in user-facing docs. With network it also checks DOCKER_HUB.md's
|
||||
size claims against Hub's measured sizes (check 8) and — check 9 — that
|
||||
**every component the next build would bake differently from the last
|
||||
published release is named in the CHANGELOG** above that release's heading:
|
||||
it reads the `se.jordbo.pi-devbox.*-ref` labels off the published image and
|
||||
`git ls-remote`s each floating `*_REF`. A red check 9 means an upstream
|
||||
(pi-toolkit, pi-extensions, mempalace-toolkit, pi-fork,
|
||||
pi-observational-memory, pi-studio) moved and no entry names the new SHA;
|
||||
the failure prints the compare URL. Name the 7-char SHA where you describe
|
||||
the change — that is what the old "Dependency audit" tables recorded by
|
||||
hand, now required.
|
||||
|
||||
**Why before and not after:** `docker-publish.yml` runs `actions/checkout@v4`
|
||||
with no `ref:`, so every job reads `github.ref` — the **tag**. A doc fix
|
||||
|
||||
@@ -13,6 +13,70 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
|
||||
|
||||
## Unreleased
|
||||
|
||||
**New check 9 in `scripts/check-doc-drift.sh`: anything the next build would bake
|
||||
differently from the last *published* release must be named in the CHANGELOG text
|
||||
above that release's heading.** The two entries below this one are why. The
|
||||
`task` tool and `fork-gate` (pi-extensions `25c1265`) and the mine-deadline fix
|
||||
(mempalace-toolkit `817b3a8`) both reach this image through floating
|
||||
`*_REF=main` ARGs, so neither produced a diff in this repo, nothing here asked
|
||||
for a CHANGELOG line, and neither had one until a reader asked. Same shape as
|
||||
check 8: a claim 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* against
|
||||
*Upstream now* — is a "someone remembers" mechanism, and it had lapsed.
|
||||
|
||||
How it measures, with no `docker`, `crane` or token: the last published
|
||||
`vX.Y.Z` is the highest such tag in Hub's list (one request, shared with
|
||||
check 8); that tag's amd64 config blob is read through the anonymous registry
|
||||
API (token → index → per-arch manifest → config) and carries one
|
||||
`se.jordbo.pi-devbox.<name>-ref` label per component holding the SHA the
|
||||
build-args actually baked. "What the next build would bake" is resolved the way
|
||||
`resolve-versions` does it — a 40-hex ARG is itself, a branch or tag is
|
||||
`git ls-remote`d with the peeled `^{}` form preferred (the un-dereferenced SHA of
|
||||
an annotated tag is the tag object; this repo has raised that false alarm once
|
||||
already), pi-studio is the highest semver tag read from `<tag>-studio`'s labels,
|
||||
and `PI_VERSION` is compared as a literal against the `pi-version` label. Nine
|
||||
components, 7.5 s.
|
||||
|
||||
The rule: unchanged needs no mention. Moved requires the new value's 7-char SHA
|
||||
prefix (tag name for pi-studio, version string for pi) somewhere above the last
|
||||
published version's `## ` heading — `## Unreleased` plus any not-yet-published
|
||||
`## vX.Y.Z`, which is what the release commit turns Unreleased into, so the tag
|
||||
build passes on the same text — sabotage-tested: renaming `## Unreleased` to
|
||||
`## v1.9.3 — …` stays green; mangling the published `## v1.9.2` heading goes
|
||||
red, and that test caught a `\b` that would have accepted `v1.9.2-typo` as the
|
||||
heading (now `(\s|$)`). Naming the SHA rather than the repo is
|
||||
deliberate: it is what the audit table always recorded, and it makes the failure
|
||||
message's compare URL one click from knowing what moved. Every upstream commit
|
||||
re-reds the gate until the CHANGELOG names the new head; that is the intended
|
||||
cost — **the thing that gets baked is the thing that gets named.** A published
|
||||
tag with no CHANGELOG heading is a failure, not a skip.
|
||||
|
||||
**First run found a move nobody had recorded.** `pi-observational-memory`
|
||||
`7b397f4 → cba0334` (6 upstream commits, 2026-09-14..16, 3.1.1 → 3.1.3): the
|
||||
memory workers' `streamSimple` lookup used to iterate every extension-registered
|
||||
provider and take the first whose `api` matched the model's, so two providers
|
||||
sharing an API type could route the observer to the wrong one (upstream #70);
|
||||
it now asks for the model's exact provider and keeps the `api` match as a
|
||||
consistency check. Reaches this image on the next variant build via
|
||||
`PI_OBSMEM_REF=master`. No behaviour change expected on the shipped
|
||||
configuration — every profile here uses the built-in `amazon-bedrock` provider
|
||||
and no extension registers one — but that is an expectation, not a
|
||||
measurement; the code path only differs when an extension has called
|
||||
`registerProvider`.
|
||||
|
||||
Header and `lint.yml` comment corrected alongside: both still said this gate
|
||||
"needs no network", which check 8 made false on 2026-09-14. There are now two
|
||||
classes — hermetic checks 1–7, and published-state checks 8–9 that SKIP loudly
|
||||
and counted when offline. Considered and not added, with reasons in the script:
|
||||
a `docker-compose.yml` ↔ `.env.example` variable cross-check (the four
|
||||
mismatches are commented-out lines, the mempalace-server compose file's own
|
||||
documented variables, and two entrypoint-consumed variables — a gate there
|
||||
would fire on nothing wrong), and a "documented tag exists on Hub" check
|
||||
(check 8 already SKIPs a missing tag by name, and a hard fail would
|
||||
misreport the window between tagging and publish).
|
||||
|
||||
---
|
||||
|
||||
**The rule "use `pi-task`, not `fork`, for a brief that carries a prohibition" was
|
||||
written in the global `AGENTS.md` and in the pi-extensions skill, and it lost to
|
||||
the `fork` tool's own description anyway.** Measured on tor-ms22, 2026-09-17: five
|
||||
|
||||
+317
-17
@@ -29,14 +29,18 @@
|
||||
# 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.
|
||||
# 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
|
||||
@@ -95,6 +99,11 @@ 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
|
||||
}
|
||||
@@ -272,22 +281,32 @@ fi
|
||||
# totals). Measuring them needs a real pull, so they are out of scope here --
|
||||
# do not read a green check 8 as covering them.
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ "${SKIP_SIZE_CHECK:-0}" = "1" ]; then
|
||||
skip "size claims -- SKIP_SIZE_CHECK=1 was set"
|
||||
elif ! command -v curl >/dev/null 2>&1 || ! command -v python3 >/dev/null 2>&1; then
|
||||
skip "size claims -- need both curl and python3 to measure them"
|
||||
else
|
||||
# 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 this check silently probing a repo nobody publishes to.
|
||||
# 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)"
|
||||
if [ -z "$HUB_REPO_PATH" ]; then
|
||||
skip "size claims -- found no \`repo:tag\` image rows in $HUB to check"
|
||||
else
|
||||
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
|
||||
@@ -369,6 +388,287 @@ PYEOF
|
||||
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` is compared as a literal against the `pi-version` label.
|
||||
#
|
||||
# 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"
|
||||
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)
|
||||
key = LABEL + name if name == "pi-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
|
||||
|
||||
Reference in New Issue
Block a user