diff --git a/.gitea/workflows/lint.yml b/.gitea/workflows/lint.yml index 688c319..e76905e 100644 --- a/.gitea/workflows/lint.yml +++ b/.gitea/workflows/lint.yml @@ -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. # diff --git a/AGENTS.md b/AGENTS.md index 5417b5a..ace5689 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 74d3563..60efffa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.-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 `-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 diff --git a/scripts/check-doc-drift.sh b/scripts/check-doc-drift.sh index 251a6f9..5d9abbf 100755 --- a/scripts/check-doc-drift.sh +++ b/scripts/check-doc-drift.sh @@ -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 - # 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. - # 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 +# 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 @@ -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.-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 -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 :'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