R-731: the shape-switch control is standing — scripts/check-currency.py (stdlib) with fixture tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -13,6 +13,8 @@ Templates are config; the few script helpers other scripts must REUSE, never re-
|
||||
- `scripts/image_digest.py` — `resolve(ref)` → the digest the registry serves now (the one Docker
|
||||
records in `RepoDigests`). stdlib only — the CI runner has no `requests`/PyYAML.
|
||||
- `scripts/upgrade_boxport.py` — runs the box walk's fixtures (`upgrade_fixtures_box*.py`) on the bench.
|
||||
- `scripts/check-currency.py` — how far behind upstream each pin is, same-shape AND the standing shape-switch control
|
||||
(an upstream that changed its tag shape, R-731); network, read-only, accuses nothing. Fixtures: `test_check_currency.py`.
|
||||
|
||||
## 2. Canonical patterns (copy structure from THE named file)
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""check-currency.py — how far behind upstream is each pin, INCLUDING an upstream that changed its tag shape (R-731).
|
||||
|
||||
WHAT IT IS. The standing form of the 2026-09-30 catalog-currency audit
|
||||
(`felhom.eu/documentation/audits/catalog-currency-2026-09-30/00-currency.py`, which needed `requests`; this needs only
|
||||
the standard library, through `image_digest.py`'s registry client). It reads every pin from the templates
|
||||
(`ladder.images_in` — the same reading the gates make), lists the repository's tags anonymously, and reports, per pin:
|
||||
|
||||
* SAME-SHAPE newest — within the pin's major and at all. Two tags compare only when the text between their numbers
|
||||
is identical and they carry as many numbers (`16-alpine` only against `<N>-alpine`). This is what the badge and
|
||||
every shape-based tool see.
|
||||
* THE SHAPE-SWITCH CONTROL — the reason this file exists. The same-shape rule read three apps as up to date that were
|
||||
not: gramps-web (`v25.6.0`; upstream dropped the `v` at `26.9.1`), jellyfin (`10.11.11`; 12.x publishes two-part
|
||||
`12.1`), kimai (`apache-2.57.0`; plain `2.67.0`). In the audit the control was a one-off pass whose output survived
|
||||
(`09-shape-switch-check.txt`) and whose code did not. Here it is standing: a RELEASE-LIKE tag under ANY shape whose
|
||||
version core (the first three numbers of its leading version run) is higher than the pin's, while no same-shape tag
|
||||
is, is reported `SHAPE-SWITCH?` — a question for a person, never an accusation. Release-like excludes what fooled
|
||||
the first pass: unstable markers (`rc`, `beta`, `dev`, …), architecture-prefixed and date-rebuild tags (sonarr's
|
||||
`amd64-5.14-…`), and branch tags (tandoor's `dependabot-pip-…`).
|
||||
|
||||
It accuses nothing and gates nothing (network, throttled registries): exit 0 when every pin was read, 2 when any could
|
||||
not be (a throttle or an error is INCONCLUSIVE, never "up to date").
|
||||
|
||||
USAGE
|
||||
python3 scripts/check-currency.py # every template
|
||||
python3 scripts/check-currency.py kimai jellyfin # only these
|
||||
python3 scripts/check-currency.py --json=<file> # also write the rows
|
||||
Fixture tests (no network): scripts/test_check_currency.py.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(HERE)
|
||||
sys.path.insert(0, HERE)
|
||||
import image_digest # noqa: E402
|
||||
import ladder # noqa: E402
|
||||
|
||||
UNSTABLE = ("rc", "beta", "alpha", "dev", "nightly", "canary", "edge", "preview", "snapshot", "-pr", "test",
|
||||
"unstable")
|
||||
HASH_RE = re.compile(r"(?<![0-9a-z])(?=[0-9a-f]*[a-f])(?=[0-9a-f]*[0-9])[0-9a-f]{7,40}(?![0-9a-z])")
|
||||
# A release-like tag for the shape-switch control: an optional `v`, a dotted version run, and at most ONE short word
|
||||
# flavour (`-alpine`, `-apache`, `-rootless`). Anything else (arch prefixes, branch names, build stamps) is not a
|
||||
# release a household could be moved to without a person reading it.
|
||||
RELEASE_RE = re.compile(r"^(?:[a-z]+-)?v?(\d+(?:\.\d+){0,3})(?:-[a-z]+)?$")
|
||||
ARCH_WORDS = ("amd64", "arm64", "arm64v8", "armhf", "arm32v7", "armv7", "aarch64", "x86_64", "i386", "s390x",
|
||||
"ppc64le")
|
||||
|
||||
|
||||
def tokenize(tag):
|
||||
"""(shape, numbers) or None when the tag carries no number."""
|
||||
t = HASH_RE.sub("<hash>", tag.lower())
|
||||
parts = re.split(r"(\d+)", t)
|
||||
nums = tuple(int(p) for p in parts[1::2])
|
||||
if not nums:
|
||||
return None
|
||||
return tuple(parts[0::2]), nums
|
||||
|
||||
|
||||
def unstable(tag):
|
||||
low = tag.lower()
|
||||
return any(mk in low for mk in UNSTABLE)
|
||||
|
||||
|
||||
def date_rebuild(nums):
|
||||
return any(n >= 20000 for n in nums)
|
||||
|
||||
|
||||
def newest_same_shape(tags, cur):
|
||||
"""{'cur': nums, 'all': (tag, nums) | None, 'maj': (tag, nums) | None} or None when the pin has no number."""
|
||||
c = tokenize(cur)
|
||||
if not c:
|
||||
return None
|
||||
shape, nums = c
|
||||
best_all = best_maj = None
|
||||
for t in tags:
|
||||
if unstable(t) and not unstable(cur):
|
||||
continue
|
||||
p = tokenize(t)
|
||||
if not p or p[0] != shape or len(p[1]) != len(nums):
|
||||
continue
|
||||
if date_rebuild(p[1]) and not date_rebuild(nums):
|
||||
continue
|
||||
if best_all is None or p[1] > best_all[1]:
|
||||
best_all = (t, p[1])
|
||||
if p[1][0] == nums[0] and (best_maj is None or p[1] > best_maj[1]):
|
||||
best_maj = (t, p[1])
|
||||
return {"cur": nums, "all": best_all, "maj": best_maj}
|
||||
|
||||
|
||||
def core(tag):
|
||||
"""The version core of a RELEASE-LIKE tag (first three numbers of its leading version run, zero-padded), or None."""
|
||||
low = tag.lower()
|
||||
if unstable(low):
|
||||
return None
|
||||
m = RELEASE_RE.match(low)
|
||||
if not m:
|
||||
return None
|
||||
prefix = low.split("-", 1)[0] if re.match(r"^[a-z]+-", low) else ""
|
||||
if prefix in ARCH_WORDS or prefix == "v":
|
||||
return None
|
||||
nums = tuple(int(x) for x in m.group(1).split("."))
|
||||
if date_rebuild(nums):
|
||||
return None
|
||||
return (nums + (0, 0, 0))[:3]
|
||||
|
||||
|
||||
def pin_core(tag):
|
||||
"""The pin's own core: the first dotted run in the tag, zero-padded (the pin need not be release-like itself)."""
|
||||
m = re.search(r"\d+(?:\.\d+)*", tag)
|
||||
if not m:
|
||||
return None
|
||||
nums = tuple(int(x) for x in m.group(0).split("."))
|
||||
return (nums + (0, 0, 0))[:3]
|
||||
|
||||
|
||||
def shape_switch(tags, cur):
|
||||
"""(tag, core) of the highest release-like tag under ANY shape that is above the pin's core while NO same-shape tag
|
||||
is above the pin — else None. That is the case the same-shape rule reads as up to date."""
|
||||
same = newest_same_shape(tags, cur)
|
||||
pc = pin_core(cur)
|
||||
if same is None or pc is None:
|
||||
return None
|
||||
if same["all"] and same["all"][1] > same["cur"]:
|
||||
return None # the same-shape rule already sees a newer release
|
||||
best = None
|
||||
for t in tags:
|
||||
c = core(t)
|
||||
if c and c > pc and (best is None or c > best[1]):
|
||||
best = (t, c)
|
||||
return best
|
||||
|
||||
|
||||
# ── the registry (network; never reached by the tests) ───────────────────────────────────────────────────────────
|
||||
|
||||
def _get(url, token=None, timeout=30):
|
||||
h = {"User-Agent": image_digest.UA}
|
||||
if token:
|
||||
h["Authorization"] = "Bearer " + token
|
||||
req = urllib.request.Request(url, headers=h)
|
||||
return urllib.request.urlopen(req, timeout=timeout)
|
||||
|
||||
|
||||
def tags_all(host, repo, max_pages=200):
|
||||
url = "https://%s/v2/%s/tags/list?n=1000" % (host, repo)
|
||||
token = None
|
||||
try:
|
||||
r = _get(url)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code != 401 or "WWW-Authenticate" not in e.headers:
|
||||
raise
|
||||
token = image_digest._bearer(e.headers["WWW-Authenticate"])
|
||||
r = _get(url, token)
|
||||
tags, pages = [], 0
|
||||
while True:
|
||||
with r:
|
||||
body = json.load(r)
|
||||
link = r.headers.get("Link")
|
||||
tags.extend(body.get("tags") or [])
|
||||
pages += 1
|
||||
m = re.search(r'<([^>]+)>;\s*rel="next"', link or "")
|
||||
if not m or pages >= max_pages:
|
||||
break
|
||||
nxt = m.group(1)
|
||||
if nxt.startswith("/"):
|
||||
nxt = "https://%s%s" % (host, nxt)
|
||||
r = _get(nxt, token)
|
||||
return tags
|
||||
|
||||
|
||||
def main(argv):
|
||||
out_json = None
|
||||
apps = []
|
||||
for a in argv:
|
||||
if a.startswith("--json="):
|
||||
out_json = a.split("=", 1)[1]
|
||||
elif a.startswith("-"):
|
||||
print("unknown option: %s" % a)
|
||||
return 2
|
||||
else:
|
||||
apps.append(a)
|
||||
tdir = os.path.join(ROOT, "templates")
|
||||
every = sorted(d for d in os.listdir(tdir) if os.path.isfile(os.path.join(tdir, d, "docker-compose.yml")))
|
||||
rows, cache, errors = [], {}, 0
|
||||
for app in (apps or every):
|
||||
imgs = ladder.images_in(open(os.path.join(tdir, app, "docker-compose.yml"), encoding="utf-8").read())
|
||||
for svc, ref in imgs.items():
|
||||
host, repo, tag = image_digest.split_ref(ref)
|
||||
row = {"app": app, "service": svc, "ref": ref}
|
||||
if host.startswith("gitea.dooplex.hu"):
|
||||
row["status"] = "internal"
|
||||
rows.append(row)
|
||||
print("%-18s %-22s internal image, not upstream" % (app, svc))
|
||||
continue
|
||||
try:
|
||||
if (host, repo) not in cache:
|
||||
cache[(host, repo)] = tags_all(host, repo)
|
||||
time.sleep(0.15)
|
||||
tags = cache[(host, repo)]
|
||||
same = newest_same_shape(tags, tag)
|
||||
sw = shape_switch(tags, tag)
|
||||
row.update(status="ok", n_tags=len(tags),
|
||||
newest_major=same["maj"][0] if same and same["maj"] else None,
|
||||
newest_all=same["all"][0] if same and same["all"] else None,
|
||||
shape_switch=sw[0] if sw else None)
|
||||
verdict = "SHAPE-SWITCH? %s" % sw[0] if sw else (
|
||||
"behind" if same and same["all"] and same["all"][1] > same["cur"] else "current")
|
||||
print("%-18s %-22s %-28s same-shape: major %s, all %s — %s" % (
|
||||
app, svc, tag, row["newest_major"], row["newest_all"], verdict))
|
||||
except Exception as e: # recorded, never guessed
|
||||
errors += 1
|
||||
row.update(status="error", error=("%s: %s" % (type(e).__name__, e))[:300])
|
||||
print("%-18s %-22s %-28s INCONCLUSIVE: %s" % (app, svc, tag, row["error"]))
|
||||
rows.append(row)
|
||||
if out_json:
|
||||
json.dump(rows, open(out_json, "w"), indent=1)
|
||||
switches = [r for r in rows if r.get("shape_switch")]
|
||||
print("\ncheck-currency: %d pin(s); %d shape switch(es) to read by a person; %d INCONCLUSIVE"
|
||||
% (len(rows), len(switches), errors))
|
||||
return 2 if errors else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Fixture tests for check-currency.py (R-731) — no network. The tag lists are the ones the 2026-09-30 audit read
|
||||
(felhom.eu/documentation/audits/catalog-currency-2026-09-30/10-shape-switch-detail.txt and 09-shape-switch-check.txt),
|
||||
cut to the tags that decide each case.
|
||||
|
||||
Run: python3 scripts/test_check_currency.py
|
||||
"""
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
spec = importlib.util.spec_from_file_location("check_currency", os.path.join(HERE, "check-currency.py"))
|
||||
cc = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(cc)
|
||||
|
||||
GRAMPS = ['26.4.3', '26.5.0', '26.9.0', '26.9.1', 'v25.1.0', 'v25.5.2', 'v25.6.0']
|
||||
JELLYFIN = ['10.11.10', '10.11.11', '12.0-rc3', '12.0-rc3-amd64.20260722-020441', '12.0-rc7.20260831-232051',
|
||||
'12.0.20260908-012347', '12.1', '12.1-amd64.20260915-010956', '12.1.20260915-010956']
|
||||
KIMAI = ['2.58.0', '2.66.0', '2.67.0', 'apache-2.56.0', 'apache-2.57.0']
|
||||
SONARR = ['4.0.19', '4.0.20', '4.0.20.2983-ls301', 'amd64-5.14-2.0.0.5344-ls5', 'develop', 'latest']
|
||||
TANDOOR = ['2.6.14', '2.6.15', 'dependabot-pip-drf-spectacular-sidecar-2025.10.1', 'beta-2.7.0']
|
||||
POSTGRES = ['16-alpine', '16.4-alpine', '17-alpine', '18-alpine', '18', '19beta1-alpine']
|
||||
|
||||
|
||||
class ShapeSwitch(unittest.TestCase):
|
||||
"""The control must catch the three the same-shape rule missed, and stay silent on the two that fooled it."""
|
||||
|
||||
def test_the_three_real_switches_are_caught(self):
|
||||
self.assertEqual(cc.shape_switch(GRAMPS, "v25.6.0")[0], "26.9.1")
|
||||
self.assertEqual(cc.shape_switch(JELLYFIN, "10.11.11")[0], "12.1")
|
||||
self.assertEqual(cc.shape_switch(KIMAI, "apache-2.57.0")[0], "2.67.0")
|
||||
|
||||
def test_the_same_shape_rule_alone_reads_them_current(self):
|
||||
# the defect the control exists for: nothing newer under the pin's own shape
|
||||
for tags, pin in ((GRAMPS, "v25.6.0"), (JELLYFIN, "10.11.11"), (KIMAI, "apache-2.57.0")):
|
||||
same = cc.newest_same_shape(tags, pin)
|
||||
self.assertFalse(same["all"][1] > same["cur"], pin)
|
||||
|
||||
def test_arch_prefixed_and_branch_tags_are_not_releases(self):
|
||||
self.assertIsNone(cc.shape_switch(SONARR, "4.0.20")) # amd64-5.14-… fooled the first pass
|
||||
self.assertIsNone(cc.shape_switch(TANDOOR, "2.6.15")) # dependabot-… and beta-… too
|
||||
|
||||
def test_a_build_stamped_tag_alone_is_not_a_release(self):
|
||||
# jellyfin publishes arch + date builds beside each release; one with no plain tag is not a release to move to
|
||||
self.assertIsNone(cc.shape_switch(['10.11.11', '13.0-amd64.20261001-010101', '13.0.20261001-010101'],
|
||||
"10.11.11"))
|
||||
|
||||
def test_a_newer_same_shape_tag_is_behind_not_a_switch(self):
|
||||
self.assertIsNone(cc.shape_switch(POSTGRES, "16-alpine"))
|
||||
same = cc.newest_same_shape(POSTGRES, "16-alpine")
|
||||
self.assertEqual(same["all"][0], "18-alpine") # 19beta1 skipped as unstable
|
||||
self.assertEqual(same["maj"][0], "16-alpine") # 16.4-alpine is another shape
|
||||
|
||||
def test_current_pin_with_nothing_newer_is_silent(self):
|
||||
self.assertIsNone(cc.shape_switch(['2.6.0', '2.5.9'], "2.6.0"))
|
||||
|
||||
def test_release_core(self):
|
||||
self.assertEqual(cc.core("26.9.1"), (26, 9, 1))
|
||||
self.assertEqual(cc.core("v1.2"), (1, 2, 0))
|
||||
self.assertEqual(cc.core("2.67.0-apache"), (2, 67, 0))
|
||||
for junk in ("12.0-rc3", "amd64-5.14-2.0.0.5344-ls5", "12.1.20260915-010956", "latest", "develop"):
|
||||
self.assertIsNone(cc.core(junk), junk)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Reference in New Issue
Block a user