diff --git a/CHANGELOG.md b/CHANGELOG.md index 03a4a8f..fb32e34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/scripts/check-copy-i18n.py b/scripts/check-copy-i18n.py index 94cc2fa..f6d6e84 100644 --- a/scripts/check-copy-i18n.py +++ b/scripts/check-copy-i18n.py @@ -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 = [] diff --git a/scripts/test_gate_decoys.py b/scripts/test_gate_decoys.py index e1abe26..0682101 100644 --- a/scripts/test_gate_decoys.py +++ b/scripts/test_gate_decoys.py @@ -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\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,