Files
app-catalog-felhom.eu/scripts/check-catalog-since.py
T

140 lines
6.1 KiB
Python
Executable File

#!/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 <A>..<B> # 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 <A>..<B> (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:]))