#!/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:]))