GATE FIX: the copy gate could not run in CI at all (R-595)
gates / gates (push) Successful in 1s

Six pushes in a row turned CI red while the local pre-push hook was green. The alarm
mail's own text says what that means and that it outranks the push it interrupted.

Cause, found by contrast rather than by reading a log: check-copy-i18n.py imports
PyYAML and is the ONLY gate in this repo importing anything outside the standard
library. The workflow's own header says the runner is "a host-mode container with
python3 and git and nothing else". The gate raised ImportError before checking
anything, so catalog_gates.py exited non-zero on every push, clean ones included.

The fix is a DEGRADED MODE, not a skip: without PyYAML the gate runs the check that
needs no parser and matters most — every frozen Hungarian string must still occur
verbatim in its app's bytes — and then prints in full what it did NOT check. Same
division catalog_gates.py already uses for engine-major on a shallow clone.

Measured before claimed: 1 030 of 1 032 frozen strings appear byte-for-byte in the raw
files; the two that do not are romm help_texts whose YAML escapes an inner double
quote, so the escaped spelling is accepted too. 1 032 of 1 032 found, so the degraded
check convicts nothing honest.

Five new decoy cases run with PyYAML shadowed by a module that refuses to import —
what CI actually executes. 38 cases in total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-20 18:28:27 +02:00
parent de392cd7b8
commit 18a6d2d824
3 changed files with 163 additions and 2 deletions
+31
View File
@@ -1,3 +1,34 @@
## GATE FIX: the copy gate could not run in CI at all (2026-09-20, R-595)
**Six pushes in a row turned CI red while the local pre-push hook was green**, and each sent an alarm
mail whose own text says what that means: *"If the local pre-push hook was GREEN for this commit, then
CI and the hook disagree — that is a finding about the gates themselves, not about CI, and it
outranks whatever the push was for."* It did.
**The cause, found by contrast rather than by reading a log:** `check-copy-i18n.py` imports PyYAML,
and it is the ONLY gate in this repo that imports anything outside the standard library.
`.gitea/workflows/gates.yml` says in its own header that the runner is *"a host-mode container with
python3 and git and nothing else"*. So the gate raised `ImportError` before it checked anything, and
`catalog_gates.py` exited non-zero — on every push, including the ones that were perfectly clean.
**The fix is a DEGRADED MODE, not a skip.** Without PyYAML the gate now runs the check that needs no
parser and matters most: **every frozen Hungarian string must still occur verbatim in its app's
`.felhom.yml` bytes.** A changed, reworded or deleted Hungarian string fails in CI exactly as it does
locally. It then prints, in full, what it did NOT check — the English block's structure, its language,
its credential tokens and the coverage ratchet — all of which run in the pre-push hook, which is where
they refuse a push anyway. That is the same division `catalog_gates.py` already uses for engine-major
on a shallow clone.
**Measured before it was claimed:** 1 030 of the 1 032 frozen strings appear byte-for-byte in the raw
files. The two that do not are romm help_texts whose YAML source escapes an inner double quote, so the
escaped spelling is accepted too — with that, 1 032 of 1 032 are found, so the degraded check convicts
nothing honest.
**Five new decoy cases**, run with PyYAML shadowed by a module that refuses to import — i.e. what CI
actually executes: a changed Hungarian byte, a deleted Hungarian line and an unfrozen new app each
convict; an untouched tree passes AND says what it did not check; and romm's two escaped-quote
help_texts are still found. 38 cases in total.
## English copy — BATCH 3 of 3: the last 16 apps. THE CATALOG IS TRANSLATED. (2026-09-20, R-560 slice 5)
actualbudget, claper, docmost, emby, gitea, immich, kimai, komga, onlyoffice, opengist, plant-it,
+70
View File
@@ -347,12 +347,60 @@ def overlay_strings(ov, hu_meta):
# ── Loading ──────────────────────────────────────────────────────────────────────────────────────
def have_yaml():
"""PyYAML is NOT on the CI runner, and that is deliberate, not an oversight.
`.gitea/workflows/gates.yml` says the runner is "a host-mode container with python3 and git and
nothing else", and every gate written before this one uses only the standard library. This gate
did not, so its first six pushes each turned CI red and sent an alarm mail while the local
pre-push hook was green — the exact disagreement that mail warns about, and it outranks whatever
the push was for. See DEGRADED MODE below for what runs instead."""
try:
import yaml # noqa: F401
return True
except ImportError:
return False
def load_yaml(path):
import yaml
with io.open(path, encoding="utf-8") as fh:
return yaml.safe_load(fh)
def freeze_only_check(freeze, apps, fails):
"""DEGRADED MODE — the freeze, checked without a YAML parser.
It asserts the ONE property that matters most and needs no parse: every frozen Hungarian string
still occurs VERBATIM in that app's `.felhom.yml` bytes. A changed, reworded or deleted Hungarian
string fails here exactly as it does in full mode.
What it CANNOT do, stated rather than implied: it cannot see WHERE a string sits, so a string
moved to another field passes; and it cannot read the English block at all, so structure,
language, credentials and the coverage ratchet are not checked. Those run in the pre-push hook,
which is where they bite anyway — the same division `catalog_gates.py` already uses for
engine-major on a shallow clone.
MEASURED 2026-09-20: 1 030 of the 1 032 frozen strings appear byte-for-byte in the raw file. The
other two are romm help_texts whose YAML source escapes an inner double quote, so the escaped
form is accepted too — with that, 1 032 of 1 032 are found. The variant is NOT a loosening: it
is the same characters, written the way YAML requires inside a double-quoted scalar.
"""
for app in apps:
frozen = (freeze.get("apps") or {}).get(app)
if frozen is None:
fails.append("%s: not in the freeze. A NEW app's Hungarian has never been reviewed — "
"run `python3 scripts/check-copy-i18n.py --add-app %s --reason \"…\"` in "
"the same commit." % (app, app))
continue
raw = io.open(os.path.join(TEMPLATES, app, ".felhom.yml"), encoding="utf-8").read()
for path, val in sorted(frozen.items()):
if val in raw or val.replace('"', '\\"') in raw:
continue
fails.append("%s: the frozen Hungarian at %s is no longer in the file: %r"
% (app, path, val))
def app_dirs():
return sorted(d for d in os.listdir(TEMPLATES)
if os.path.isdir(os.path.join(TEMPLATES, d))
@@ -502,6 +550,28 @@ def main(argv):
fails = []
apps = app_dirs()
# DEGRADED MODE. Without PyYAML the file cannot be parsed, so the structure, language,
# credential and coverage checks cannot run — but the FREEZE can, and it is the check that
# protects the customer-facing Hungarian. Running it and saying loudly what did not run beats
# both alternatives: exiting 2 would paint CI red on every push (and "inconclusive is never a
# pass" would make that permanent), and exiting 0 would be a gate that reports a pass it never
# measured.
if not have_yaml():
print("copy-i18n: DEGRADED - PyYAML is absent on this machine, so only the HUNGARIAN\n"
" FREEZE runs (%d apps). NOT checked here: the English block's\n"
" structure, its language, its credential tokens, and the coverage\n"
" ratchet - those run in the pre-push hook, which is where they\n"
" refuse a push anyway." % len(apps))
freeze_only_check(freeze, apps, fails)
if fails:
print("\ncopy-i18n: FAIL — %d finding(s)\n" % len(fails))
for f in fails:
print(" - %s" % f)
return 1
print("copy-i18n: OK (degraded — freeze only)")
return 0
en_missing_total = 0
per_app = []
+62 -2
View File
@@ -48,7 +48,7 @@ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
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)",
"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)",
"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 = []
@@ -156,6 +156,44 @@ def case_copy(name, clone, edits, expect_rc, must_contain=(), extra_args=()):
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."""
@@ -367,7 +405,29 @@ i18n:
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", "--expect-missing", str(TOTAL - PB_EN)))
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,