140 lines
6.1 KiB
Python
Executable File
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:]))
|