New-app checklist: reviewed, a gate (onboarding), the wger pilot record, the existing apps' gap page
gates / gates (push) Successful in 2s

NEW-APP-CHECKLIST.md: the reviewer's draft reviewed - 60 rows in 10 groups, each with how/why and a since date;
7 rows added, 16 sharpened, 9 wrong claims fixed. onboarding/_TEMPLATE.md (one line per id), onboarding/wger.md
(the pilot, exempt app, 11 open rows each a register row), onboarding/EXISTING-APPS-GAPS.md (read only, from
scripts/onboarding_gaps.py). Gate onboarding (scripts/check-onboarding.py) in --fast: a template directory not
among the 53 published before 2026-10-01 needs a complete record; decoys in test_gate_decoys.py (16 cases, 5 gate
mutants seen red). CLAUDE.md, REUSE.md 5, README point to it. No template changed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-01 15:20:52 +02:00
parent 6d72c091e0
commit dc0ab8b2a8
15 changed files with 1068 additions and 96 deletions
+7
View File
@@ -23,6 +23,8 @@ Gates, in order (all must pass; **non-zero exit on any failure**):
and its newest step IS the compose's images (runs in CI too)
9. test-record-move git history + the registry for MOVED refs only — an image move adds a PROVEN
ladder entry whose digests the registry still serves (hook; skipped on CI)
10. onboarding static, instant, whole repo — a NEW template directory carries a complete onboarding
record (NEW-APP-CHECKLIST.md; the 53 apps published before 2026-10-01 are exempt by name)
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
@@ -107,6 +109,11 @@ GATES = [
# move must add a PROVEN entry whose digests the registry still serves.
("test-record", "check-test-record.py", True, True, False),
("test-record-move", "check-test-record-move.py", False, True, True),
# 2026-10-01 (operator request): a NEW template directory carries a complete onboarding record —
# onboarding/<app>.md answering every NEW-APP-CHECKLIST.md id, none open, every `done` naming evidence that
# exists. Static, files only, so it is --fast and bites in the hook AND in CI. The 53 apps published before
# the checklist are exempt by name inside the script.
("onboarding", "check-onboarding.py", False, True, False),
]
VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"}
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""check-onboarding.py — a NEW catalog app carries a complete onboarding record (NEW-APP-CHECKLIST.md).
WHY. Operator request 2026-10-01: before a new app is offered, it is mapped and tested against one checklist —
storage, database, the first admin, health checks, memory, updates, mail, the household's text. A checklist that
nothing enforces is a wish; this gate is the enforcement. It reads files only (no network, no containers, no git
history), so it runs in `catalog_gates.py --fast`: the pre-push hook and CI.
THE RULE, for every directory under templates/ that is NOT in EXEMPT:
1. `onboarding/<app>.md` exists, its `app:` line names the app, and its `opened: YYYY-MM-DD` is a real date
on or after CUTOFF and not in the future.
2. It answers every checklist id whose `since` date is on or before `opened:` (an id added later binds only
apps opened after it — the checklist stores the date per id).
3. No answer is `open`.
4. Every `done` names evidence that EXISTS: a non-empty file, or a directory holding at least one non-empty
file. An empty directory is a label, not evidence (R-410: a `mkdir` once turned a release gate green).
5. Every `n/a` carries a reason of at least MIN_REASON_WORDS words.
For an EXEMPT app that has a record anyway (wger, the pilot): the record must be well-formed (known ids, no
duplicate, a valid status, `done` evidence that exists, `n/a` with a reason) — `open` and missing ids are allowed.
And `onboarding/_TEMPLATE.md` must carry every checklist id, so a row added to the checklist cannot be forgotten
in the template a new app copies.
EVIDENCE PATHS are written from the workspace root. `app-catalog-felhom.eu/…` resolves against THIS checkout
(whatever its directory is called — the CI runner checks out into another name). Any other first component
(`felhom.eu/…`) resolves against the checkout's parent directory; if that sibling repository is not there (the CI
runner fetches this repo alone) the path is NOT CHECKED and the count is printed — the pre-push hook on DooPlex has
the sibling and checks it. Same shape as engine-major's shallow-clone skip: said out loud, never silent.
WHY THE 53 ARE LISTED BY NAME and not "new since commit X": the CI runner fetches at --depth 1 and has no history
to diff (R-452), and a list is a fact a reader can check. A directory not on the list is new, whatever its age.
Run from the repo root: python3 scripts/check-onboarding.py [--root=DIR] [--today=YYYY-MM-DD]
Exit 0 all records complete · 1 a record is missing or incomplete · 2 the checklist itself cannot be read.
"""
import datetime
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CATALOG_PREFIX = "app-catalog-felhom.eu/"
CUTOFF = "2026-10-01"
MIN_REASON_WORDS = 4
STATUSES = ("done", "n/a", "open")
# The 53 apps in the catalog on 2026-10-01 (catalog main 9c5eae9 + the checklist commit). They were published
# before the checklist existed; re-testing them is not owed (operator default 2026-10-01, may be reversed). What the
# catalog shows for each is onboarding/EXISTING-APPS-GAPS.md. NEVER add a name here to let a new app through.
EXEMPT = frozenset("""
actualbudget adventurelog audiobookshelf bentopdf bookstack calcom calibre-web claper code-server
crafty-controller docmost emby ghost gitea glance gokapi grafana gramps-web home-assistant homebox homepage
immich jellyfin kimai komga mealie n8n navidrome nextcloud onlyoffice opengist outline paperless-ngx papra
plant-it plex privatebin radarr rallly recipe-importer romm seerr sonarr sparkyfitness tandoor termix
uptime-kuma vaultwarden vikunja wanderer wger wishlist zipline
""".split())
ROW_CHECKLIST = re.compile(r"^\|\s*(\d+\.\d+)\s*\|\s*(\d{4}-\d{2}-\d{2})\s*\|")
ROW_RECORD = re.compile(r"^(\d+\.\d+)\s*\|\s*([^|]*?)\s*\|\s*(.*?)\s*$")
DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
def read_checklist(root):
path = os.path.join(root, "NEW-APP-CHECKLIST.md")
if not os.path.isfile(path):
return None, "NEW-APP-CHECKLIST.md is missing"
ids = {}
for line in open(path, encoding="utf-8"):
m = ROW_CHECKLIST.match(line)
if m:
cid, since = m.group(1), m.group(2)
if cid in ids:
return None, "checklist id %s appears twice" % cid
try:
datetime.date.fromisoformat(since)
except ValueError:
return None, "checklist id %s has a bad since date %r" % (cid, since)
ids[cid] = since
if not ids:
return None, "no `| <id> | <since> |` rows found in NEW-APP-CHECKLIST.md"
return ids, None
def evidence_ok(p):
if os.path.isfile(p):
return os.path.getsize(p) > 0
if os.path.isdir(p):
for dp, _dn, fn in os.walk(p):
for f in fn:
if os.path.getsize(os.path.join(dp, f)) > 0:
return True
return False
def resolve(root, rel, unchecked):
"""Return (abs path or None, problem or None). None, None = not checkable here (counted)."""
rel = rel.strip().strip("`")
if not rel or rel.startswith("/") or ".." in rel.split("/"):
return None, "evidence %r is not a workspace-relative path" % rel
if rel.startswith(CATALOG_PREFIX):
return os.path.join(root, rel[len(CATALOG_PREFIX):]), None
first = rel.split("/", 1)[0]
sib = os.path.join(os.path.dirname(root), first)
if not os.path.isdir(sib):
unchecked.append(rel)
return None, None
return os.path.join(os.path.dirname(root), rel), None
def check_record(root, app, path, checklist, strict, today, unchecked):
probs = []
# A row inside an HTML comment is not an answer: `<!-- … -->` is how a row is set aside, and a commented-out
# `1.4 | done | …` must not count as done (the label without the fact, R-421). Line numbers are kept.
text = re.sub(r"<!--.*?-->", lambda m: "\n" * m.group(0).count("\n"),
open(path, encoding="utf-8").read(), flags=re.S)
head = {}
for line in text.splitlines():
m = re.match(r"^(app|opened):\s*(\S+)\s*$", line)
if m and m.group(1) not in head:
head[m.group(1)] = m.group(2)
if head.get("app") != app:
probs.append("its `app:` line reads %r, not %r" % (head.get("app"), app))
opened = head.get("opened", "")
if not DATE.match(opened):
probs.append("no `opened: YYYY-MM-DD` line")
opened = None
else:
try:
datetime.date.fromisoformat(opened)
except ValueError:
probs.append("`opened: %s` is not a real date" % opened)
opened = None
if strict and opened:
if opened < CUTOFF:
probs.append("`opened: %s` is before the checklist existed (%s) — a new app cannot be opened earlier"
% (opened, CUTOFF))
if opened > today:
probs.append("`opened: %s` is in the future (today %s)" % (opened, today))
seen = {}
for n, line in enumerate(text.splitlines(), 1):
m = ROW_RECORD.match(line)
if not m:
continue
cid, status, rest = m.group(1), m.group(2).lower(), m.group(3)
if cid not in checklist:
probs.append("line %d: id %s is not in the checklist" % (n, cid))
continue
if cid in seen:
probs.append("line %d: id %s answered twice (first on line %d)" % (n, cid, seen[cid]))
continue
seen[cid] = n
if status not in STATUSES:
probs.append("line %d: id %s has status %r — one of done / n/a / open" % (n, cid, status))
elif status == "open":
if strict:
probs.append("id %s is OPEN: %s" % (cid, rest or "(no note)"))
elif status == "n/a":
if len(re.findall(r"[^\W\d_]{2,}", rest)) < MIN_REASON_WORDS:
probs.append("id %s is n/a with no reason of %d+ words: %r" % (cid, MIN_REASON_WORDS, rest))
else: # done
paths = rest.split(" — ", 1)[0]
parts = [p for p in (x.strip() for x in paths.split(" ; ")) if p]
if not parts:
probs.append("id %s is done with no evidence path" % cid)
for p in parts:
ap, why = resolve(root, p, unchecked)
if why:
probs.append("id %s: %s" % (cid, why))
elif ap and not evidence_ok(ap):
probs.append("id %s is done but its evidence %s does not exist (or is empty)" % (cid, p))
if strict and opened:
missing = [c for c, s in sorted(checklist.items(), key=lambda kv: [int(x) for x in kv[0].split(".")])
if s <= opened and c not in seen]
if missing:
probs.append("missing id(s): %s" % ", ".join(missing))
return probs
def main(argv):
root = ROOT
today = datetime.date.today().isoformat()
for a in argv:
if a.startswith("--root="):
root = os.path.abspath(a.split("=", 1)[1])
elif a.startswith("--today="):
today = a.split("=", 1)[1]
elif a in ("--all",):
pass # the runner passes --all through; every template is judged anyway
elif not a.startswith("-"):
pass # app scope is not used: the rule is about the whole templates/ tree
else:
print("unknown option %s" % a)
return 2
checklist, err = read_checklist(root)
if err:
print("ONBOARDING GATE INCONCLUSIVE — %s" % err)
return 2
tdir = os.path.join(root, "templates")
odir = os.path.join(root, "onboarding")
apps = sorted(d for d in os.listdir(tdir) if os.path.isdir(os.path.join(tdir, d)))
new = [a for a in apps if a not in EXEMPT]
problems, unchecked = [], []
tpl = os.path.join(odir, "_TEMPLATE.md")
if not os.path.isfile(tpl):
problems.append(("_TEMPLATE", ["onboarding/_TEMPLATE.md is missing"]))
else:
body = re.sub(r"<!--.*?-->", "", open(tpl, encoding="utf-8").read(), flags=re.S)
have = {m.group(1) for m in (ROW_RECORD.match(l) for l in body.splitlines()) if m}
lack = sorted(set(checklist) - have, key=lambda c: [int(x) for x in c.split(".")])
if lack:
problems.append(("_TEMPLATE", ["onboarding/_TEMPLATE.md lacks checklist id(s): %s" % ", ".join(lack)]))
for app in new:
rec = os.path.join(odir, app + ".md")
if not os.path.isfile(rec):
problems.append((app, ["NEW app with no onboarding record — copy onboarding/_TEMPLATE.md to "
"onboarding/%s.md and answer every id (NEW-APP-CHECKLIST.md)" % app]))
continue
p = check_record(root, app, rec, checklist, True, today, unchecked)
if p:
problems.append((app, p))
exempt_records = []
if os.path.isdir(odir):
for f in sorted(os.listdir(odir)):
name = f[:-3] if f.endswith(".md") else None
if not name or name.startswith("_") or name.isupper() or "-GAPS" in name.upper():
continue
if name not in apps:
problems.append((name, ["onboarding/%s.md names no template directory" % f]))
elif name in EXEMPT:
exempt_records.append(name)
p = check_record(root, name, os.path.join(odir, f), checklist, False, today, unchecked)
if p:
problems.append((name, p))
print("onboarding gate — %d checklist ids; %d template dirs: %d exempt (published before %s), %d new; "
"%d exempt app(s) with a record (shape-checked): %s"
% (len(checklist), len(apps), len(apps) - len(new), CUTOFF, len(new), len(exempt_records),
", ".join(exempt_records) or "none"))
if unchecked:
print(" NOT CHECKED here (sibling repository absent — the pre-push hook on DooPlex checks them): %d path(s)"
% len(unchecked))
for u in unchecked[:10]:
print(" %s" % u)
if problems:
print("ONBOARDING GATE FAILED:")
for app, ps in problems:
for p in ps:
print(" %s: %s" % (app, p))
return 1
print("onboarding gate OK")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+247
View File
@@ -0,0 +1,247 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""onboarding_gaps.py — what the catalog's FILES already show, per checklist group, for the 53 exempt apps.
Writes onboarding/EXISTING-APPS-GAPS.md. READ ONLY: no network, no containers, no re-testing. It answers "which of
the NEW-APP-CHECKLIST.md groups does the catalog already hold a record for, per old app" from what is committed:
the templates, the ladder entries, the fixture tables, FIRST-ADMIN.md, README.md and the 2026-08-02 persistence
sweep. A cell is a signal the files carry, not a measurement made today — "shown" means a file says it, never
that it is still true. This is information, not work (operator default 2026-10-01).
Run from the repo root (PyYAML needed — this is a local report, not a gate):
python3 scripts/onboarding_gaps.py # rewrite onboarding/EXISTING-APPS-GAPS.md
python3 scripts/onboarding_gaps.py --check # exit 1 if the committed page differs from a fresh run
"""
import datetime
import io
import json
import os
import re
import subprocess
import sys
import yaml
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(ROOT, "onboarding", "EXISTING-APPS-GAPS.md")
SWEEP = os.path.join(ROOT, "audits", "persistence-sweep-2026-08-02", "state", "gate.log")
sys.path.insert(0, os.path.join(ROOT, "scripts"))
def exempt():
src = io.open(os.path.join(ROOT, "scripts", "check-onboarding.py"), encoding="utf-8").read()
return sorted(re.search(r'EXEMPT = frozenset\("""(.*?)"""', src, re.S).group(1).split())
def mb(v):
m = re.match(r"^\s*(\d+(?:\.\d+)?)\s*([MG])", str(v or ""))
if not m:
return None
return int(float(m.group(1)) * (1024 if m.group(2) == "G" else 1))
def fixture_apps():
names = set()
for f in ("upgrade_fixtures.py", "upgrade_fixtures_box.py", "upgrade_fixtures_box28.py"):
src = io.open(os.path.join(ROOT, "scripts", f), encoding="utf-8").read()
for block in re.findall(r"FIXTURES(?:28)?(?: = |\.update\()\{(.*?)\n\}", src, re.S):
names |= set(re.findall(r'^\s+"([a-z0-9-]+)":', block, re.M))
return names
def sweep_verdicts():
"""The FIRST run's lists in the 2026-08-02 sweep (53 in scope). BROKEN and UNDETERMINED are named; the rest
of the 53 were CLEAN."""
out = {}
if not os.path.isfile(SWEEP):
return out
text = io.open(SWEEP, encoding="utf-8").read()
first = text.split("of 53 in scope")[0]
sec = None
for line in first.splitlines():
if line.startswith("BROKEN"):
sec = "broken"
elif line.startswith("UNDETERMINED"):
sec = "undetermined"
elif sec and re.match(r"^ ([a-z0-9-]+)(:|$)", line):
out[re.match(r"^ ([a-z0-9-]+)", line).group(1)] = sec
return out
def first_admin_rows():
rows = {}
for line in io.open(os.path.join(ROOT, "FIRST-ADMIN.md"), encoding="utf-8"):
cells = [c.strip() for c in line.strip().strip("|").split("|")]
if len(cells) >= 6 and re.match(r"^\**[a-z0-9-]+\**$", cells[0]) and cells[1][:1].isdigit():
rows[cells[0].strip("*")] = {"class": cells[1], "measured": "**M" in cells[5] or cells[5].startswith("M")}
return rows
def main(argv):
apps = exempt()
fx = fixture_apps()
sweep = sweep_verdicts()
fa = first_admin_rows()
readme = io.open(os.path.join(ROOT, "README.md"), encoding="utf-8").read()
rows, tally = [], {g: 0 for g in range(9)}
notes = {"mem_mismatch": [], "no_limit": [], "maria": [], "pg18": []}
for app in apps:
d = os.path.join(ROOT, "templates", app)
fy_text = io.open(os.path.join(d, ".felhom.yml"), encoding="utf-8").read()
fy = yaml.safe_load(fy_text) or {}
dc = yaml.safe_load(io.open(os.path.join(d, "docker-compose.yml"), encoding="utf-8")) or {}
svcs = dc.get("services") or {}
ai = fy.get("app_info") or {}
res = fy.get("resources") or {}
cell = {}
# 0 Fit
life = (fy.get("lifecycle") or "available")
g0 = life == "available" and bool(ai.get("use_cases")) and "pi_compatible" in res
cell[0] = ("yes" if g0 else "—") + ("" if life == "available" else " (%s)" % life)
# 1 Images, start command, database — the files show the ENGINE rules only
engines, ok1 = [], True
for sn, s in svcs.items():
img = str(s.get("image", ""))
env = s.get("environment") or []
env = env if isinstance(env, list) else ["%s=%s" % kv for kv in env.items()]
if img.startswith("mariadb:"):
engines.append("mariadb")
if not any(str(e).replace(" ", "") in ("MARIADB_AUTO_UPGRADE=1", "MARIADB_AUTO_UPGRADE=\"1\"") for e in env):
ok1 = False
notes["maria"].append(app)
if re.match(r"^(postgres|postgis/postgis|ghcr\.io/immich-app/postgres):", img):
engines.append("pg")
if re.match(r"^postgres:18", img) and any(":/var/lib/postgresql/data" in str(v) for v in s.get("volumes") or []):
ok1 = False
notes["pg18"].append(app)
if ":latest" in img or ":" not in img.split("/")[-1]:
ok1 = False
cell[1] = ("engine rules hold" if engines else "no DB sidecar") if ok1 else "ENGINE RULE BROKEN"
g1 = ok1
# 2 Storage and backup
sv = sweep.get(app, "clean" if sweep else None)
hdd = bool(res.get("needs_hdd"))
g2 = sv == "clean" and (not hdd or "backup" in fy)
cell[2] = "%s%s" % ("sweep %s" % sv if sv else "no sweep",
(", backup classes" if "backup" in fy else ", HDD w/o classes") if hdd else "")
# 3 Accounts and strangers
r = fa.get(app)
mech = [k for k in ("after_install", "setup_gate", "signup_block", "after_setup") if fy.get(k)]
g3 = bool(r and r["measured"])
cell[3] = ("class %s, %s" % (r["class"], "measured" if r["measured"] else "read only") if r else "no row") + \
((" + " + "/".join(mech)) if mech else "")
# 4 Health
all_hc = all(s.get("healthcheck") for s in svcs.values())
probe = bool((fy.get("healthcheck") or {}).get("checks"))
# the probe dials the container named like the stack, or the one its `container:` names (R-630)
targets = {c.get("container") for c in (fy.get("healthcheck") or {}).get("checks") or [] if c.get("container")}
names = {s.get("container_name") for s in svcs.values()}
named = (app in names) if not targets else targets <= names
g4 = all_hc and probe and named
cell[4] = "yes" if g4 else ", ".join(x for x, ok in (("hc missing", all_hc), ("no probe", probe),
("probe container not in compose", named)) if not ok)
# 5 Resources
lims = [mb(((s.get("deploy") or {}).get("resources") or {}).get("limits", {}).get("memory")) for s in svcs.values()]
every = all(lims)
total = sum(x for x in lims if x)
ml = mb(res.get("mem_limit"))
if ml != total:
notes["mem_mismatch"].append("%s (%s vs %d)" % (app, res.get("mem_limit"), total))
if not every:
notes["no_limit"].append(app)
ladder = [json.loads(l.strip()[2:]) for l in fy_text.splitlines() if l.strip().startswith('- {"from"')]
watched = [e for e in ladder if e.get("memory_peak_pct") is not None]
g5 = every and ml == total and bool(watched)
cell[5] = ("watched %.0f%%" % watched[-1]["memory_peak_pct"] if watched else "no watch") + \
("" if ml == total else ", mem_limit≠sum") + ("" if every else ", a service w/o limit")
# 6 Updates
proven = [e for e in ladder if e.get("verdict") == "proven" and not e.get("backfilled")]
g6 = bool(proven) and app in fx
cell[6] = "%d proven step(s)%s" % (len(proven), ", fixture" if app in fx else ", no fixture")
# 7 Mail — the files cannot say whether an app WANTS mail; only whether it is mapped
cell[7] = "smtp mapped" if fy.get("smtp_mapping") else "—"
# 8 Text and listing
en = bool((fy.get("i18n") or {}).get("en"))
txt = all(ai.get(k) for k in ("tagline", "use_cases", "first_steps"))
# README's App Catalog table names an app by display name, so the row is found by its subdomain cell
listed = re.search(r"\|\s*%s\.\*\s*\|" % re.escape(str(fy.get("subdomain", "\0"))), readme) is not None
g8 = en and txt and listed and bool(r)
cell[8] = "yes" if g8 else ", ".join(x for x, ok in (("no en", en), ("app_info gap", txt),
("not in README", listed), ("no FIRST-ADMIN row", bool(r))) if not ok)
for g, v in enumerate((g0, g1, g2, g3, g4, g5, g6, None, g8)):
if v:
tally[g] += 1
rows.append((app, cell))
n = len(apps)
sha = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=ROOT, capture_output=True, text=True).stdout.strip()
L = []
L.append("# EXISTING APPS — what the catalog already shows, per checklist group")
L.append("")
L.append("> Generated by `scripts/onboarding_gaps.py` from committed files (catalog `%s`). **Do not edit by hand.**" % sha)
L.append("> Read only: nothing was re-tested. A cell is what a FILE says, not a measurement made today. The 53 apps")
L.append("> published before the checklist (2026-10-01) are exempt from the onboarding gate; this page is information,")
L.append("> not work (operator default 2026-10-01, may be reversed).")
L.append("")
L.append("## Headline — apps whose files show the group covered (of %d)" % n)
L.append("")
L.append("| group | covered | what \"covered\" means here (the signal read) |")
L.append("|---|---|---|")
defs = [
("0 Fit", "`lifecycle` available, `use_cases` and `pi_compatible` present — licence, telemetry, internet need and phone apps are recorded nowhere"),
("1 Images, start command, DB", "pins clean and the engine rules hold (MariaDB auto-upgrade, PG 18 mount) — entrypoint switches, the production server, migrations and secrets read (1.4–1.9) are recorded for NO app"),
("2 Storage and backup", "the 2026-08-02 persistence sweep read the app CLEAN, and an HDD app carries `backup:` classes — no restore round trip is recorded per app"),
("3 Accounts and strangers", "a FIRST-ADMIN.md row whose source is MEASURED on a box — lock-out (3.6) is recorded only for the R-752 apps"),
("4 Health", "every service has a compose healthcheck, a controller probe exists, the exposed container is named like the stack — no negative control is recorded"),
("5 Resources", "every service limited, `mem_limit` = the sum, and a ladder entry carries a measured memory watch — no first-start-from-birth watch is recorded except immich's"),
("6 Updates", "a proven (not backfilled) ladder step AND an upgrade fixture"),
("7 Mail", "not countable from files: whether an app WANTS mail is not recorded; the mapped count is below"),
("8 Text and listing", "English block, tagline + use_cases + first_steps, listed in README, a FIRST-ADMIN row"),
]
for g, (name, d) in enumerate(defs):
L.append("| %s | %s | %s |" % (name, "—" if g == 7 else "%d / %d" % (tally[g], n), d))
L.append("")
L.append("Mail: %d app(s) carry `smtp_mapping`." % sum(1 for _a, c in rows if c[7] == "smtp mapped"))
L.append("")
L.append("## Found while computing this page")
L.append("")
L.append("- `mem_limit` differs from the sum of the compose limits (REUSE.md §2 says equal): %d — %s."
% (len(notes["mem_mismatch"]), ", ".join(notes["mem_mismatch"]) or "none"))
L.append("- A service with no memory limit: %s." % (", ".join(notes["no_limit"]) or "none"))
L.append("- A MariaDB sidecar without `MARIADB_AUTO_UPGRADE=1`: %s." % (", ".join(notes["maria"]) or "none"))
L.append("- A PostgreSQL 18 data mount at the old path: %s." % (", ".join(notes["pg18"]) or "none"))
L.append("")
L.append("## Per app")
L.append("")
L.append("| app | 0 fit | 1 images/DB | 2 storage | 3 accounts | 4 health | 5 resources | 6 updates | 7 mail | 8 text |")
L.append("|---|---|---|---|---|---|---|---|---|---|")
for app, c in rows:
L.append("| %s | %s |" % (app, " | ".join(c[g] for g in range(9))))
L.append("")
body = "\n".join(L)
if "--check" in argv:
cur = io.open(OUT, encoding="utf-8").read() if os.path.isfile(OUT) else ""
strip = lambda t: re.sub(r"catalog `[0-9a-f]+`", "catalog `X`", t)
if strip(cur) != strip(body):
print("EXISTING-APPS-GAPS.md is stale — run python3 scripts/onboarding_gaps.py")
return 1
print("EXISTING-APPS-GAPS.md is current")
return 0
io.open(OUT, "w", encoding="utf-8").write(body)
print("wrote %s — %d apps; covered per group: %s" % (os.path.relpath(OUT, ROOT), n,
", ".join("%d:%s" % (g, "-" if g == 7 else tally[g]) for g in range(9))))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+3 -1
View File
@@ -62,7 +62,9 @@ class CatalogGatesFastTest(unittest.TestCase):
# (copy-i18n, probe-matches-compose) — the test was red and nobody ran it. Now 9 with the test
# record's two halves; the slow pair stays out of --fast. STALE AGAIN until 2026-09-30: 10 since
# probe-measured joined; the test had been red on main (found by the more-night-apps session).
self.assertEqual(len(mod.GATES), 10)
# 11 since 2026-10-01: onboarding (NEW-APP-CHECKLIST.md), fast and history-free, so it runs in CI too.
self.assertEqual(len(mod.GATES), 11)
self.assertIn("onboarding", [g[0] for g in mod.GATES if g[3] and not g[4]])
self.assertEqual([g[0] for g in mod.GATES if not g[3]], ["image-resolvable", "volume-persistence"])
self.assertIn("test-record", [g[0] for g in mod.GATES if g[3] and not g[4]]) # runs in CI too
# the gates that need git history are the ones the CI half cannot run (R-452's shallow gap)
+135
View File
@@ -55,6 +55,7 @@ COVERS = {
"test-record": "a ladder whose newest step is not the compose's images (a move without a record), a gap, a line that is not one JSON entry, a failed verdict - vs a clean ladder (09 decision 13)",
"test-record-move": "an image move with NO entry, with the entry only in a COMMENT or in README, with a failed/backfilled entry, with a digest the registry no longer serves, memory_tight without a raised limit - vs a proven entry that matches; a ref moving in a compose COMMENT is not a move (09 decision 13)",
"probe-measured": "the measurement written in the TAGLINE or another comment block, not directly above setup_done_probe:; a date with no before/after; before/after with no date; 'read upstream' instead of 'measured' - vs a genuine measured comment (R-715)",
"onboarding": "a NEW template with no record; a record missing an id, or carrying it only inside an HTML comment; a `done` whose path does not exist, is an EMPTY directory (the mkdir shape, R-410) or names an absent sibling-repo file; an `n/a` with an empty or two-word reason; an `open` row; `opened:` backdated before the checklist; the template a new app copies lacking a new id - vs a complete record, an id added after `opened:`, and an exempt app's record with open rows (NEW-APP-CHECKLIST.md)",
"copy-i18n": "Hungarian edited in a COMMENT/README/display_name (label, not copy) vs a real frozen string changed; an English block that is not English, is not matched to a Hungarian twin, or rewrites a credential (R-560). Also the DEGRADED mode CI actually runs — PyYAML shadowed out, freeze only (R-595)",
}
@@ -485,6 +486,138 @@ def test_record_cases(clone):
case_tr_move("FACT: a re-test with no new digest reaches the move gate too", clone,
[(NF, tr_append(rt(TR_D1, TR_D1, box_evidence="x")))] + with_steps, 1, ("no new digest",), {old: TR_D1})
def onboarding_cases():
"""The onboarding gate reads FILES — the checklist, the template, the records, and evidence paths that may live
in a SIBLING repository. So each case runs in its own scratch WORKSPACE: <ws>/app-catalog-felhom.eu (a clone,
with this working tree's checklist + template copied in — the files under test are the ones being edited) and
<ws>/felhom.eu (a stand-in sibling holding one evidence file). The real tree is never touched."""
global ran
ws = tempfile.mkdtemp(prefix="catalog-onboarding-")
cat = os.path.join(ws, "app-catalog-felhom.eu")
sh(["git", "clone", "-q", "file://" + ROOT, cat], cwd=ROOT)
for rel in ("NEW-APP-CHECKLIST.md", os.path.join("onboarding", "_TEMPLATE.md")):
os.makedirs(os.path.dirname(os.path.join(cat, rel)) or cat, exist_ok=True)
shutil.copy(os.path.join(ROOT, rel), os.path.join(cat, rel))
sib = os.path.join(ws, "felhom.eu", "documentation", "audits", "onb")
os.makedirs(sib)
with io.open(os.path.join(sib, "proof.txt"), "w", encoding="utf-8") as fh:
fh.write("measured\n")
shutil.copytree(os.path.join(cat, "templates", "vaultwarden"), os.path.join(cat, "templates", "newapp"))
ev = os.path.join(cat, "onboarding", "evidence", "newapp")
os.makedirs(ev)
with io.open(os.path.join(ev, "e.txt"), "w", encoding="utf-8") as fh:
fh.write("bench output\n")
ids = [m.group(1) for m in (re.match(r"^(\d+\.\d+) \|", l) for l in
io.open(os.path.join(cat, "onboarding", "_TEMPLATE.md"), encoding="utf-8")) if m]
if len(ids) < 50:
raise SystemExit("the template carries %d ids — the fixture drifted, not the gate" % len(ids))
def record(rows=None, opened="2026-10-01", app="newapp"):
rows = rows if rows is not None else ["%s | done | app-catalog-felhom.eu/onboarding/evidence/newapp/e.txt" % i
for i in ids]
return "# Onboarding record\n\napp: %s\nopened: %s\n\n%s\n" % (app, opened, "\n".join(rows))
def full(**over):
out = []
for i in ids:
out.append(over.get(i, "%s | done | app-catalog-felhom.eu/onboarding/evidence/newapp/e.txt" % i))
return [r for r in out if r is not None]
REC = os.path.join(cat, "onboarding", "newapp.md")
def case_onb(name, setup, expect_rc, must=()):
global ran
ran += 1
try:
setup()
r = sh([sys.executable, os.path.join(ROOT, "scripts", "check-onboarding.py"), "--root=" + cat,
"--today=2026-10-02"], cwd=cat)
out = r.stdout + r.stderr
if r.returncode == expect_rc and all(m in out for m in must):
print(" ok %-52s rc=%d (expected %d)" % (name, r.returncode, expect_rc))
else:
fails.append("%s: rc=%d expected %d; missing %s\n%s" % (
name, r.returncode, expect_rc, [m for m in must if m not in out], out[-900:]))
finally:
for f in (REC, os.path.join(cat, "onboarding", "wger.md")):
if os.path.exists(f):
os.remove(f)
shutil.copy(os.path.join(ROOT, "NEW-APP-CHECKLIST.md"), os.path.join(cat, "NEW-APP-CHECKLIST.md"))
shutil.copy(os.path.join(ROOT, "onboarding", "_TEMPLATE.md"), os.path.join(cat, "onboarding", "_TEMPLATE.md"))
empty = os.path.join(cat, "onboarding", "evidence", "hollow")
if os.path.isdir(empty):
shutil.rmtree(empty)
def put(text, path=REC):
def f():
with io.open(path, "w", encoding="utf-8") as fh:
fh.write(text)
return f
try:
print("\n-- onboarding: the facts (each MUST be refused)")
case_onb("FACT: a new template with NO record", lambda: None, 1, ("newapp: NEW app with no onboarding record",))
case_onb("FACT: a record missing id 1.4", put(record(full(**{"1.4": None}))), 1, ("missing id(s): 1.4",))
case_onb("FACT: 1.4 answered only inside an HTML comment",
put(record(full(**{"1.4": "<!--\n1.4 | done | app-catalog-felhom.eu/onboarding/evidence/newapp/e.txt\n-->"}))),
1, ("missing id(s): 1.4",))
case_onb("FACT: done with a path that does not exist",
put(record(full(**{"2.5": "2.5 | done | app-catalog-felhom.eu/onboarding/evidence/newapp/restore.txt"}))),
1, ("id 2.5 is done but its evidence", "restore.txt"))
def hollow():
os.makedirs(os.path.join(cat, "onboarding", "evidence", "hollow"))
put(record(full(**{"5.1": "5.1 | done | app-catalog-felhom.eu/onboarding/evidence/hollow"})))()
case_onb("FACT: done with an EMPTY directory (the mkdir shape)", hollow, 1, ("id 5.1 is done but its evidence",))
case_onb("FACT: done naming an absent file in the sibling repo",
put(record(full(**{"3.6": "3.6 | done | felhom.eu/documentation/audits/onb/lockout.txt"}))),
1, ("id 3.6 is done but its evidence",))
case_onb("FACT: n/a with an EMPTY reason", put(record(full(**{"7.1": "7.1 | n/a | "}))), 1, ("id 7.1 is n/a",))
case_onb("FACT: n/a with a two-word reason", put(record(full(**{"7.1": "7.1 | n/a | not needed"}))), 1, ("id 7.1 is n/a",))
case_onb("FACT: an OPEN row", put(record(full(**{"6.3": "6.3 | open | the forced-fail case is not run yet"}))),
1, ("id 6.3 is OPEN",))
case_onb("FACT: opened: backdated before the checklist", put(record(full(), opened="2026-09-01")),
1, ("before the checklist existed",))
def new_id_template_lacks():
t = io.open(os.path.join(cat, "NEW-APP-CHECKLIST.md"), encoding="utf-8").read()
t = t.replace("\n## 7. Mail", "\n| 6.9 | 2026-10-01 | a new check | how | why |\n\n## 7. Mail", 1)
io.open(os.path.join(cat, "NEW-APP-CHECKLIST.md"), "w", encoding="utf-8").write(t)
put(record(full() + ["6.9 | done | app-catalog-felhom.eu/onboarding/evidence/newapp/e.txt"]))()
case_onb("FACT: a checklist id the template a new app copies lacks", new_id_template_lacks, 1,
("_TEMPLATE.md lacks checklist id(s): 6.9",))
case_onb("FACT: an exempt app's record with a done that points nowhere",
lambda: (put(record(full()))(), put(record(["1.5 | done | app-catalog-felhom.eu/nowhere.txt"],
app="wger"), os.path.join(cat, "onboarding", "wger.md"))()),
1, ("wger: id 1.5 is done but its evidence",))
print("-- onboarding: the genuine articles (each MUST pass)")
case_onb("GENUINE: a complete record (catalog + sibling evidence)",
put(record(full(**{"3.6": "3.6 | done | felhom.eu/documentation/audits/onb/proof.txt — measured on 9202",
"7.1": "7.1 | n/a | the app sends no mail at all"}))), 0, ("onboarding gate OK",))
def later_id():
t = io.open(os.path.join(cat, "NEW-APP-CHECKLIST.md"), encoding="utf-8").read()
t = t.replace("\n## 7. Mail", "\n| 6.9 | 2026-11-01 | a later check | how | why |\n\n## 7. Mail", 1)
io.open(os.path.join(cat, "NEW-APP-CHECKLIST.md"), "w", encoding="utf-8").write(t)
tp = os.path.join(cat, "onboarding", "_TEMPLATE.md")
io.open(tp, "a", encoding="utf-8").write("6.9 | open | not started: a later check\n")
put(record(full()))()
case_onb("GENUINE: an id added AFTER opened: does not bind", later_id, 0, ("onboarding gate OK",))
case_onb("GENUINE: an exempt app's record may say open",
lambda: (put(record(full()))(), put(record(["1.5 | open | the dev server runs (R-755)"], app="wger"),
os.path.join(cat, "onboarding", "wger.md"))()),
0, ("exempt app(s) with a record (shape-checked): wger",))
def no_sibling():
shutil.move(os.path.join(ws, "felhom.eu"), os.path.join(ws, "felhom.eu.away"))
put(record(full(**{"3.6": "3.6 | done | felhom.eu/documentation/audits/onb/lockout.txt"})))()
try:
case_onb("STATED SKIP: sibling repo absent (the CI shape) - printed, not checked", no_sibling, 0,
("NOT CHECKED here", "felhom.eu/documentation/audits/onb/lockout.txt"))
finally:
if os.path.isdir(os.path.join(ws, "felhom.eu.away")):
shutil.move(os.path.join(ws, "felhom.eu.away"), os.path.join(ws, "felhom.eu"))
finally:
shutil.rmtree(ws, ignore_errors=True)
def main():
gate = os.path.join(ROOT, "scripts", "check-engine-major.py")
if not os.path.isfile(gate):
@@ -961,6 +1094,8 @@ i18n:
finally:
shutil.rmtree(clone, ignore_errors=True)
onboarding_cases()
if fails:
print()
for f in fails: