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
gates / gates (push) Successful in 1s
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Executable
+139
@@ -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:]))
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user