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:
@@ -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)
|
## 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,
|
actualbudget, claper, docmost, emby, gitea, immich, kimai, komga, onlyoffice, opengist, plant-it,
|
||||||
|
|||||||
@@ -347,12 +347,60 @@ def overlay_strings(ov, hu_meta):
|
|||||||
|
|
||||||
# ── Loading ──────────────────────────────────────────────────────────────────────────────────────
|
# ── 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):
|
def load_yaml(path):
|
||||||
import yaml
|
import yaml
|
||||||
with io.open(path, encoding="utf-8") as fh:
|
with io.open(path, encoding="utf-8") as fh:
|
||||||
return yaml.safe_load(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():
|
def app_dirs():
|
||||||
return sorted(d for d in os.listdir(TEMPLATES)
|
return sorted(d for d in os.listdir(TEMPLATES)
|
||||||
if os.path.isdir(os.path.join(TEMPLATES, d))
|
if os.path.isdir(os.path.join(TEMPLATES, d))
|
||||||
@@ -502,6 +550,28 @@ def main(argv):
|
|||||||
|
|
||||||
fails = []
|
fails = []
|
||||||
apps = app_dirs()
|
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
|
en_missing_total = 0
|
||||||
per_app = []
|
per_app = []
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|||||||
COVERS = {
|
COVERS = {
|
||||||
"engine-major": "the major moved in a comment/env var/README/app image, not on an engine's image: line",
|
"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)",
|
"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 = []
|
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)
|
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):
|
def swap_image(service, frm, to):
|
||||||
"""Change ONLY the named service's own image: line — the same per-service discipline as the
|
"""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."""
|
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,
|
case_copy("GENUINE: naming an app does not change the coverage count", clone,
|
||||||
[(PB, add_en())], expect_rc=0,
|
[(PB, add_en())], expect_rc=0,
|
||||||
must_contain=("copy-i18n: OK",),
|
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.
|
# THE DECOYS — the LABEL moves, the FACT does not. Each must pass.
|
||||||
case_copy("DECOY: Hungarian rewritten inside a YAML COMMENT", clone,
|
case_copy("DECOY: Hungarian rewritten inside a YAML COMMENT", clone,
|
||||||
|
|||||||
Reference in New Issue
Block a user