dc0ab8b2a8
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
1110 lines
70 KiB
Python
1110 lines
70 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""test_gate_decoys.py — can this repo's gates be fooled by a LABEL? (R-421)
|
|
|
|
The same instrument as `felhom.eu/scripts/test_gate_decoys.py`: a decoy is the LABEL without the
|
|
FACT, and a gate that convicts on the label alone — or fails to convict on the fact — is a live hole.
|
|
Every case asserts BOTH directions where it can: the genuine article must pass and the decoy must be
|
|
judged on what it IS, not on what it says.
|
|
|
|
Covered here (the `COVERS` literal is AST-read by `felhom.eu/scripts/decoy_coverage_gate.py`):
|
|
|
|
engine-major — `check-engine-major.py` refuses a database-engine pin that crosses a MAJOR.
|
|
Its label is the version string; its fact is the `image:` line of an engine SERVICE. Decoys a
|
|
real session would produce:
|
|
* the major moves in a COMMENT and in kimai's `serverVersion=11.6.2-MariaDB` env var, while
|
|
the image line stays — must PASS (nothing moved);
|
|
* the APP's own image crosses a major (kimai 2.57 -> 3.0) — must PASS (not an engine);
|
|
* a `mariadb:12.3` string lands in README.md — must PASS (not a template);
|
|
* the engine moves WITHIN its major (11.6 -> 11.8) — must PASS (the rule says MAJOR);
|
|
and the facts:
|
|
* `mariadb:11.6 -> mariadb:12.3` on `kimai-db` — must be REFUSED (exit 1), naming the rule
|
|
and its expiry (R-448);
|
|
* `postgres:16-alpine -> postgres:17-alpine` on `docmost-postgres` — must be REFUSED (the
|
|
eleven PostgreSQL services are covered by NAME MATCH, not by a list);
|
|
* `mariadb:11.6 -> mariadb:lts` — INCONCLUSIVE (exit 2), never 0: a major nobody can read is
|
|
not a pass.
|
|
|
|
HOW. The repo is cloned into a scratch directory; the WORKING-TREE gate is run inside the clone
|
|
(so the file under test is the one being edited, not HEAD's); each case is one commit on top of the
|
|
clone's HEAD and the gate is run with `--range HEAD~1..HEAD`. The real tree is never touched.
|
|
|
|
Run from the repo root: python3 scripts/test_gate_decoys.py
|
|
Exit 0 every decoy judged correctly · 1 a decoy passed or a genuine article was refused.
|
|
"""
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
# ── WHAT THIS FILE COVERS ────────────────────────────────────────────────────────────────────────
|
|
# Read by felhom.eu/scripts/decoy_coverage_gate.py, which AST-parses this literal. A gate named here
|
|
# 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)",
|
|
"probe-matches-compose": "the probe TARGET resolves by exact name, explicit `container`, or a UNIQUE prefix - an ambiguity is refused, not guessed (R-630); the DEGRADED no-PyYAML mode CI actually runs; the port/path moved in a COMMENT, in traefik's loadbalancer label, in "
|
|
"`ports:`/`expose:`, or on a NON-probed service - none of which is where "
|
|
"the app listens; vs a real probe port/path that the app does not answer (R-618)",
|
|
"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)",
|
|
}
|
|
|
|
fails = []
|
|
ran = 0
|
|
|
|
|
|
def sh(args, cwd):
|
|
return subprocess.run(args, cwd=cwd, capture_output=True, text=True)
|
|
|
|
|
|
def make_clone():
|
|
tmp = tempfile.mkdtemp(prefix="catalog-decoys-")
|
|
r = sh(["git", "clone", "-q", "file://" + ROOT, tmp], cwd=ROOT)
|
|
if r.returncode != 0:
|
|
raise SystemExit("clone failed: " + r.stderr)
|
|
sh(["git", "config", "user.email", "decoy@gate.invalid"], cwd=tmp)
|
|
sh(["git", "config", "user.name", "decoy"], cwd=tmp)
|
|
return tmp
|
|
|
|
|
|
def edit(clone, relpath, fn):
|
|
p = os.path.join(clone, relpath)
|
|
os.makedirs(os.path.dirname(p), exist_ok=True) # a case may ADD a file (a new app directory)
|
|
text = io.open(p, encoding="utf-8").read() if os.path.exists(p) else ""
|
|
new = fn(text)
|
|
if new == text:
|
|
raise SystemExit("case did not change %s — the case is broken, not the gate" % relpath)
|
|
with io.open(p, "w", encoding="utf-8") as fh:
|
|
fh.write(new)
|
|
|
|
|
|
def commit(clone, msg):
|
|
sh(["git", "add", "-A"], cwd=clone)
|
|
r = sh(["git", "commit", "-q", "-m", msg], cwd=clone)
|
|
if r.returncode != 0:
|
|
raise SystemExit("commit failed: " + r.stderr)
|
|
|
|
|
|
def reset(clone):
|
|
sh(["git", "reset", "-q", "--hard", "HEAD"], cwd=clone)
|
|
sh(["git", "clean", "-fdq"], cwd=clone) # a case may ADD a file (a steps/ definition)
|
|
|
|
|
|
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
|
|
base = sh(["git", "rev-parse", "HEAD"], cwd=clone).stdout.strip()
|
|
try:
|
|
for relpath, fn in edits:
|
|
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", 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)
|
|
if ok:
|
|
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_contain if m not in out], out[-900:]))
|
|
return out
|
|
finally:
|
|
sh(["git", "reset", "-q", "--hard", base], cwd=clone)
|
|
|
|
|
|
def case_probe(name, clone, edits, expect_rc, must_contain=(), apps=("tandoor", "zipline", "wger",
|
|
"home-assistant")):
|
|
"""The probe gate reads FILES in a checkout, so its cases need `--root` and no commit.
|
|
|
|
Without `--root` the gate would read the REAL repo while the case edits the clone, every case
|
|
would see identical bytes, and every case would pass — the constant-for-measurement shape. The
|
|
app list is passed explicitly for the same reason: a whole-repo run is dominated by the three
|
|
genuine faults and would mask whether THIS case's edit changed anything.
|
|
"""
|
|
global ran
|
|
ran += 1
|
|
base = sh(["git", "rev-parse", "HEAD"], cwd=clone).stdout.strip()
|
|
try:
|
|
for relpath, fn in edits:
|
|
edit(clone, relpath, fn)
|
|
r = sh([sys.executable, os.path.join(ROOT, "scripts", "check-probe-matches-compose.py"),
|
|
"--root=" + clone] + list(apps), cwd=clone)
|
|
out = r.stdout + r.stderr
|
|
ok = r.returncode == expect_rc and all(m in out for m in must_contain)
|
|
if ok:
|
|
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_contain if m not in out], out[-900:]))
|
|
return out
|
|
finally:
|
|
sh(["git", "checkout", "-q", "--", "."], cwd=clone)
|
|
sh(["git", "reset", "-q", "--hard", base], cwd=clone)
|
|
|
|
|
|
def case_probe_noyaml(name, clone, edits, expect_rc, must_contain=(), apps=()):
|
|
"""The same cases with PyYAML SHADOWED OUT — the mode the CI runner actually has.
|
|
|
|
A degraded mode that always passes is worse than no gate, because the summary says OK. So the
|
|
three real faults are re-introduced here too and must still be REFUSED by the line reader.
|
|
"""
|
|
global ran
|
|
ran += 1
|
|
base = sh(["git", "rev-parse", "HEAD"], cwd=clone).stdout.strip()
|
|
nd = noyaml_dir()
|
|
env = dict(os.environ, PYTHONPATH=nd + os.pathsep + os.environ.get("PYTHONPATH", ""))
|
|
try:
|
|
for relpath, fn in edits:
|
|
edit(clone, relpath, fn)
|
|
r = subprocess.run([sys.executable, os.path.join(ROOT, "scripts",
|
|
"check-probe-matches-compose.py"),
|
|
"--root=" + clone] + list(apps),
|
|
cwd=clone, capture_output=True, text=True, env=env)
|
|
out = r.stdout + r.stderr
|
|
ok = r.returncode == expect_rc and all(m in out for m in must_contain)
|
|
if ok:
|
|
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_contain if m not in out], out[-900:]))
|
|
return out
|
|
finally:
|
|
sh(["git", "checkout", "-q", "--", "."], cwd=clone)
|
|
sh(["git", "reset", "-q", "--hard", base], cwd=clone)
|
|
|
|
|
|
def case_copy(name, clone, edits, expect_rc, must_contain=(), extra_args=()):
|
|
"""The copy-i18n gate reads FILES, not commits, so its cases need neither a commit nor a range —
|
|
but they DO need --root, or the gate would read the real repo and judge files nobody edited.
|
|
That is the `constant-for-measurement` decoy shape, and it would make every case below pass."""
|
|
global ran
|
|
ran += 1
|
|
base = sh(["git", "rev-parse", "HEAD"], cwd=clone).stdout.strip()
|
|
try:
|
|
for relpath, fn in edits:
|
|
edit(clone, relpath, fn)
|
|
args = list(extra_args)
|
|
if "--expect-missing" not in args:
|
|
# MEASURE the clone's current coverage and hand it back as the ceiling. The ratchet is
|
|
# not what these cases test — they test the freeze, the structure and the language —
|
|
# and hard-coding a number here would make every case fail the day a batch lands.
|
|
# The ratchet has its own two cases below, in both directions.
|
|
probe = sh([sys.executable, os.path.join(ROOT, "scripts", "check-copy-i18n.py"),
|
|
"--root", clone, "--expect-missing", "-1"], cwd=clone)
|
|
m = re.search(r"(\d+) strings have no English", probe.stdout + probe.stderr)
|
|
if not m:
|
|
fails.append("%s: could not measure the clone's coverage — the case is broken, "
|
|
"not the gate" % name)
|
|
return ""
|
|
args += ["--expect-missing", m.group(1)]
|
|
r = sh([sys.executable, os.path.join(ROOT, "scripts", "check-copy-i18n.py"),
|
|
"--root", clone] + args, cwd=clone)
|
|
out = r.stdout + r.stderr
|
|
if r.returncode == expect_rc and all(m in out for m in must_contain):
|
|
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_contain if m not in out], out[-900:]))
|
|
return out
|
|
finally:
|
|
sh(["git", "checkout", "-q", "--", "."], cwd=clone)
|
|
sh(["git", "clean", "-qfd"], cwd=clone)
|
|
sh(["git", "reset", "-q", "--hard", base], cwd=clone)
|
|
|
|
|
|
NOYAML = os.path.join(tempfile.gettempdir(), "felhom-decoy-noyaml")
|
|
|
|
|
|
def noyaml_dir():
|
|
"""A directory that shadows PyYAML with a module that refuses to import — the CI runner has
|
|
python3 and git and NOTHING else (.gitea/workflows/gates.yml), and the copy gate's first six
|
|
pushes each turned CI red because it imported yaml. These cases pin the degraded mode."""
|
|
os.makedirs(NOYAML, exist_ok=True)
|
|
with io.open(os.path.join(NOYAML, "yaml.py"), "w", encoding="utf-8") as fh:
|
|
fh.write('raise ImportError("no module named yaml (CI-runner simulation)")\n')
|
|
return NOYAML
|
|
|
|
|
|
def case_copy_noyaml(name, clone, edits, expect_rc, must_contain=()):
|
|
"""case_copy with PyYAML made unimportable — i.e. what CI actually runs."""
|
|
global ran
|
|
ran += 1
|
|
base = sh(["git", "rev-parse", "HEAD"], cwd=clone).stdout.strip()
|
|
env = dict(os.environ, PYTHONPATH=noyaml_dir())
|
|
try:
|
|
for relpath, fn in edits:
|
|
edit(clone, relpath, fn)
|
|
r = subprocess.run([sys.executable, os.path.join(ROOT, "scripts", "check-copy-i18n.py"),
|
|
"--root", clone], cwd=clone, capture_output=True, text=True, env=env)
|
|
out = r.stdout + r.stderr
|
|
if r.returncode == expect_rc and all(m in out for m in must_contain):
|
|
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_contain if m not in out], out[-900:]))
|
|
return out
|
|
finally:
|
|
sh(["git", "checkout", "-q", "--", "."], cwd=clone)
|
|
sh(["git", "clean", "-qfd"], cwd=clone)
|
|
sh(["git", "reset", "-q", "--hard", base], cwd=clone)
|
|
|
|
|
|
def swap_image(service, frm, to):
|
|
"""Change ONLY the named service's own image: line — the same per-service discipline as the
|
|
gate, so the case moves the fact and nothing else."""
|
|
def _fn(text):
|
|
out, cur, done = [], None, False
|
|
for line in text.splitlines():
|
|
m = re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line)
|
|
if m:
|
|
cur = m.group(1)
|
|
mi = re.match(r"^(\s+image:\s*)(\S+)\s*$", line)
|
|
if mi and cur == service and mi.group(2) == frm:
|
|
line = mi.group(1) + to
|
|
done = True
|
|
out.append(line)
|
|
if not done:
|
|
raise SystemExit("%s does not carry image %s — fixture drifted" % (service, frm))
|
|
return "\n".join(out) + "\n"
|
|
return _fn
|
|
|
|
|
|
|
|
def cur_image(clone, relpath, service):
|
|
"""The service's current image in the clone — read, never typed (R-663)."""
|
|
sys.path.insert(0, os.path.join(ROOT, "scripts"))
|
|
import ladder as _l
|
|
return _l.images_in(io.open(os.path.join(clone, relpath), encoding="utf-8").read())[service]
|
|
|
|
|
|
def strip_ladder(t):
|
|
"""The template WITHOUT its update_ladder block (and the header comment the writer puts above it).
|
|
The writer appends the block at the END of the file (ladder.append_entry), so everything from its
|
|
first line on goes — a case then builds exactly the ladder it describes, whatever the live catalog
|
|
has recorded since (R-663)."""
|
|
lines = t.splitlines()
|
|
for i, l in enumerate(lines):
|
|
if l.startswith("# update_ladder") or l.startswith("update_ladder:"):
|
|
return "\n".join(lines[:i]).rstrip("\n") + "\n"
|
|
return t
|
|
|
|
|
|
# ── test record (09 §3 decision 13) ────────────────────────────────────────────────────────────
|
|
TR_D1 = "sha256:" + "a" * 64
|
|
TR_D2 = "sha256:" + "b" * 64
|
|
|
|
|
|
def tr_entry(frm, to, digest, verdict="proven", peak=41.0, tight=False, **extra):
|
|
import json as _j
|
|
e = {"from": frm, "to": to, "digest": digest, "verdict": verdict,
|
|
"tested_at": "2026-09-23T22:00:00Z", "harness_version": 2,
|
|
"evidence": "felhom.eu/documentation/audits/night-2026-09-23/apps/x/", "memory_peak_pct": peak,
|
|
"marks": {"files_may_change": False, "needs_person": None, "memory_tight": tight}}
|
|
e.update(extra)
|
|
return " - " + _j.dumps(e)
|
|
|
|
|
|
def tr_append(line, header=True):
|
|
"""Append one entry line. A template that already HAS a ladder (the writer puts it at the end of
|
|
the file) gets the line appended to it; one without gets the block (R-663: navidrome gained a
|
|
ladder on 2026-09-23 night, and a second `update_ladder:` key is its own conviction)."""
|
|
def _fn(t):
|
|
has = any(l.startswith("update_ladder:") for l in t.splitlines())
|
|
block = ("\nupdate_ladder:\n" if header and not has else "") + line + "\n"
|
|
return t.rstrip("\n") + "\n" + block
|
|
return _fn
|
|
|
|
|
|
def case_tr_move(name, clone, edits, expect_rc, must_contain=(), table=None):
|
|
import json as _j
|
|
tf = os.path.join(clone, "..", os.path.basename(clone) + "-digests.json")
|
|
_j.dump(table or {}, open(tf, "w"))
|
|
global ran
|
|
ran += 1
|
|
base = sh(["git", "rev-parse", "HEAD"], cwd=clone).stdout.strip()
|
|
try:
|
|
for relpath, fn in edits:
|
|
edit(clone, relpath, fn)
|
|
commit(clone, name)
|
|
r = sh([sys.executable, os.path.join(ROOT, "scripts", "check-test-record-move.py"),
|
|
"--range", "HEAD~1..HEAD", "--digests-from", tf], cwd=clone)
|
|
out = r.stdout + r.stderr
|
|
ok = r.returncode == expect_rc and all(m in out for m in must_contain)
|
|
print(" %s %-52s rc=%d (expected %d)" % ("ok" if ok else "XX", name, r.returncode, expect_rc))
|
|
if not ok:
|
|
fails.append("%s: rc=%d expected %d; missing %s\n%s" % (
|
|
name, r.returncode, expect_rc, [m for m in must_contain if m not in out], out[-900:]))
|
|
finally:
|
|
sh(["git", "reset", "-q", "--hard", base], cwd=clone)
|
|
os.remove(tf)
|
|
|
|
|
|
def case_tr_static(name, clone, edits, expect_rc, must_contain=(), apps=("navidrome",)):
|
|
global ran
|
|
ran += 1
|
|
try:
|
|
for relpath, fn in edits:
|
|
edit(clone, relpath, fn)
|
|
r = sh([sys.executable, os.path.join(ROOT, "scripts", "check-test-record.py"),
|
|
"--root", clone] + list(apps), cwd=clone)
|
|
out = r.stdout + r.stderr
|
|
ok = r.returncode == expect_rc and all(m in out for m in must_contain)
|
|
print(" %s %-52s rc=%d (expected %d)" % ("ok" if ok else "XX", name, r.returncode, expect_rc))
|
|
if not ok:
|
|
fails.append("%s: rc=%d expected %d; missing %s\n%s" % (
|
|
name, r.returncode, expect_rc, [m for m in must_contain if m not in out], out[-900:]))
|
|
finally:
|
|
reset(clone)
|
|
|
|
|
|
def test_record_cases(clone):
|
|
NC, NF = "templates/navidrome/docker-compose.yml", "templates/navidrome/.felhom.yml"
|
|
# READ, never typed (R-663): the live pin moves with every proven step.
|
|
old = cur_image(clone, NC, "navidrome")
|
|
new = "deluan/navidrome:0.99.1"
|
|
prev = "deluan/navidrome:0.1.0" # an invented older step, for the static cases
|
|
frm, to = {"navidrome": old}, {"navidrome": new}
|
|
move = (NC, swap_image("navidrome", old, new))
|
|
good = tr_entry(frm, to, {"navidrome": TR_D1})
|
|
table = {new: TR_D1}
|
|
print("-- test-record-move: an image move needs its own proven record")
|
|
case_tr_move("FACT: a bare image move, no entry", clone, [move], 1,
|
|
("adds NO update_ladder entry",), table)
|
|
case_tr_move("GENUINE: a proven entry whose digest the registry serves", clone,
|
|
[move, (NF, tr_append(good))], 0, ("0.64.1" if False else "test-record-move gate",), table)
|
|
case_tr_move("FACT: the entry's verdict is failed", clone,
|
|
[move, (NF, tr_append(tr_entry(frm, to, {"navidrome": TR_D1}, verdict="failed")))], 1,
|
|
("not allowed in a ladder",), table)
|
|
case_tr_move("FACT: the registry now serves another digest", clone,
|
|
[move, (NF, tr_append(good))], 1, ("the registry serves",), {new: TR_D2})
|
|
case_tr_move("INCONCLUSIVE: the registry cannot be asked", clone,
|
|
[move, (NF, tr_append(good))], 2, ("could not be asked",), {})
|
|
case_tr_move("DECOY: the entry only in a COMMENT under update_ladder", clone,
|
|
[move, (NF, lambda t: strip_ladder(t).rstrip("\n") + "\nupdate_ladder:\n # " + good.strip() + "\n")], 1,
|
|
("holds no entry",), table)
|
|
case_tr_move("DECOY: the entry only in README.md", clone,
|
|
[move, ("README.md", lambda t: t + "\n" + good + "\n")], 1,
|
|
("adds NO update_ladder entry",), table)
|
|
case_tr_move("FACT: a new move carrying a BACKFILLED entry", clone,
|
|
[move, (NF, tr_append(tr_entry(frm, to, {"navidrome": TR_D1}, backfilled="2026-09-23")))], 1,
|
|
("marked backfilled",), table)
|
|
case_tr_move("FACT: memory_tight and the limit did not move", clone,
|
|
[move, (NF, tr_append(tr_entry(frm, to, {"navidrome": TR_D1}, peak=86.0, tight=True)))], 1,
|
|
("memory_tight",), table)
|
|
case_tr_move("GENUINE: memory_tight WITH the limit raised", clone,
|
|
[move, (NC, lambda t: t.replace("memory: 256M", "memory: 384M")),
|
|
(NF, tr_append(tr_entry(frm, to, {"navidrome": TR_D1}, peak=86.0, tight=True)))], 0,
|
|
(), table)
|
|
case_tr_move("DECOY: a ref moves in a compose COMMENT only", clone,
|
|
[(NC, lambda t: t + "\n# was: deluan/navidrome:0.63.2\n")], 0, (), table)
|
|
print("-- test-record (static): the newest step IS the compose")
|
|
def ladder_of(*lines):
|
|
"""Replace the template's ladder with exactly these entry lines."""
|
|
return lambda t: tr_append("\n".join(lines))(strip_ladder(t))
|
|
head = tr_entry({"navidrome": prev}, frm, {"navidrome": TR_D1})
|
|
case_tr_static("GENUINE: a ladder whose head is the compose", clone, [(NF, ladder_of(head))], 0)
|
|
case_tr_static("FACT: the compose moved past the ladder's head", clone,
|
|
[(NF, ladder_of(head)), move], 1, ("not the ladder's newest step",))
|
|
case_tr_static("FACT: a line that is not one JSON entry", clone,
|
|
[(NF, lambda t: strip_ladder(t) + "\nupdate_ladder:\n - from: x\n")], 1, ("not a one-line JSON entry",))
|
|
case_tr_static("FACT: a gap between steps", clone,
|
|
[(NF, ladder_of(tr_entry({"navidrome": "deluan/navidrome:0.0.1"}, {"navidrome": "deluan/navidrome:0.0.2"}, {"navidrome": TR_D1}), head))],
|
|
1, ("the ladder has a gap",))
|
|
case_tr_static("FACT: a failed verdict sits in the ladder", clone,
|
|
[(NF, ladder_of(tr_entry({"navidrome": prev}, frm, {"navidrome": TR_D1}, verdict="failed")))],
|
|
1, ("not allowed in a ladder",))
|
|
|
|
# ── `09` §6.4 part 5 (v0.268.0 on the box): every step but the newest carries its OWN definition
|
|
# at steps/<StepKey(to)>.yml — the box climbs one step at a time and pins that file. ──────────
|
|
print("-- test-record (static): every intermediate step carries its own definition")
|
|
sys.path.insert(0, os.path.join(ROOT, "scripts"))
|
|
import ladder as _l
|
|
mid = {"navidrome": "deluan/navidrome:0.2.0"}
|
|
two = ladder_of(tr_entry({"navidrome": prev}, mid, {"navidrome": TR_D1}),
|
|
tr_entry(mid, frm, {"navidrome": TR_D1}))
|
|
step_rel = "templates/navidrome/" + _l.step_file(mid)
|
|
meta_rel = "templates/navidrome/" + _l.step_meta_file(mid)
|
|
meta_body = lambda t: _l.strip_ladder_block(io.open(os.path.join(clone, NF), encoding="utf-8").read())
|
|
step_body = lambda t: swap_image("navidrome", old, mid["navidrome"])(io.open(os.path.join(clone, NC), encoding="utf-8").read())
|
|
case_tr_static("FACT: a two-step ladder with NO steps/ file for the first step", clone,
|
|
[(NF, two)], 1, ("has no definition",))
|
|
case_tr_static("GENUINE: a two-step ladder whose first step carries its definition", clone,
|
|
[(NF, two), (step_rel, step_body), (meta_rel, meta_body)], 0)
|
|
case_tr_static("DECOY: the steps/ file has the right NAME and names the head's image", clone,
|
|
[(NF, two), (step_rel, lambda t: io.open(os.path.join(clone, NC), encoding="utf-8").read()), (meta_rel, meta_body)],
|
|
1, ("names",))
|
|
case_tr_static("DECOY: the step's definition sits beside the template under another name", clone,
|
|
[(NF, two), ("templates/navidrome/steps/0.2.0.yml", step_body)], 1, ("has no definition",))
|
|
# R-664: the step's own .felhom.yml
|
|
case_tr_static("FACT: the step has its compose but no .felhom.yml", clone,
|
|
[(NF, two), (step_rel, step_body)], 1, ("R-664",))
|
|
case_tr_static("DECOY: the step's .felhom.yml exists by NAME and holds no healthcheck", clone,
|
|
[(NF, two), (step_rel, step_body), (meta_rel, lambda t: "display_name: Navidrome\n")], 1, ("not a real step file",))
|
|
|
|
# ── `09` §3 decision 52: a RE-TEST — the same tag proved at a NEW digest (from == to). ─────────────
|
|
print("-- test-record: a same-tag re-test (decision 52)")
|
|
TR_D3 = "sha256:" + "c" * 64
|
|
frm_step_rel = "templates/navidrome/" + _l.step_file(frm)
|
|
frm_meta_rel = "templates/navidrome/" + _l.step_meta_file(frm)
|
|
same_step = lambda t: io.open(os.path.join(clone, NC), encoding="utf-8").read()
|
|
rt = lambda dig, dfrom, **kw: tr_entry(frm, frm, {"navidrome": dig}, digest_from={"navidrome": dfrom}, **kw)
|
|
good_rt = rt(TR_D2, TR_D1, box_evidence="felhom.eu/documentation/audits/x/box/")
|
|
with_steps = [(frm_step_rel, same_step), (frm_meta_rel, meta_body)]
|
|
case_tr_static("GENUINE: head + a re-test from its digest, both venues", clone,
|
|
[(NF, ladder_of(head, good_rt))] + with_steps, 0)
|
|
case_tr_static("FACT: a re-test with NO new digest", clone,
|
|
[(NF, ladder_of(head, rt(TR_D1, TR_D1, box_evidence="x")))] + with_steps, 1, ("no new digest",))
|
|
case_tr_static("FACT: a re-test without the box venue", clone,
|
|
[(NF, ladder_of(head, rt(TR_D2, TR_D1)))] + with_steps, 1, ("BOTH venues",))
|
|
case_tr_static("FACT: a re-test FROM a digest the previous entry never tested", clone,
|
|
[(NF, ladder_of(head, rt(TR_D2, TR_D3, box_evidence="x")))] + with_steps, 1, ("starts from the previous entry",))
|
|
case_tr_static("DECOY: a re-test that names digest_from only in its evidence text", clone,
|
|
[(NF, ladder_of(head, tr_entry(frm, frm, {"navidrome": TR_D2}, box_evidence="x", evidence="digest_from sha256:aaaa")))] + with_steps,
|
|
1, ("needs digest_from",))
|
|
rt_live = lambda t: tr_append(rt(TR_D2, TR_D1, box_evidence="felhom.eu/documentation/audits/x/box/"))(t)
|
|
case_tr_move("GENUINE: a re-test whose digest the registry serves now", clone,
|
|
[(NF, rt_live)] + with_steps, 0, ("test-record-move gate",), {old: TR_D2})
|
|
case_tr_move("FACT: a re-test whose digest the registry no longer serves", clone,
|
|
[(NF, rt_live)] + with_steps, 1, ("re-test navidrome",), {old: TR_D3})
|
|
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):
|
|
print("FAIL: scripts/check-engine-major.py is missing — a failure, never a skip")
|
|
return 1
|
|
clone = make_clone()
|
|
try:
|
|
KIMAI = "templates/kimai/docker-compose.yml"
|
|
DOCMOST = "templates/docmost/docker-compose.yml"
|
|
# The engine ref is READ from the clone, not typed: the night of 2026-09-23 moved kimai-db
|
|
# 11.6 -> 11.8 through its own test record, and a literal here broke every case below
|
|
# ("fixture drifted") without a single gate changing. R-663.
|
|
KDB = cur_image(clone, KIMAI, "kimai-db")
|
|
# docmost-postgres too (2026-09-29): it moved 16 -> 18 through its own ladder, and the typed "16-alpine" here
|
|
# made every run fail "fixture drifted" before a single case ran — the same shape as R-663. Read it.
|
|
DMDB = cur_image(clone, DOCMOST, "docmost-postgres")
|
|
DM_MAJ = int(DMDB.split(":")[1].split("-")[0].split(".")[0])
|
|
DM_NEXT = DMDB.replace(":%d" % DM_MAJ, ":%d" % (DM_MAJ + 1), 1)
|
|
# kimai's own image too (2026-09-30): it moved apache-2.57.0 -> 2.67.0 through its ladder, and the typed tag here
|
|
# failed every run "fixture drifted" before a single case ran (R-663's shape a third time). Read it.
|
|
KAPP = cur_image(clone, KIMAI, "kimai")
|
|
_krepo, _ktag = KAPP.rsplit(":", 1)
|
|
_kpre = _ktag[:len(_ktag) - len(_ktag.lstrip("abcdefghijklmnopqrstuvwxyz-"))]
|
|
_kmaj, _kmin = (int(x) for x in _ktag[len(_kpre):].split(".")[:2])
|
|
KAPP_MINOR = "%s:%s%d.%d.0" % (_krepo, _kpre, _kmaj, _kmin + 1)
|
|
KAPP_MAJOR = "%s:%s%d.0.0" % (_krepo, _kpre, _kmaj + 1)
|
|
if not KDB.startswith("mariadb:11."):
|
|
raise SystemExit("kimai-db is %s — the cases below assume a MariaDB 11 line; fixture drifted" % KDB)
|
|
|
|
# ── THE FACTS: these must be refused ─────────────────────────────────────────────────
|
|
out = case("FACT: docmost-postgres major + 1 (read from the clone)", clone,
|
|
[(DOCMOST, lambda t: swap_image("docmost-postgres", DMDB, DM_NEXT)(t))],
|
|
expect_rc=1,
|
|
must_contain=("ENGINE-MAJOR GATE FAILED", "docmost-postgres", "postgres %d -> %d" % (DM_MAJ, DM_MAJ + 1)))
|
|
if "REFUSAL_TEXT" in os.environ:
|
|
print(out)
|
|
case("FACT: docmost-postgres major + 1, alone", clone,
|
|
[(DOCMOST, swap_image("docmost-postgres", DMDB, DM_NEXT))],
|
|
expect_rc=1, must_contain=("docmost-postgres", "postgres %d -> %d" % (DM_MAJ, DM_MAJ + 1), "R-463"))
|
|
# R-469 + R-450 (2026-09-21): a MariaDB major bundled with the app's own bump is the
|
|
# bookstack 0b73e5e shape — two migrations behind one edge — and stays refused.
|
|
case("FACT: kimai-db 11.6 -> 12.3 BUNDLED with the kimai app bump", clone,
|
|
[(KIMAI, lambda t: swap_image("kimai", KAPP, KAPP_MINOR)(
|
|
swap_image("kimai-db", KDB, "mariadb:12.3")(t)))],
|
|
expect_rc=1, must_contain=("IN THE SAME COMMIT as kimai", "OWN EDGE", "R-450"))
|
|
case("FACT: kimai-db mariadb:11.6 -> mariadb:lts (major unreadable)", clone,
|
|
[(KIMAI, swap_image("kimai-db", KDB, "mariadb:lts"))],
|
|
expect_rc=2, must_contain=("INCONCLUSIVE",))
|
|
|
|
# ── THE GENUINE ARTICLES: these must pass ────────────────────────────────────────────
|
|
case("GENUINE: kimai-db mariadb:11.x -> 11.99 (within major)", clone,
|
|
[(KIMAI, swap_image("kimai-db", KDB, "mariadb:11.99"))],
|
|
expect_rc=0, must_contain=("engine-major gate OK",))
|
|
# R-469: the LIFT itself. A MariaDB major ALONE in its template is now permitted, and the
|
|
# gate says so by name rather than passing in silence.
|
|
case("GENUINE: kimai-db mariadb:11.6 -> 12.3 ALONE (the R-469 lift)", clone,
|
|
[(KIMAI, swap_image("kimai-db", KDB, "mariadb:12.3"))],
|
|
expect_rc=0, must_contain=("ALLOWED", "kimai-db", "mariadb 11 -> 12", "R-469"))
|
|
|
|
# ── `09` §3 decision 35 (2026-09-25): a PostgreSQL major passes ONLY with its proven,
|
|
# two-venue, MARKED ladder entry, alone in its commit. The fact is the entry's CONTENT for
|
|
# THIS step — never the word "engine_conversion" somewhere, never one venue.
|
|
DOCMOST_FY = "templates/docmost/.felhom.yml"
|
|
DM, DR = cur_image(clone, DOCMOST, "docmost"), cur_image(clone, DOCMOST, "docmost-redis")
|
|
DPG = cur_image(clone, DOCMOST, "docmost-postgres")
|
|
# Read, never typed (2026-09-29: docmost moved 16 -> 18 and the typed 16 stopped every run). The conversion
|
|
# case moves the CURRENT major one up.
|
|
PG_FROM = int(DPG.split(":")[1].split("-")[0].split(".")[0]); PG_TO = PG_FROM + 1
|
|
PG_TO_REF = DPG.replace(":%d" % PG_FROM, ":%d" % PG_TO, 1)
|
|
def pg_entry(mark=True, box=True, to_pg=None, extra=""):
|
|
e = {"from": {"docmost": DM, "docmost-postgres": DPG, "docmost-redis": DR},
|
|
"to": {"docmost": DM, "docmost-postgres": to_pg or PG_TO_REF, "docmost-redis": DR},
|
|
"digest": {"docmost": "sha256:" + "a" * 64, "docmost-postgres": "sha256:" + "b" * 64, "docmost-redis": "sha256:" + "c" * 64},
|
|
"verdict": "proven", "tested_at": "2026-09-25T20:00:00Z", "harness_version": 4,
|
|
"evidence": "x/bench.json", "box_evidence": "x/box.json" if box else None, "memory_peak_pct": 20.0,
|
|
"marks": {"files_may_change": False, "needs_person": None, "memory_tight": False}}
|
|
if mark:
|
|
e["engine_conversion"] = {"service": "docmost-postgres", "engine": "postgres", "from": PG_FROM, "to": PG_TO}
|
|
return lambda t: t.rstrip("\n") + "\n - " + json.dumps(e) + "\n" + extra
|
|
pg18 = swap_image("docmost-postgres", DPG, PG_TO_REF)
|
|
case("GENUINE: docmost-postgres major+1 ALONE with its proven two-venue MARKED entry", clone,
|
|
[(DOCMOST, pg18), (DOCMOST_FY, pg_entry())],
|
|
expect_rc=0, must_contain=("ALLOWED", "docmost-postgres", "postgres %d -> %d" % (PG_FROM, PG_TO), "decision 35"))
|
|
case("DECOY: the entry is proven on both venues but carries NO conversion mark", clone,
|
|
[(DOCMOST, pg18), (DOCMOST_FY, pg_entry(mark=False))],
|
|
expect_rc=1, must_contain=("NOT PROVEN FOR THIS APP", "engine_conversion None"))
|
|
case("DECOY: the marked entry cites ONE venue only (no box_evidence)", clone,
|
|
[(DOCMOST, pg18), (DOCMOST_FY, pg_entry(box=False))],
|
|
expect_rc=1, must_contain=("NOT PROVEN FOR THIS APP", "BOTH venues"))
|
|
case("DECOY: the mark sits in a COMMENT, the entry has none", clone,
|
|
[(DOCMOST, pg18), (DOCMOST_FY, pg_entry(mark=False, extra='# engine_conversion: {"service": "docmost-postgres", "engine": "postgres", "from": PG_FROM, "to": PG_TO}\n'))],
|
|
expect_rc=1, must_contain=("NOT PROVEN FOR THIS APP",))
|
|
case("DECOY: the marked entry, but the move is BUNDLED with the app's own bump", clone,
|
|
[(DOCMOST, lambda t: swap_image("docmost", DM, DM + "-next")(pg18(t))), (DOCMOST_FY, pg_entry())],
|
|
expect_rc=1, must_contain=("ENGINE-MAJOR GATE FAILED", "docmost-postgres"))
|
|
case("FACT: adventurelog's postgis 16 -> 17 (the postgis family was never judged before)", clone,
|
|
[("templates/adventurelog/docker-compose.yml",
|
|
swap_image("adventurelog-postgres", cur_image(clone, "templates/adventurelog/docker-compose.yml", "adventurelog-postgres"), "postgis/postgis:17-3.5-alpine"))],
|
|
expect_rc=1, must_contain=("adventurelog-postgres", "postgis 16 -> 17"))
|
|
|
|
# ── THE DECOYS: the label moves, the fact does not — these must pass ─────────────────
|
|
def comment_and_env(text):
|
|
# the version string moves in a COMMENT and in kimai's serverVersion env, image untouched
|
|
t = text.replace("serverVersion=11.6.2-MariaDB", "serverVersion=12.3.0-MariaDB")
|
|
return t.replace("# Database: mariadb", "# Database: mariadb (image: mariadb:12.3 soon)")
|
|
case("DECOY: major moves only in a comment + serverVersion env", clone,
|
|
[(KIMAI, comment_and_env)], expect_rc=0, must_contain=("engine-major gate OK",))
|
|
case("DECOY: the APP image crosses a major (kimai 2.57 -> 3.0)", clone,
|
|
[(KIMAI, swap_image("kimai", KAPP, KAPP_MAJOR))],
|
|
expect_rc=0, must_contain=("engine-major gate OK",))
|
|
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, n = re.subn(r'^catalog_since:\s*"?\d{4}-\d{2}-\d{2}"?', 'catalog_since: "%s"' % date, text, count=1, flags=re.M)
|
|
if n == 0: # the FIELD is missing (2026-09-30: "unchanged" is not drift — kimai's date was today)
|
|
raise SystemExit("kimai's .felhom.yml carries no catalog_since — fixture drifted")
|
|
return new
|
|
return _fn
|
|
# The cases below need kimai's catalog_since to be a PAST date at HEAD (2026-09-30: kimai moved that day, so
|
|
# "untouched" and "set to today" were the same text). The throwaway clone gets one fixture commit.
|
|
edit(clone, KIMAI_FY, lambda t: re.sub(r'^catalog_since:.*$', 'catalog_since: "2026-01-01"', t, count=1, flags=re.M))
|
|
commit(clone, "fixture: kimai catalog_since in the past")
|
|
case("FACT: kimai image moves, catalog_since untouched", clone,
|
|
[(KIMAI, swap_image("kimai", KAPP, KAPP_MINOR))],
|
|
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", KAPP, KAPP_MINOR)),
|
|
(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", KAPP, KAPP_MINOR)),
|
|
(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", KAPP, KAPP_MINOR)(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)
|
|
|
|
# ── copy-i18n (R-560): Hungarian frozen, English sound ───────────────────────────────
|
|
PB = "templates/privatebin/.felhom.yml"
|
|
TOTAL = 1032 # every copy string in the catalog, measured on 94bc5febaca2
|
|
PB_EN = 14 # what the genuine block below translates
|
|
|
|
# A CORRECT English block for privatebin — the genuine article every decoy is a twist on.
|
|
GENUINE_EN = """
|
|
i18n:
|
|
en:
|
|
description: "Encrypted note and text sharing"
|
|
app_info:
|
|
tagline: "Encrypted text sharing - the server never sees the content"
|
|
use_cases:
|
|
- 'Share sensitive text safely'
|
|
- 'End-to-end encryption - the server cannot read the content'
|
|
- 'Choose how long it lasts (5 minutes to a year, or never)'
|
|
- 'Delete after reading, automatically'
|
|
- 'Password protection for extra safety'
|
|
first_steps:
|
|
- 'Open paste.DOMAIN in your browser'
|
|
- 'Type your text and select Send'
|
|
- 'Share the link you get - the encryption key is inside the URL'
|
|
deploy_fields:
|
|
- env_var: DOMAIN
|
|
label: "Domain"
|
|
description: "The server domain name"
|
|
- env_var: SUBDOMAIN
|
|
label: "Subdomain"
|
|
description: "The address this app answers on"
|
|
"""
|
|
|
|
def strip_en(t):
|
|
"""Remove an existing English block (and its comment header) — a case must behave the
|
|
same before and after that app's batch lands, or the suite rots on a future push."""
|
|
t = re.sub(r"\n# --- English copy.*\Z", "\n", t, flags=re.S)
|
|
return re.sub(r"\n^i18n:\n.*\Z", "\n", t, flags=re.S | re.M)
|
|
|
|
def add_en(block=GENUINE_EN):
|
|
return lambda t: strip_en(t).rstrip("\n") + "\n" + block
|
|
|
|
def en_with(old_, new_):
|
|
return add_en(GENUINE_EN.replace(old_, new_))
|
|
|
|
# THE FACTS — each must be refused.
|
|
case_copy("FACT: a Hungarian byte changed in a frozen string", clone,
|
|
[(PB, lambda t: t.replace("Titkosított jegyzet és szöveg megosztás",
|
|
"Titkosított jegyzet- és szövegmegosztás"))],
|
|
expect_rc=1, must_contain=("Hungarian CHANGED", "privatebin", "description"))
|
|
case_copy("FACT: a Hungarian first_step removed", clone,
|
|
[(PB, lambda t: t.replace(" - 'Oszd meg a generált linket - a titkosítási kulcs az URL-ben van'\n", ""))],
|
|
expect_rc=1, must_contain=("REMOVED", "first_steps"))
|
|
case_copy("FACT: a NEW app is not in the freeze", clone,
|
|
[("templates/decoyapp/.felhom.yml",
|
|
lambda t: 'display_name: "Decoy"\ndescription: "Uj alkalmazas"\nslug: decoyapp\n')],
|
|
expect_rc=1, must_contain=("not in the freeze", "--add-app"))
|
|
case_copy("FACT: an unknown key inside the English block", clone,
|
|
[(PB, en_with(' description: "Encrypted note and text sharing"',
|
|
' description: "Encrypted note and text sharing"\n docs_url: "https://example.invalid"'))],
|
|
expect_rc=1, must_contain=("unknown key",), extra_args=("--expect-missing", str(TOTAL - PB_EN)))
|
|
case_copy("FACT: an English deploy field with no Hungarian twin", clone,
|
|
[(PB, en_with(" - env_var: DOMAIN", " - env_var: NOSUCHFIELD"))],
|
|
expect_rc=1, must_contain=("no Hungarian twin", "NOSUCHFIELD"))
|
|
case_copy("FACT: an accented Hungarian letter left in the English", clone,
|
|
[(PB, en_with("Share sensitive text safely", "Érzékeny text sharing"))],
|
|
expect_rc=1, must_contain=("accented Hungarian letter",))
|
|
case_copy("FACT: ASCII-only Hungarian left in the English (no accent to find)", clone,
|
|
[(PB, en_with(' description: "The address this app answers on"',
|
|
' description: "Aldomain for the app"'))],
|
|
expect_rc=1, must_contain=("ASCII-only Hungarian", "aldomain"))
|
|
case_copy("FACT: the product begs (\"please\")", clone,
|
|
[(PB, en_with("Type your text and select Send", "Please type your text and select Send"))],
|
|
expect_rc=1, must_contain=("does not beg",))
|
|
case_copy("FACT: an English retrieval promise the Hungarian never made", clone,
|
|
[(PB, en_with("Password protection for extra safety",
|
|
"Deleted notes can still be restored later"))],
|
|
expect_rc=1, must_contain=("retrieval promise",))
|
|
# A credential is a LOGIN, not prose: gokapi's default_creds carries admin / adminadmin.
|
|
GK = "templates/gokapi/.felhom.yml"
|
|
case_copy("FACT: a credential token rewritten in translation", clone,
|
|
[(GK, lambda t: t.rstrip("\n") + """
|
|
i18n:
|
|
en:
|
|
app_info:
|
|
default_creds: "Sign in: administrator / hunter2"
|
|
""")],
|
|
expect_rc=1, must_contain=("credential token",))
|
|
case_copy("FACT: an i18n block for a language the controller does not render", clone,
|
|
[(PB, lambda t: t.rstrip("\n") + "\ni18n:\n de:\n description: \"Verschluesselte Notizen\"\n")],
|
|
expect_rc=1, must_contain=("renders en only",))
|
|
case_copy("FACT: an English list with a different number of steps", clone,
|
|
[(PB, en_with(" - 'Share the link you get - the encryption key is inside the URL'\n", ""))],
|
|
expect_rc=1, must_contain=("a list is replaced",))
|
|
|
|
# THE RATCHET — the one thing --expect-missing does not test for the cases above, so it is
|
|
# tested here explicitly, in BOTH directions. A ceiling that only convicts upwards can be
|
|
# left behind by a push that translated more than it recorded.
|
|
case_copy("FACT: ratchet — fewer strings translated than the ceiling records", clone,
|
|
[(PB, add_en())], expect_rc=1,
|
|
must_contain=("English coverage", "ABOVE"),
|
|
extra_args=("--expect-missing", "0"))
|
|
case_copy("FACT: ratchet — more translated than the ceiling records", clone,
|
|
[(PB, add_en())], expect_rc=1,
|
|
must_contain=("English coverage", "BELOW"),
|
|
extra_args=("--expect-missing", "999999"))
|
|
|
|
# THE GENUINE ARTICLE — must pass.
|
|
case_copy("GENUINE: a correct English block on privatebin", clone,
|
|
[(PB, add_en())], expect_rc=0,
|
|
must_contain=("copy-i18n: OK", "privatebin 14/14"))
|
|
|
|
# The ratchet is a fact about the CATALOG, not about how the gate was invoked. Naming one
|
|
# app must not change the count — the first version of this gate counted coverage only for
|
|
# the apps in scope, so `check-copy-i18n.py privatebin` reported 47 more missing strings
|
|
# than the same tree unscoped, and either number could have been made to "pass".
|
|
case_copy("GENUINE: naming an app does not change the coverage count", clone,
|
|
[(PB, add_en())], expect_rc=0,
|
|
must_contain=("copy-i18n: OK",),
|
|
extra_args=("privatebin",))
|
|
|
|
# DEGRADED MODE — what CI actually runs, because its runner has no PyYAML.
|
|
case_copy_noyaml("FACT(no-yaml): a Hungarian byte changed in a frozen string", clone,
|
|
[(PB, lambda t: t.replace("Titkosított jegyzet és szöveg megosztás",
|
|
"Titkosított jegyzet- és szövegmegosztás"))],
|
|
expect_rc=1, must_contain=("DEGRADED", "no longer in the file", "privatebin"))
|
|
case_copy_noyaml("FACT(no-yaml): a frozen Hungarian line deleted", clone,
|
|
[(PB, lambda t: t.replace(" - 'Oszd meg a generált linket - a titkosítási kulcs az URL-ben van'\n", ""))],
|
|
expect_rc=1, must_contain=("no longer in the file",))
|
|
case_copy_noyaml("FACT(no-yaml): a NEW app is not in the freeze", clone,
|
|
[("templates/decoyapp2/.felhom.yml",
|
|
lambda t: 'display_name: "Decoy"\ndescription: "Uj"\nslug: decoyapp2\n')],
|
|
expect_rc=1, must_contain=("not in the freeze",))
|
|
case_copy_noyaml("GENUINE(no-yaml): the untouched tree passes, and SAYS what it did not check",
|
|
clone, [("README.md", lambda t: t + "\n<!-- decoy: a harmless line -->\n")],
|
|
expect_rc=0,
|
|
must_contain=("DEGRADED", "OK (degraded", "NOT checked here"))
|
|
# The escaped-quote pair: romm's two help_texts whose YAML escapes an inner double quote.
|
|
# The degraded check must find them anyway — if it cannot, it convicts an honest tree.
|
|
case_copy_noyaml("GENUINE(no-yaml): romm's escaped-quote help_texts are still found", clone,
|
|
[("templates/romm/docker-compose.yml", lambda t: t + "\n# decoy comment\n")],
|
|
expect_rc=0, must_contain=("OK (degraded",))
|
|
|
|
# THE DECOYS — the LABEL moves, the FACT does not. Each must pass.
|
|
case_copy("DECOY: Hungarian rewritten inside a YAML COMMENT", clone,
|
|
[(PB, lambda t: t.replace("# --- App info (info page content) ---",
|
|
"# --- Alkalmazas informacio: Titkosított jegyzet MEGVALTOZOTT ---"))],
|
|
expect_rc=0, must_contain=("copy-i18n: OK",))
|
|
case_copy("DECOY: a frozen Hungarian sentence pasted into README.md", clone,
|
|
[("README.md", lambda t: t + "\nTitkositott jegyzet es szoveg megosztas (decoy)\n")],
|
|
expect_rc=0, must_contain=("copy-i18n: OK",))
|
|
case_copy("DECOY: display_name changed - a NAME, never copy", clone,
|
|
[(PB, lambda t: t.replace('display_name: "PrivateBin"', 'display_name: "PrivateBin 2"'))],
|
|
expect_rc=0, must_contain=("copy-i18n: OK",))
|
|
case_copy("DECOY: docs_url changed - configuration, never copy", clone,
|
|
[(PB, lambda t: t.replace("https://github.com/PrivateBin/PrivateBin/wiki",
|
|
"https://example.invalid/wiki"))],
|
|
expect_rc=0, must_contain=("copy-i18n: OK",))
|
|
case_copy("DECOY: Hungarian text added to a docker-compose.yml", clone,
|
|
[("templates/privatebin/docker-compose.yml",
|
|
lambda t: t.replace("services:", "# Titkosított jegyzet és szöveg megosztás\nservices:", 1))],
|
|
expect_rc=0, must_contain=("copy-i18n: OK",))
|
|
|
|
# ── probe-matches-compose (R-618) ────────────────────────────────────────────────────────
|
|
# The FACT is where the app LISTENS inside its container. The LABEL is every other number
|
|
# in the file that looks like a port: the traefik label, `ports:`, `expose:`, a comment,
|
|
# and a sidecar's own healthcheck. A gate that reads any of those would have passed the
|
|
# three templates that stopped a working app on 2026-09-21.
|
|
BS = "templates/bookstack/.felhom.yml"
|
|
BSC = "templates/bookstack/docker-compose.yml"
|
|
|
|
print("\n-- probe-matches-compose: the facts (each MUST be refused)")
|
|
# The three real faults, RE-INTRODUCED rather than read off the tree. Reading them off the
|
|
# tree passed only while the tree was broken; the case would have gone green for the wrong
|
|
# reason the moment R-618 was fixed, which is the `constant-for-measurement` shape again.
|
|
case_probe("FACT: tandoor's real R-618 port fault, re-introduced", clone,
|
|
[("templates/tandoor/.felhom.yml", lambda t: t.replace(" port: 80\n",
|
|
" port: 8080\n"))],
|
|
expect_rc=1, must_contain=("FAIL tandoor", "Nothing listens on 8080"),
|
|
apps=("tandoor",))
|
|
case_probe("FACT: wger's real R-618 port fault, re-introduced", clone,
|
|
[("templates/wger/.felhom.yml", lambda t: t.replace(" port: 8000\n",
|
|
" port: 80\n"))],
|
|
expect_rc=1, must_contain=("FAIL wger", "dials 8000 on loopback"),
|
|
apps=("wger",))
|
|
case_probe("FACT: zipline's real R-618 path fault, re-introduced", clone,
|
|
[("templates/zipline/.felhom.yml",
|
|
lambda t: t.replace('path: "/api/healthcheck"', 'path: "/api/health"'))],
|
|
expect_rc=1, must_contain=("FAIL zipline", "This probe CAN fail on it"),
|
|
apps=("zipline",))
|
|
case_probe("GENUINE: the fixed tree passes", clone, [], expect_rc=0,
|
|
must_contain=("probe-matches-compose: OK",),
|
|
apps=("tandoor", "zipline", "wger"))
|
|
case_probe("FACT: a clean app given a wrong probe port", clone,
|
|
[(BS, lambda t: t.replace("port: 80", "port: 8080"))],
|
|
expect_rc=1, must_contain=("FAIL bookstack", "dials 80 on loopback"),
|
|
apps=("bookstack",))
|
|
case_probe("FACT: api+expect probe given a path the app does not answer", clone,
|
|
[(BS, lambda t: t.replace(" - type: http\n port: 80",
|
|
" - type: api\n port: 80\n"
|
|
" path: /status\n expect:\n"
|
|
" status: 200"))],
|
|
expect_rc=1, must_contain=("FAIL bookstack", "This probe CAN fail on it"),
|
|
apps=("bookstack",))
|
|
|
|
print("-- probe-matches-compose: the decoys (the label moves, the fact does not)")
|
|
case_probe("DECOY: the port moves in a COMMENT in .felhom.yml", clone,
|
|
[(BS, lambda t: t.replace("healthcheck:",
|
|
"# the app listens on 8080 (decoy comment)\nhealthcheck:"))],
|
|
expect_rc=0, must_contain=("probe-matches-compose: OK",), apps=("bookstack",))
|
|
case_probe("DECOY: traefik loadbalancer.server.port changed", clone,
|
|
[(BSC, lambda t: t.replace("loadbalancer.server.port=80",
|
|
"loadbalancer.server.port=9999"))],
|
|
expect_rc=0, must_contain=("probe-matches-compose: OK",), apps=("bookstack",))
|
|
case_probe("DECOY: a published `ports:` mapping changed", clone,
|
|
[(BSC, lambda t: t.replace(" container_name: bookstack\n",
|
|
" container_name: bookstack\n"
|
|
" ports:\n - \"9999:80\"\n", 1))],
|
|
expect_rc=0, must_contain=("probe-matches-compose: OK",), apps=("bookstack",))
|
|
case_probe("DECOY: a NON-probed sidecar's healthcheck port changed", clone,
|
|
[(BSC, lambda t: t.replace(
|
|
'["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]',
|
|
'["CMD", "curl", "-f", "http://127.0.0.1:7777/"]'))],
|
|
expect_rc=0, must_contain=("probe-matches-compose: OK",), apps=("bookstack",))
|
|
case_probe("DECOY: path mismatch on a probe that CANNOT fail on it -> WARN, not FAIL", clone,
|
|
[], expect_rc=0,
|
|
must_contain=("WARN home-assistant", "Harmless TODAY"),
|
|
apps=("home-assistant",))
|
|
|
|
|
|
print("-- probe-matches-compose: the DEGRADED mode CI actually runs (no PyYAML)")
|
|
case_probe_noyaml("DEGRADED: the fixed tree passes, and SAYS it is degraded", clone, [],
|
|
expect_rc=0, must_contain=("mode: DEGRADED", "OK (degraded)"),
|
|
apps=("tandoor", "zipline", "wger"))
|
|
case_probe_noyaml("DEGRADED FACT: tandoor's port fault still refused", clone,
|
|
[("templates/tandoor/.felhom.yml",
|
|
lambda t: t.replace(" port: 80\n", " port: 8080\n"))],
|
|
expect_rc=1, must_contain=("FAIL tandoor", "Nothing listens on 8080"),
|
|
apps=("tandoor",))
|
|
case_probe_noyaml("DEGRADED FACT: wger's port fault still refused", clone,
|
|
[("templates/wger/.felhom.yml",
|
|
lambda t: t.replace(" port: 8000\n", " port: 80\n"))],
|
|
expect_rc=1, must_contain=("FAIL wger",), apps=("wger",))
|
|
case_probe_noyaml("DEGRADED FACT: zipline's path fault still refused", clone,
|
|
[("templates/zipline/.felhom.yml",
|
|
lambda t: t.replace('path: "/api/healthcheck"', 'path: "/api/health"'))],
|
|
expect_rc=1, must_contain=("FAIL zipline", "This probe CAN fail on it"),
|
|
apps=("zipline",))
|
|
case_probe_noyaml("DEGRADED DECOY: traefik label moves, the fact does not", clone,
|
|
[("templates/bookstack/docker-compose.yml",
|
|
lambda t: t.replace("loadbalancer.server.port=80",
|
|
"loadbalancer.server.port=9999"))],
|
|
expect_rc=0, must_contain=("OK (degraded)",), apps=("bookstack",))
|
|
|
|
|
|
print("-- probe-matches-compose: the probe TARGET (R-630)")
|
|
case_probe("FACT: paperless-ngx without its explicit container is refused", clone,
|
|
[("templates/paperless-ngx/.felhom.yml",
|
|
lambda t: t.replace(" container: paperless-webserver\n", ""))],
|
|
expect_rc=1, must_contain=("FAIL paperless-ngx", "resolves to no container"),
|
|
apps=("paperless-ngx",))
|
|
case_probe("FACT: immich's FOUR prefix candidates are an ambiguity, not a pick", clone,
|
|
[("templates/immich/.felhom.yml",
|
|
lambda t: t.replace(" container: immich-server\n", ""))],
|
|
expect_rc=1, must_contain=("FAIL immich", "ambiguous"),
|
|
apps=("immich",))
|
|
case_probe("FACT: an explicit container no service declares is refused", clone,
|
|
[("templates/paperless-ngx/.felhom.yml",
|
|
lambda t: t.replace("container: paperless-webserver",
|
|
"container: paperless-nope"))],
|
|
expect_rc=1, must_contain=("FAIL paperless-ngx", "no service declares"),
|
|
apps=("paperless-ngx",))
|
|
case_probe("GENUINE: both explicit containers resolve", clone, [], expect_rc=0,
|
|
must_contain=("probe-matches-compose: OK",), apps=("paperless-ngx", "immich"))
|
|
case_probe("DECOY: the container name moves in a COMMENT, not the field", clone,
|
|
[("templates/paperless-ngx/.felhom.yml",
|
|
lambda t: t.replace("healthcheck:", "# container: paperless-nope\nhealthcheck:"))],
|
|
expect_rc=0, must_contain=("probe-matches-compose: OK",), apps=("paperless-ngx",))
|
|
# A unique prefix must still resolve WITHOUT the explicit field — the third rule is a
|
|
# narrowing, not a removal. immich with three of its four sidecars renamed leaves exactly
|
|
# one `immich-*` container, which is no longer an ambiguity.
|
|
case_probe("DECOY: a UNIQUE prefix still resolves without the field", clone,
|
|
[("templates/immich/docker-compose.yml",
|
|
lambda t: t.replace("container_name: immich-postgres", "container_name: db-immich")
|
|
.replace("container_name: immich-redis", "container_name: cache-immich")
|
|
.replace("container_name: immich-machine-learning", "container_name: ml-immich")),
|
|
("templates/immich/.felhom.yml",
|
|
lambda t: t.replace(" container: immich-server\n", ""))],
|
|
expect_rc=0, must_contain=("probe-matches-compose: OK",), apps=("immich",))
|
|
case_probe_noyaml("DEGRADED: the explicit container is read without PyYAML too", clone, [],
|
|
expect_rc=0, must_contain=("mode: DEGRADED", "OK (degraded)"),
|
|
apps=("paperless-ngx", "immich"))
|
|
case_probe_noyaml("DEGRADED FACT: removing it still refuses", clone,
|
|
[("templates/paperless-ngx/.felhom.yml",
|
|
lambda t: t.replace(" container: paperless-webserver\n", ""))],
|
|
expect_rc=1, must_contain=("FAIL paperless-ngx",), apps=("paperless-ngx",))
|
|
|
|
test_record_cases(clone)
|
|
|
|
# probe-measured (R-715): the measurement must sit DIRECTLY above setup_done_probe:, with a date and both answers.
|
|
KOMGA = "templates/komga/.felhom.yml"
|
|
def drop_note(t):
|
|
return "\n".join(l for l in t.split("\n") if not (l.startswith("# measured on 9202") and "isClaimed" in l))
|
|
def case_pm(name, edits, expect_rc, must=()):
|
|
global ran
|
|
ran += 1
|
|
try:
|
|
for relpath, fn in edits:
|
|
edit(clone, relpath, fn)
|
|
r = sh([sys.executable, os.path.join(ROOT, "scripts", "check-probe-measured.py"), "--root=" + clone, "komga"], cwd=clone)
|
|
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\n%s" % (name, r.returncode, expect_rc, out[-600:]))
|
|
finally:
|
|
sh(["git", "checkout", "-q", "--", "."], cwd=clone)
|
|
case_pm("probe-measured: genuine komga note passes", [], 0, ("probe-measured: OK",))
|
|
case_pm("probe-measured: note moved into the TAGLINE", [(KOMGA, lambda t: drop_note(t).replace(
|
|
"\ntagline:", "\ntagline:", 1).replace("app_info:\n", "app_info:\n # measured on 9202 2026-09-29: isClaimed false -> true\n", 1))], 1, ("FAIL",))
|
|
case_pm("probe-measured: date but no before/after", [(KOMGA, lambda t: drop_note(t).replace(
|
|
"\nsetup_done_probe:", "\n# measured on 9202 2026-09-29\nsetup_done_probe:"))], 1, ("before/after",))
|
|
case_pm("probe-measured: before/after but no date", [(KOMGA, lambda t: drop_note(t).replace(
|
|
"\nsetup_done_probe:", "\n# measured: isClaimed false -> true\nsetup_done_probe:"))], 1, ("a date",))
|
|
case_pm("probe-measured: 'read upstream' is not a measurement", [(KOMGA, lambda t: drop_note(t).replace(
|
|
"\nsetup_done_probe:", "\n# read upstream 2026-09-29: isClaimed false -> true\nsetup_done_probe:"))], 1, ("measured",))
|
|
|
|
finally:
|
|
shutil.rmtree(clone, ignore_errors=True)
|
|
|
|
onboarding_cases()
|
|
|
|
if fails:
|
|
print()
|
|
for f in fails:
|
|
print("FAIL: %s" % f)
|
|
return 1
|
|
print("\ncatalog gate decoys OK — %d case(s), every label judged on its fact (R-421)" % ran)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|