catalog-since gate: an image: move must bump the app's catalog_since (R-452) — hook-enforced, shallow CI skips out loud
gates / gates (push) Successful in 1s

This commit is contained in:
2026-09-13 19:42:13 +02:00
parent 7410915d82
commit 3f73c0e28a
7 changed files with 200 additions and 8 deletions
+10
View File
@@ -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
+1
View File
@@ -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
+3 -1
View File
@@ -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
+4
View File
@@ -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"}
+139
View File
@@ -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 <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:]))
+10 -5
View File
@@ -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)
+33 -2
View File
@@ -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)