New-app checklist: reviewed, a gate (onboarding), the wger pilot record, the existing apps' gap page
gates / gates (push) Successful in 2s
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:
@@ -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"}
|
||||
|
||||
@@ -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:]))
|
||||
@@ -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:]))
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user