96829d0d0f
gates / gates (push) Successful in 3s
- family_gate / family_gate_except / min_controller format (README, REUSE); gate family-gate + decoys - templates/grimmory (v3.5.0, exceptions OPDS/Kobo/KOReader/Komga) + onboarding/grimmory.md - templates/metube (2026.09.29, no exceptions; ladder .28 -> .29) + onboarding/metube.md; fixture MeTube - volume-persistence gate: routed port read from the label NAME (R-801, red-proofed); APP_EXERCISE (R-788) - box_walk: family_cookie; no cached "not gated" while an app has no router Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
147 lines
6.5 KiB
Python
147 lines
6.5 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""check-family-gate.py — the family gate's template fields are safe to publish (`09` §3 decisions 63/64, controller 0.287.0).
|
|
|
|
A template with `family_gate: true` is published ONLY behind the household's family gate. Three ways that goes wrong, each
|
|
refused here:
|
|
|
|
1. an exception (`family_gate_except:`) that is not a LITERAL path prefix — a regex, a traefik matcher, `..`, `//`, or `/`
|
|
itself. The controller anchors each prefix at a path-segment boundary (`^/prefix(/|$)`, finding F1 of the spike: an
|
|
unanchored `PathPrefix(/api/v1/opds)` let `/api/v1/opdsx` past the gate) and refuses the install on anything else —
|
|
this gate refuses it at push time instead of at a household's install;
|
|
2. `family_gate: true` without `min_controller: "0.287.0"` (or newer) — the controller refuses a template that needs a
|
|
newer box, but only if the template SAYS so;
|
|
3. `family_gate: true` while the newest baked golden is older than 0.287.0 — a box installed from that golden runs a
|
|
controller that does not know `family_gate` and would publish the app OPEN. Read from the sibling `felhom.eu`
|
|
checkout (`documentation/tests/golden-<VER>-<DATE>/` holding a bake log with a `GOLDEN_SHA256=` line — a directory
|
|
NAME is not a bake, R-410). The sibling absent (CI's single clone) → printed as NOT CHECKED, never as a pass of rule 3.
|
|
|
|
Also refused: `family_gate_except:` on a template without `family_gate: true` (an exception list with no gate is a label
|
|
without the fact).
|
|
|
|
Line-based on purpose: the catalog's CI has NO PyYAML. Only TOP-LEVEL, uncommented keys count; a key inside a comment, a
|
|
README or a tagline is not the field.
|
|
|
|
Run from the repo root: python3 scripts/check-family-gate.py [--root=<catalog>] [--felhom-eu=<sibling>]
|
|
Exit 0 clean · 1 refused. Decoys: scripts/test_gate_decoys.py (family-gate).
|
|
"""
|
|
import io
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
MIN_VERSION = (0, 287, 0)
|
|
LITERAL = re.compile(r"^/[A-Za-z0-9._~/-]*$")
|
|
TOP_TRUE = re.compile(r"^family_gate:\s*true\s*(#.*)?$")
|
|
TOP_EXCEPT = re.compile(r"^family_gate_except:\s*(#.*)?$")
|
|
TOP_MINC = re.compile(r'^min_controller:\s*"?([0-9]+\.[0-9]+\.[0-9]+)"?\s*(#.*)?$')
|
|
ITEM = re.compile(r'^\s+-\s*(?:"([^"]*)"|\'([^\']*)\'|(\S+))\s*(#.*)?$')
|
|
GOLDEN_DIR = re.compile(r"^golden-(\d+)\.(\d+)\.(\d+)-\d{4}-\d{2}-\d{2}$")
|
|
GOLDEN_SHA = re.compile(r"GOLDEN_SHA256=[0-9a-f]{64}")
|
|
BAKE_LOGS = ("bake.log", "06-bake.log", "bake-clean.log", "06-bake-clean.log")
|
|
|
|
|
|
def arg(name, default):
|
|
for a in sys.argv[1:]:
|
|
if a.startswith(name + "="):
|
|
return a.split("=", 1)[1]
|
|
return default
|
|
|
|
|
|
def parse(text):
|
|
"""(family_gate, excepts or None, min_controller tuple or None) from a .felhom.yml text."""
|
|
gate, excepts, minc = False, None, None
|
|
lines = text.splitlines()
|
|
for i, ln in enumerate(lines):
|
|
if TOP_TRUE.match(ln):
|
|
gate = True
|
|
m = TOP_MINC.match(ln)
|
|
if m:
|
|
minc = tuple(int(x) for x in m.group(1).split("."))
|
|
if TOP_EXCEPT.match(ln):
|
|
excepts = []
|
|
for nxt in lines[i + 1:]:
|
|
if not nxt.strip() or nxt.lstrip().startswith("#"):
|
|
continue
|
|
mi = ITEM.match(nxt)
|
|
if not mi:
|
|
break
|
|
excepts.append(next(g for g in mi.groups()[:3] if g is not None))
|
|
return gate, excepts, minc
|
|
|
|
|
|
def literal_ok(p):
|
|
if not LITERAL.match(p) or "//" in p or "/../" in p or p.endswith("/..") or p.strip("/") == "":
|
|
return False
|
|
return True
|
|
|
|
|
|
def newest_golden(sibling):
|
|
tests = os.path.join(sibling, "documentation", "tests")
|
|
if not os.path.isdir(tests):
|
|
return None
|
|
best = None
|
|
for name in os.listdir(tests):
|
|
m = GOLDEN_DIR.match(name)
|
|
if not m:
|
|
continue
|
|
d = os.path.join(tests, name)
|
|
baked = False
|
|
for log in BAKE_LOGS:
|
|
p = os.path.join(d, log)
|
|
if os.path.isfile(p) and GOLDEN_SHA.search(io.open(p, encoding="utf-8", errors="replace").read()):
|
|
baked = True
|
|
break
|
|
if baked:
|
|
v = tuple(int(x) for x in m.groups())
|
|
best = v if best is None or v > best else best
|
|
return best
|
|
|
|
|
|
def main():
|
|
root = arg("--root", os.getcwd())
|
|
sibling = arg("--felhom-eu", os.path.join(os.path.dirname(os.path.abspath(root)), "felhom.eu"))
|
|
tdir = os.path.join(root, "templates")
|
|
fails, gated, unchecked = [], [], False
|
|
for app in sorted(os.listdir(tdir)):
|
|
fy = os.path.join(tdir, app, ".felhom.yml")
|
|
if not os.path.isfile(fy):
|
|
continue
|
|
gate, excepts, minc = parse(io.open(fy, encoding="utf-8").read())
|
|
if excepts is not None and not gate:
|
|
fails.append("%s: family_gate_except without family_gate: true — an exception list with no gate" % app)
|
|
if not gate:
|
|
continue
|
|
gated.append(app)
|
|
for p in excepts or []:
|
|
if not literal_ok(p):
|
|
fails.append("%s: family_gate_except %r is not a literal path prefix (the controller anchors "
|
|
"^/prefix(/|$) and refuses anything else — F1)" % (app, p))
|
|
if minc is None or minc < MIN_VERSION:
|
|
fails.append("%s: family_gate needs min_controller: \"%d.%d.%d\" or newer (got %s)"
|
|
% ((app,) + MIN_VERSION + (minc,)))
|
|
if gated:
|
|
g = newest_golden(sibling)
|
|
if g is None:
|
|
print("family-gate: rule 3 NOT CHECKED — no felhom.eu sibling with a baked golden at %s" % sibling)
|
|
unchecked = True
|
|
elif g < MIN_VERSION:
|
|
fails.append("family_gate on %s while the newest baked golden is %s — a box installed from it would publish "
|
|
"the app OPEN; bake a golden >= %d.%d.%d first" % ((", ".join(gated), "%d.%d.%d" % g) + MIN_VERSION))
|
|
else:
|
|
print("family-gate: newest baked golden %d.%d.%d >= %d.%d.%d" % (g + MIN_VERSION))
|
|
for f in fails:
|
|
print(" REFUSED " + f)
|
|
if fails:
|
|
print("family-gate gate: %d refusal(s)" % len(fails))
|
|
return 1
|
|
# The summary line carries the gap: an "OK" read alone must not stand for rule 3 when rule 3 was not checked (the
|
|
# 2026-10-02 bench run read exactly that). Exit stays 0 — CI's single clone can never check it; the hook does.
|
|
print("family-gate gate OK: %d family-gated template(s) %s%s" % (len(gated), gated,
|
|
" — rule 3 (golden >= 0.287.0) NOT CHECKED here" if unchecked else ""))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|