diff --git a/CHANGELOG.md b/CHANGELOG.md index fe076b3..c1c11f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## catalog-since gate: an image move must bump catalog_since (2026-09-13, R-452) + +`scripts/check-catalog-since.py`, fifth gate in `catalog_gates.py` (fast, git-history, `--range A..B` +like engine-major): for every compose file changed in the range whose per-service `image:` lines +differ, the app's `.felhom.yml` at the range end must carry a `catalog_since` on or after the day of +the commit that moved them, and not in the future. A date moving in a comment, a README or a +CHANGELOG is not the fact (R-421). Enforced by the pre-push hook; CI's shallow clone skips it out +loud, as it does engine-major. Five decoy cases (`scripts/test_gate_decoys.py`); red-proof: dropping +the date comparison lets the "untouched date" fact through (`felhom.eu` `audits/v0240-2026-09-13/rp-R452.txt`). + ## glance: a fresh install lands on a start page instead of a crash loop (2026-09-13, R-473) The image ships no default config and exits at once without `/app/config/glance.yml`, so every diff --git a/CLAUDE.md b/CLAUDE.md index 7fec985..f43302d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,7 @@ deployed `app.yaml` (customer secrets) is never overwritten. Full deploy details version jump). Digest pins (`@sha256:`) also count. Gate: `python scripts/check-image-pins.py` (run after any compose change; exit 1 on any floating/missing tag). - **Any commit that changes an `image:` line MUST set that app's `catalog_since` to the same day.** + **Gated since 2026-09-13 (R-452):** `scripts/check-catalog-since.py` runs in the pre-push hook and refuses an image move whose `catalog_since` is older than the moving commit (CI's shallow clone skips it out loud). `.felhom.yml` carries `catalog_since: "YYYY-MM-DD"` — the date THIS repo last moved that app's pinned images. It is not a version and not an upstream release date; the controller uses it, and only it, to tell a customer *"Frissítés elérhető — 45 napja"*. No version number is ever shown to diff --git a/REPORT.md b/REPORT.md index 581c293..117f6bd 100644 --- a/REPORT.md +++ b/REPORT.md @@ -8,7 +8,9 @@ - **glance — a seeded `glance.yml` on first boot** (`50ad286`, R-473). Fresh installs crash-looped without a config. Proven live: a throwaway install healthy in 21 s, 0 restarts, front door 200. -No version moved. Gates green on both pushes (`catalog_gates.py --fast`). +- **catalog-since gate** (R-452): `scripts/check-catalog-since.py` — an `image:` move must bump that app's `catalog_since`; five decoys; red-proofed. + +No version moved. Gates green on every push (`catalog_gates.py --fast`, now five gates). ## Observations diff --git a/scripts/catalog_gates.py b/scripts/catalog_gates.py index b2d928a..2c8f3f1 100644 --- a/scripts/catalog_gates.py +++ b/scripts/catalog_gates.py @@ -15,6 +15,7 @@ Gates, in order (all must pass; **non-zero exit on any failure**): 1. image-pins static, instant, whole repo — no :latest / untagged / floating alias 2. image-resolvable network — every pinned tag still EXISTS upstream 3. volume-persistence RUNTIME — the folder a template preserves is the folder the app writes to + 5. catalog-since static, needs GIT HISTORY — an image: move bumps that app's catalog_since (R-452) 4. engine-major static, needs GIT HISTORY — no database engine pin crosses a MAJOR version (operator ruling 2026-09-13; expires when Slice 4 / R-448 ships). Runs in the pre-push hook, which has the full clone; on a SHALLOW clone (CI fetches at @@ -75,6 +76,9 @@ GATES = [ ("image-resolvable", "check-image-resolvable.py", True, False, False), ("volume-persistence", "check-volume-persistence.py", True, False, False), ("engine-major", "check-engine-major.py", False, True, True), + # R-452 (2026-09-13): an image: move must bump that app's catalog_since. Same shape as + # engine-major — git history, fast, skipped out loud on a shallow clone. + ("catalog-since", "check-catalog-since.py", False, True, True), ] VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"} diff --git a/scripts/check-catalog-since.py b/scripts/check-catalog-since.py new file mode 100755 index 0000000..6580bb5 --- /dev/null +++ b/scripts/check-catalog-since.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""check-catalog-since.py — catalog gate: an `image:` move must bump that app's `catalog_since` (R-452). + + python3 scripts/check-catalog-since.py # diff origin/main..HEAD + python3 scripts/check-catalog-since.py --range .. # what .githooks/pre-push passes + +THE RULE (CLAUDE.md, since 2026-09-02): any commit that changes an `image:` line MUST set that app's +`catalog_since` (`.felhom.yml`) to the same day. `catalog_since` is the one number the update badge +shows a household — „Frissítés elérhető — N napja" — and a stale date under-reports N silently. +R-452 named the gap: the rule had no instrument. + +WHAT IT CHECKS, per compose file changed in the range: the per-service `image:` lines at A and at B. +If any service's image differs (or the template is new at B), the `.felhom.yml` at B must carry a +`catalog_since: "YYYY-MM-DD"` that is + - not older than the newest commit in the range that touched that compose file, and + - not in the future (a mistyped year is the label without the fact). +A comment, an env line, a README mention or a CHANGELOG entry moving is NOT an image move (R-421: +label vs fact); the field is read from `.felhom.yml` and nowhere else. + +SHALLOW CLONES. Like engine-major, this diffs two commits, so on a `--depth 1` clone (CI) it is +INCONCLUSIVE and `catalog_gates.py` SKIPS it out loud; the pre-push hook has the full clone and is +where it bites. Exit 0 clean · 1 convicted · 2 inconclusive. +""" +import datetime +import re +import subprocess +import sys + +TEMPLATE_RE = re.compile(r"^templates/([^/]+)/docker-compose\.yml$") +SERVICE_RE = re.compile(r"^ ([A-Za-z0-9_-]+):\s*$") +IMAGE_RE = re.compile(r"^\s+image:\s*[\"']?([^\s\"'#]+)") +SINCE_RE = re.compile(r"^catalog_since:\s*[\"']?(\d{4}-\d{2}-\d{2})[\"']?\s*(#.*)?$", re.MULTILINE) +ZERO_SHA_RE = re.compile(r"^0{40}$") + + +def git(*args): + p = subprocess.run(["git"] + list(args), capture_output=True, text=True) + return p.returncode, p.stdout, p.stderr + + +def images_in(text): + out, cur = {}, None + for line in text.splitlines(): + m = SERVICE_RE.match(line) + if m: + cur = m.group(1) + continue + mi = IMAGE_RE.match(line) + if mi and cur and cur not in out: + out[cur] = mi.group(1) + return out + + +def resolve_range(spec): + if not spec or ".." not in spec: + return None, None, "range must be .. (got %r)" % spec + a, b = spec.split("..", 1) + if ZERO_SHA_RE.match(a): + a = "origin/main" + for r in (a, b): + rc, _, err = git("rev-parse", "--verify", "-q", r + "^{commit}") + if rc != 0: + return None, None, "cannot resolve %r (%s)" % (r, err.strip() or "not a commit") + return a, b, "" + + +def show(ref, path): + rc, out, _ = git("show", "%s:%s" % (ref, path)) + return out if rc == 0 else None + + +def main(argv): + spec = "origin/main..HEAD" + for i, arg in enumerate(argv): + if arg.startswith("--range="): + spec = arg[len("--range="):] + elif arg == "--range" and i + 1 < len(argv): + spec = argv[i + 1] + rc, shallow, _ = git("rev-parse", "--is-shallow-repository") + if rc == 0 and shallow.strip() == "true": + print("CATALOG-SINCE GATE INCONCLUSIVE: this clone is SHALLOW — there is no parent commit to " + "diff an image: line against (the R-452 gap; the CI runner fetches at --depth 1). " + "This gate is enforced by the pre-push hook, which has the full clone.") + return 2 + a, b, why = resolve_range(spec) + if a is None: + print("CATALOG-SINCE GATE INCONCLUSIVE: %s" % why) + return 2 + rc, names, err = git("diff", "--name-only", a, b, "--", "templates") + if rc != 0: + print("CATALOG-SINCE GATE INCONCLUSIVE: git diff %s %s failed: %s" % (a, b, err.strip())) + return 2 + files = [n for n in names.split("\n") if TEMPLATE_RE.match(n)] + today = datetime.date.today().isoformat() + + moved, convicted, inconclusive = 0, [], [] + for path in files: + app = TEMPLATE_RE.match(path).group(1) + before, after = show(a, path), show(b, path) + if after is None: + continue # deleted at B — nothing to date + if before is not None and images_in(before) == images_in(after): + continue # a comment / env / label moved; the images did not + moved += 1 + rc, dates, err = git("log", "--format=%cs", "%s..%s" % (a, b), "--", path) + newest = (dates.split("\n")[0].strip() if rc == 0 and dates.strip() else "") + if not newest: + inconclusive.append("%s: no commit in %s..%s touches %s, yet its images differ" % (app, a, b, path)) + continue + fy = show(b, "templates/%s/.felhom.yml" % app) + m = SINCE_RE.search(fy or "") + if not m: + convicted.append("%s: image line(s) moved (commit dated %s) but templates/%s/.felhom.yml at %s carries no `catalog_since: \"YYYY-MM-DD\"`" % (app, newest, app, b)) + continue + since = m.group(1) + if since < newest: + convicted.append("%s: image line(s) moved in a commit dated %s, but catalog_since is still %s — set it to the day of the move" % (app, newest, since)) + elif since > today: + convicted.append("%s: catalog_since %s is in the future (today is %s) — a mistyped date is the label without the fact" % (app, since, today)) + + print("catalog-since gate — range %s..%s: %d compose file(s) changed, %d image move(s) dated" % (a, b, len(files), moved)) + if convicted: + print("CATALOG-SINCE GATE FAILED — an image: line moved without its catalog_since (R-452):") + for c in convicted: + print(" - " + c) + print("The badge „Frissítés elérhető — N napja” counts from catalog_since; a stale date under-reports N.") + return 1 + if inconclusive: + print("CATALOG-SINCE GATE INCONCLUSIVE:") + for c in inconclusive: + print(" - " + c) + return 2 + print("catalog-since gate OK — every image move in the range carries a catalog_since on or after its commit day") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/test_catalog_gates.py b/scripts/test_catalog_gates.py index 249d1dd..0bd4df2 100644 --- a/scripts/test_catalog_gates.py +++ b/scripts/test_catalog_gates.py @@ -58,10 +58,10 @@ class CatalogGatesFastTest(unittest.TestCase): spec = importlib.util.spec_from_file_location("catalog_gates_under_test", ENTRY) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) - self.assertEqual(len(mod.GATES), 4) - self.assertEqual([g[0] for g in mod.GATES if g[3]], ["image-pins", "engine-major"]) - # exactly one gate needs git history; it is the one the CI half cannot run (R-452) - self.assertEqual([g[0] for g in mod.GATES if g[4]], ["engine-major"]) + self.assertEqual(len(mod.GATES), 5) + self.assertEqual([g[0] for g in mod.GATES if g[3]], ["image-pins", "engine-major", "catalog-since"]) + # two gates need git history; they are the ones the CI half cannot run (R-452's shallow gap) + self.assertEqual([g[0] for g in mod.GATES if g[4]], ["engine-major", "catalog-since"]) def test_engine_major_ran_under_fast(self): """The 2026-09-13 gate is fast (git reads only) and must be IN --fast, or the hook that @@ -77,7 +77,7 @@ class CatalogGatesFastTest(unittest.TestCase): subprocess.run(["git", "clone", "-q", "--depth", "1", "file://" + ROOT, tmp], check=True, capture_output=True) # test the WORKING-TREE runner and gate, not whatever HEAD happens to hold - for fn in ("catalog_gates.py", "check-engine-major.py", "check-image-pins.py"): + for fn in ("catalog_gates.py", "check-engine-major.py", "check-image-pins.py", "check-catalog-since.py"): shutil.copy(os.path.join(ROOT, "scripts", fn), os.path.join(tmp, "scripts", fn)) p = subprocess.run([sys.executable, os.path.join(tmp, "scripts", "catalog_gates.py"), "--fast"], cwd=tmp, capture_output=True, text=True) @@ -90,5 +90,10 @@ class CatalogGatesFastTest(unittest.TestCase): shutil.rmtree(tmp, ignore_errors=True) + if __name__ == "__main__": unittest.main(verbosity=2) + + def test_catalog_since_ran_under_fast(self): + """R-452's gate is fast (git reads only) and must be IN --fast, like engine-major.""" + self.assertIn("catalog-since gate", self.out) diff --git a/scripts/test_gate_decoys.py b/scripts/test_gate_decoys.py index 5a1e38b..b3daf09 100644 --- a/scripts/test_gate_decoys.py +++ b/scripts/test_gate_decoys.py @@ -47,6 +47,7 @@ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # MUST have a decoy below that has been seen to fail. COVERS = { "engine-major": "the major moved in a comment/env var/README/app image, not on an engine's image: line", + "catalog-since": "the date bumped in a comment/README while .felhom.yml's field stayed; or only a comment/env moved, no image (R-452)", } fails = [] @@ -88,7 +89,7 @@ def reset(clone): sh(["git", "reset", "-q", "--hard", "HEAD"], cwd=clone) -def case(name, clone, edits, expect_rc, must_contain=()): +def case(name, clone, edits, expect_rc, must_contain=(), gate="check-engine-major.py"): """edits: list of (relpath, fn). Commits them, runs the gate on HEAD~1..HEAD, restores.""" global ran ran += 1 @@ -98,7 +99,7 @@ def case(name, clone, edits, expect_rc, must_contain=()): edit(clone, relpath, fn) commit(clone, name) # the WORKING-TREE gate, run inside the clone (it reads git from its cwd) - r = sh([sys.executable, os.path.join(ROOT, "scripts", "check-engine-major.py"), + r = sh([sys.executable, os.path.join(ROOT, "scripts", gate), "--range", "HEAD~1..HEAD"], cwd=clone) out = r.stdout + r.stderr ok = r.returncode == expect_rc and all(m in out for m in must_contain) @@ -176,6 +177,36 @@ def main(): case("DECOY: 'mariadb:12.3' lands in README.md, not a template", clone, [("README.md", lambda t: t + "\nDecoy: mariadb:11.6 -> mariadb:12.3 pending.\n")], expect_rc=0, must_contain=("0 compose file(s) changed",)) + + # ── catalog-since (R-452): an image move must bump the app's catalog_since ─────────── + import datetime + today = datetime.date.today().isoformat() + KIMAI_FY = "templates/kimai/.felhom.yml" + CS = "check-catalog-since.py" + def set_since(date): + def _fn(text): + new = re.sub(r'^catalog_since:\s*"?\d{4}-\d{2}-\d{2}"?', 'catalog_since: "%s"' % date, text, count=1, flags=re.M) + if new == text: + raise SystemExit("kimai's .felhom.yml carries no catalog_since — fixture drifted") + return new + return _fn + case("FACT: kimai image moves, catalog_since untouched", clone, + [(KIMAI, swap_image("kimai", "kimai/kimai2:apache-2.57.0", "kimai/kimai2:apache-2.58.0"))], + expect_rc=1, must_contain=("CATALOG-SINCE GATE FAILED", "kimai", "catalog_since is still"), gate=CS) + case("FACT: kimai image moves, catalog_since set to a FUTURE year", clone, + [(KIMAI, swap_image("kimai", "kimai/kimai2:apache-2.57.0", "kimai/kimai2:apache-2.58.0")), + (KIMAI_FY, set_since("2036-09-13"))], + expect_rc=1, must_contain=("in the future",), gate=CS) + case("GENUINE: kimai image moves AND catalog_since = today", clone, + [(KIMAI, swap_image("kimai", "kimai/kimai2:apache-2.57.0", "kimai/kimai2:apache-2.58.0")), + (KIMAI_FY, set_since(today))], + expect_rc=0, must_contain=("catalog-since gate OK", "1 image move(s) dated"), gate=CS) + case("DECOY: image moves; today's date lands in a COMMENT and README, the field stays", clone, + [(KIMAI, lambda t: swap_image("kimai", "kimai/kimai2:apache-2.57.0", "kimai/kimai2:apache-2.58.0")(t).replace("services:", "# catalog_since: %s\nservices:" % today, 1)), + ("README.md", lambda t: t + "\ncatalog_since: %s (kimai)\n" % today)], + expect_rc=1, must_contain=("CATALOG-SINCE GATE FAILED",), gate=CS) + case("DECOY: only a comment + env line change, images untouched, date untouched", clone, + [(KIMAI, comment_and_env)], expect_rc=0, must_contain=("0 image move(s) dated", "catalog-since gate OK"), gate=CS) finally: shutil.rmtree(clone, ignore_errors=True)