diff --git a/hydra-gates/scripts/lib/check_register_version_bump.py b/hydra-gates/scripts/lib/check_register_version_bump.py new file mode 100755 index 00000000..a29a459c --- /dev/null +++ b/hydra-gates/scripts/lib/check_register_version_bump.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Conduction +# SPDX-License-Identifier: EUPL-1.2 +"""gate-120 helper: a register version change needs an app version change. + +An app's register configuration (lib/Settings/_register.json and its +lib/Settings/register.d/*.json fragments) reaches an existing instance only +through the app's InitializeSettings repair step. Nextcloud runs repair steps +on install and on `occ upgrade`, and `occ upgrade` does nothing while +appinfo/info.xml matches the installed version. + +So a PR that raises the register's `info.version` without raising the app's + ships a register change that no existing instance ever imports. +That happened twice in one week of October 2026 (portaliq #1549, pipelinq +#2471): the register was closed to a staff group in the file, and every +running instance kept the open register until a follow-up release PR. + +WHAT COUNTS +----------- +The documents are every lib/Settings/*_register.json and every +lib/Settings/register.d/*.json. Two kinds of version move count: + + * a document's top-level `info.version` differs between the base and the + change (an added document counts when it carries one, a deleted one not); + * a register's EFFECTIVE version differs: `components.registers..version` + resolved the way the settings loaders merge, base documents first, then + register.d in sorted order, the last declaration winning. pipelinq #2471 + raised `pipelinq` from 1.0.0 to 1.1.0 in a NEW register.d fragment that has + no `info.version` at all, so the first kind alone does not see it. + +A mock (`x-openregister.type: mock`) does not count. Mocks are sample data an +administrator imports on demand, never through a repair step. + +THE RULE +-------- + * no base ref -> NOT APPLICABLE (exit 4). This is a delta rule. + * no appinfo/info.xml -> NOT APPLICABLE (exit 4). Not a Nextcloud app. + * register versions moved, app did not -> FAIL, one finding per + document, naming both versions. + * otherwise -> PASS. + +Usage: + check_register_version_bump.py --base [--ref ] + + --ref read the change from a git ref instead of the working tree. + +Output always ends in one of + [gate-120] register-version-needs-app-version: checked N document(s), M version change(s) + [gate-120] register-version-needs-app-version: FAIL: K finding(s) +and the exit status is 0 clean, 1 findings, 4 not applicable, 2 the checker +could not run. A traceback is exit 1 with no summary line, which the runner +reports as WIRING, never as a finding. +""" + +import argparse +import fnmatch +import json +import os +import re +import subprocess +import sys + +INFO_XML = "appinfo/info.xml" +SETTINGS = "lib/Settings" +PATTERNS = ("lib/Settings/*_register.json", "lib/Settings/register.d/*.json") +VERSION_RE = re.compile(r"\s*([^<\s]+)\s*") +TAG = "[gate-120] register-version-needs-app-version" + + +def _git(app, *args): + return subprocess.run(["git", "-c", "safe.directory=*", "-C", app] + list(args), + capture_output=True, text=True) + + +def is_register_document(path): + """True for a path the rule watches. fnmatch's * crosses '/', so depth is checked.""" + for pattern in PATTERNS: + if fnmatch.fnmatch(path, pattern) and path.count("/") == pattern.count("/"): + return True + return False + + +def read_file(app, path, ref=None): + """The file's text at a ref, or in the working tree; None when absent.""" + if ref is None: + full = os.path.join(app, path) + if not os.path.isfile(full): + return None + with open(full, encoding="utf-8") as fh: + return fh.read() + r = _git(app, "show", "%s:%s" % (ref, path)) + if r.returncode != 0: + return None + return r.stdout + + +def list_documents(app, ref=None): + """Every watched path, at a ref or in the working tree.""" + if ref is None: + found = [] + for root, _dirs, files in os.walk(os.path.join(app, SETTINGS)): + for name in files: + rel = os.path.relpath(os.path.join(root, name), app).replace(os.sep, "/") + if is_register_document(rel): + found.append(rel) + return sorted(found) + r = _git(app, "ls-tree", "-r", "--name-only", ref, "--", SETTINGS) + if r.returncode != 0: + raise RuntimeError("git ls-tree %s failed: %s" % (ref, r.stderr.strip())) + return sorted(p for p in r.stdout.splitlines() if is_register_document(p)) + + +def parse(text): + """The document as a dict, {} when absent or not an object. Invalid JSON raises ValueError.""" + if text is None: + return {} + data = json.loads(text) + return data if isinstance(data, dict) else {} + + +def is_mock(data): + marker = data.get("x-openregister") + return isinstance(marker, dict) and marker.get("type") == "mock" + + +def info_version(data): + info = data.get("info") + version = info.get("version") if isinstance(info, dict) else None + return str(version) if version is not None else None + + +def register_versions(data): + """slug -> declared version, for every register the document declares with one.""" + registers = data.get("components", {}).get("registers") if isinstance(data.get("components"), dict) else None + if not isinstance(registers, dict): + return {} + found = {} + for key, register in registers.items(): + if isinstance(register, dict) and register.get("version") is not None: + slug = str(register.get("slug") or key).lower() + found[slug] = str(register["version"]) + return found + + +def merge_order(paths): + """Base documents first, then register.d, each sorted: the loaders' merge order.""" + base = sorted(p for p in paths if "/register.d/" not in p) + fragments = sorted(p for p in paths if "/register.d/" in p) + return base + fragments + + +def app_version(app, ref=None): + text = read_file(app, INFO_XML, ref) + if text is None: + return None + m = VERSION_RE.search(text) + return m.group(1) if m else "" + + +def read_side(app, ref): + """{path: parsed document} for every non-mock watched document on one side.""" + docs = {} + for path in list_documents(app, ref): + try: + data = parse(read_file(app, path, ref)) + except ValueError as e: + raise RuntimeError("%s is not valid JSON: %s" % (path, e)) + if not is_mock(data): + docs[path] = data + return docs + + +def effective_registers(docs): + """slug -> (version, path that set it), merged in the loaders' order.""" + effective = {} + for path in merge_order(list(docs)): + for slug, version in register_versions(docs[path]).items(): + effective[slug] = (version, path) + return effective + + +def changed_versions(app, base, ref=None): + """(documents checked, [finding lines]) for every version that moved.""" + before = read_side(app, base) + after = read_side(app, ref) + changes = [] + + for path in sorted(after): + old = info_version(before.get(path, {})) + new = info_version(after[path]) + if new is not None and old != new: + changes.append("%s: info.version %s -> %s" % (path, old or "(none)", new)) + + old_registers = effective_registers(before) + for slug, (new, path) in sorted(effective_registers(after).items()): + old = old_registers.get(slug, (None, None))[0] + if old != new: + changes.append("register %s: version %s -> %s (%s)" % (slug, old or "(none)", new, path)) + + return sorted(set(before) | set(after)), changes + + +def main(argv): + ap = argparse.ArgumentParser() + ap.add_argument("app") + ap.add_argument("--base") + ap.add_argument("--ref") + args = ap.parse_args(argv) + app = args.app + + if not args.base: + print("%s: NOT APPLICABLE: no base ref, and this rule compares a change against its base." % TAG) + return 4 + + head_app = app_version(app, args.ref) + if head_app is None: + print("%s: NOT APPLICABLE: no %s, so this is not a Nextcloud app." % (TAG, INFO_XML)) + return 4 + + try: + base_app = app_version(app, args.base) + paths, changes = changed_versions(app, args.base, args.ref) + except RuntimeError as e: + print("%s: could not read the change: %s" % (TAG, e)) + return 2 + + print("%s: checked %d document(s), %d version change(s)" % (TAG, len(paths), len(changes))) + for line in changes: + print(" " + line) + + if not changes or base_app != head_app: + if changes: + print(" appinfo/info.xml %s -> %s, so an upgrade imports it" % (base_app, head_app)) + return 0 + + print("%s: FAIL: %d finding(s)" % (TAG, len(changes))) + print(" appinfo/info.xml stays %s. occ upgrade only runs the repair step that imports" % head_app) + print(" the register when changes, so no existing instance will import this register") + print(" change. Raise in appinfo/info.xml in this PR.") + return 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/hydra-gates/scripts/lib/test_check_register_version_bump.py b/hydra-gates/scripts/lib/test_check_register_version_bump.py new file mode 100755 index 00000000..30df431a --- /dev/null +++ b/hydra-gates/scripts/lib/test_check_register_version_bump.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Conduction +# SPDX-License-Identifier: EUPL-1.2 +"""Tests for gate-120's checker (check_register_version_bump.py), rule by rule. + + 1. A REGISTER VERSION RAISED WITHOUT AN APP VERSION FAILS: the portaliq #1549 shape. + 2. RAISED TOGETHER, IT PASSES. + 3. A register.d FRAGMENT'S info.version COUNTS LIKE THE BASE DOCUMENT'S. + 4. A MOCK DOES NOT COUNT: sample data is never imported by a repair step. + 5. A CHANGE THAT LEAVES info.version ALONE PASSES, whatever else it edits. + 6. AN ADDED DOCUMENT WITH A VERSION COUNTS; A DELETED ONE DOES NOT. + 7. NO BASE, OR NO appinfo/info.xml, IS NOT APPLICABLE (exit 4), never PASS. + 8. A DOCUMENT THAT IS NOT JSON IS exit 2, never a pass and never a finding. + 9. ONLY lib/Settings/*_register.json AND register.d/*.json ARE WATCHED. + 10. REF MODE AGREES WITH WORKING-TREE MODE. + 11. A REGISTER'S EFFECTIVE VERSION COUNTS: a new register.d fragment raising + `components.registers..version`, without any info.version, FAILS + (the pipelinq #2471 shape); re-declaring the same version does not. + +Run: python3 scripts/lib/test_check_register_version_bump.py +""" +import atexit +import contextlib +import io +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +import check_register_version_bump as crv # noqa: E402 + + +def git(d, *a): + subprocess.run(["git", "-C", d, "-c", "user.email=f@x.invalid", "-c", "user.name=F", *a], + check=True, capture_output=True) + + +def info_xml(version): + return "\n\n fixture\n %s\n\n" % version + + +def register(version, mock=False, extra=None): + doc = {"openapi": "3.0.0", "info": {"title": "Fixture", "version": version}, + "components": {"registers": {"fixture": {"slug": "fixture"}}}} + if mock: + doc["x-openregister"] = {"type": "mock"} + if extra: + doc["components"]["schemas"] = extra + return json.dumps(doc) + + +def write(d, files): + for path, body in files.items(): + full = os.path.join(d, path) + if body is None: + if os.path.exists(full): + os.remove(full) + continue + os.makedirs(os.path.dirname(full), exist_ok=True) + with open(full, "w", encoding="utf-8") as fh: + fh.write(body) + + +def make_app(base_files, head_files): + """A repo with a `base` commit and the change on top, in the working tree and committed as `head`.""" + d = tempfile.mkdtemp(prefix="gate120-") + atexit.register(shutil.rmtree, d, True) + git(d, "init", "-q") + write(d, base_files) + git(d, "add", "-A") + git(d, "commit", "-qm", "base") + git(d, "branch", "base") + write(d, head_files) + git(d, "add", "-A") + git(d, "commit", "-qm", "change", "--allow-empty") + git(d, "branch", "head") + return d + + +def run_main(argv): + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = crv.main(argv) + return rc, out.getvalue() + + +BASE = {"appinfo/info.xml": info_xml("1.0.0"), + "lib/Settings/fixture_register.json": register("0.96.0")} + + +class RegisterVersionBumpTest(unittest.TestCase): + + def test_register_raised_without_app_version_fails(self): + d = make_app(BASE, {"lib/Settings/fixture_register.json": register("0.97.0")}) + rc, out = run_main([d, "--base", "base"]) + self.assertEqual(1, rc, out) + self.assertIn("FAIL: 1 finding(s)", out) + self.assertIn("lib/Settings/fixture_register.json: info.version 0.96.0 -> 0.97.0", out) + self.assertIn("stays 1.0.0", out) + + def test_raised_together_passes(self): + d = make_app(BASE, {"lib/Settings/fixture_register.json": register("0.97.0"), + "appinfo/info.xml": info_xml("1.0.1")}) + rc, out = run_main([d, "--base", "base"]) + self.assertEqual(0, rc, out) + self.assertIn("checked 1 document(s), 1 version change(s)", out) + + def test_a_fragment_version_counts(self): + base = dict(BASE, **{"lib/Settings/register.d/10-extra.json": json.dumps({"info": {"version": "1"}})}) + d = make_app(base, {"lib/Settings/register.d/10-extra.json": json.dumps({"info": {"version": "2"}})}) + rc, out = run_main([d, "--base", "base"]) + self.assertEqual(1, rc, out) + self.assertIn("register.d/10-extra.json: info.version 1 -> 2", out) + + def test_a_mock_does_not_count(self): + base = dict(BASE, **{"lib/Settings/fixture_mock_register.json": register("0.27.0", mock=True)}) + d = make_app(base, {"lib/Settings/fixture_mock_register.json": register("0.28.0", mock=True)}) + rc, out = run_main([d, "--base", "base"]) + self.assertEqual(0, rc, out) + self.assertIn("0 version change(s)", out) + + def test_an_edit_that_keeps_the_version_passes(self): + d = make_app(BASE, {"lib/Settings/fixture_register.json": register("0.96.0", extra={"thing": {}})}) + rc, out = run_main([d, "--base", "base"]) + self.assertEqual(0, rc, out) + + def test_an_added_document_counts_a_deleted_one_does_not(self): + added = make_app(BASE, {"lib/Settings/other_register.json": register("1.0.0")}) + rc, out = run_main([added, "--base", "base"]) + self.assertEqual(1, rc, out) + self.assertIn("other_register.json: info.version (none) -> 1.0.0", out) + + deleted = make_app(BASE, {"lib/Settings/fixture_register.json": None}) + rc, out = run_main([deleted, "--base", "base"]) + self.assertEqual(0, rc, out) + + def test_no_base_is_not_applicable(self): + d = make_app(BASE, {"lib/Settings/fixture_register.json": register("0.97.0")}) + rc, out = run_main([d]) + self.assertEqual(4, rc, out) + self.assertIn("NOT APPLICABLE", out) + + def test_no_info_xml_is_not_applicable(self): + d = make_app({"lib/Settings/fixture_register.json": register("0.96.0")}, + {"lib/Settings/fixture_register.json": register("0.97.0")}) + rc, out = run_main([d, "--base", "base"]) + self.assertEqual(4, rc, out) + + def test_a_document_that_is_not_json_cannot_be_judged(self): + d = make_app(BASE, {"lib/Settings/fixture_register.json": "{not json"}) + rc, out = run_main([d, "--base", "base"]) + self.assertEqual(2, rc, out) + self.assertNotIn("checked", out) + + def test_only_watched_paths_count(self): + self.assertTrue(crv.is_register_document("lib/Settings/portaliq_register.json")) + self.assertTrue(crv.is_register_document("lib/Settings/register.d/40-portal.json")) + self.assertFalse(crv.is_register_document("lib/Settings/connections.json")) + self.assertFalse(crv.is_register_document("lib/Settings/example-sets/a_register.json")) + self.assertFalse(crv.is_register_document("lib/Settings/register.d/deep/x.json")) + self.assertFalse(crv.is_register_document("tests/fixtures/x_register.json")) + + def test_ref_mode_agrees_with_working_tree_mode(self): + d = make_app(BASE, {"lib/Settings/fixture_register.json": register("0.97.0")}) + work = run_main([d, "--base", "base"]) + ref = run_main([d, "--base", "base", "--ref", "head"]) + self.assertEqual(work, ref) + + def _registers_doc(self, versions, info=None): + doc = {"components": {"registers": {slug: {"slug": slug, "version": v} for slug, v in versions.items()}}} + if info is not None: + doc["info"] = {"version": info} + return json.dumps(doc) + + def test_a_new_fragment_raising_a_register_version_fails(self): + base = {"appinfo/info.xml": info_xml("1.0.0"), + "lib/Settings/pipelinq_register.json": self._registers_doc({"pipelinq": "1.0.0"}, info="1.11.0")} + d = make_app(base, {"lib/Settings/register.d/99-zzz-access.json": self._registers_doc( + {"pipelinq": "1.1.0", "sla": "1.1.0"})}) + rc, out = run_main([d, "--base", "base"]) + self.assertEqual(1, rc, out) + self.assertIn("register pipelinq: version 1.0.0 -> 1.1.0 (lib/Settings/register.d/99-zzz-access.json)", out) + self.assertIn("register sla: version (none) -> 1.1.0", out) + + def test_a_fragment_redeclaring_the_same_register_version_passes(self): + base = {"appinfo/info.xml": info_xml("1.0.0"), + "lib/Settings/pipelinq_register.json": self._registers_doc({"pipelinq": "1.0.0"}, info="1.11.0")} + d = make_app(base, {"lib/Settings/register.d/10-more.json": self._registers_doc({"pipelinq": "1.0.0"})}) + rc, out = run_main([d, "--base", "base"]) + self.assertEqual(0, rc, out) + + def test_a_register_version_raised_in_the_base_document_fails(self): + base = {"appinfo/info.xml": info_xml("1.0.0"), + "lib/Settings/x_register.json": self._registers_doc({"x": "1.0.0"}, info="2.0.0")} + d = make_app(base, {"lib/Settings/x_register.json": self._registers_doc({"x": "1.0.1"}, info="2.0.0")}) + rc, out = run_main([d, "--base", "base"]) + self.assertEqual(1, rc, out) + self.assertIn("register x: version 1.0.0 -> 1.0.1", out) + + +if __name__ == "__main__": + unittest.main(verbosity=1) diff --git a/hydra-gates/scripts/lib/test_gate120_register_version_scope.sh b/hydra-gates/scripts/lib/test_gate120_register_version_scope.sh new file mode 100755 index 00000000..fdaaac74 --- /dev/null +++ b/hydra-gates/scripts/lib/test_gate120_register_version_scope.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: EUPL-1.2 +# +# gate-120 (register-version-needs-app-version): acceptance over a REAL git +# history, through the real runner. The rule-by-rule arms live in +# test_check_register_version_bump.py; this suite pins what only the runner can +# get wrong: the verdict word, the delta base, and a crash read as a finding. +# +# ARM 1 the register's info.version raised, appinfo not: FAIL, +# naming the document and both versions (portaliq #1549) +# ARM 2 both raised in the change: PASS, with the census line (the mock +# is not counted as a document) +# ARM 3 only a mock's version raised: PASS +# ARM 4 no delta base: NOT APPLICABLE, never PASS +# ARM 5 a repo with no lib/Settings: NOT APPLICABLE +# ARM 6 a register document that is not JSON: SKIPPED (wiring), never PASS +# and never a finding + +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +RUNNER="${SCRIPT_DIR}/../run-hydra-gates.sh" + +_fail_n=0 +_ok() { printf ' ok: %s\n' "$1"; } +_bad() { _fail_n=$((_fail_n + 1)); printf ' FAIL: %s\n' "$1"; } + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/gate120-scope.XXXXXXXX")" +trap 'rm -rf "${WORK}"' EXIT +APP="${WORK}/app" +mkdir -p "${APP}/appinfo" "${APP}/lib/Settings" + +_info() { + printf '\n\n fixture\n Fixture\n %s\n\n' "$1" > appinfo/info.xml +} +_register() { # [mock] + local _type='"application"' + [ "${3:-}" = "mock" ] && _type='"mock"' + printf '{"openapi":"3.0.0","info":{"title":"Fixture","version":"%s"},"x-openregister":{"type":%s},"components":{"registers":{"fixture":{"slug":"fixture"}}}}\n' "$2" "${_type}" > "lib/Settings/$1" +} + +cd "${APP}" || exit 1 +git init --quiet . +git config user.email fixture@example.invalid +git config user.name Fixture + +_info 1.0.0 +_register fixture_register.json 0.96.0 +_register fixture_mock_register.json 0.27.0 mock +git add -A && git commit --quiet -m "base" +git branch -M base + +_branch() { git checkout --quiet base && git checkout --quiet -b "$1"; } + +_branch register-only +_register fixture_register.json 0.97.0 +git commit --quiet -am "close the register, forget the app version" + +_branch both +_register fixture_register.json 0.97.0 +_info 1.0.1 +git commit --quiet -am "close the register and raise the app version" + +_branch mock-only +_register fixture_mock_register.json 0.28.0 mock +git commit --quiet -am "more sample data" + +_branch broken +echo '{not json' > lib/Settings/fixture_register.json +git commit --quiet -am "break the register" + +_run() { # [extra runner args...] + local _dir="$1" _b="$2"; shift 2 + git -C "${_dir}" checkout --quiet "${_b}" + mkdir -p "${WORK}/logs-${_b}" + HYDRA_GATE_LOG_DIR="${WORK}/logs-${_b}" bash "${RUNNER}" "$@" "${_dir}" 2>&1 +} + +_verdict() { + printf '%s\n' "$1" | grep -E '^\[gate-120\] register-version-needs-app-version: (PASS|FAIL|WARNING|SKIPPED|NOT APPLICABLE)' | head -1 +} + +echo "-- ARM 1: register raised, app version not: FAIL --" +_out="$(_run "${APP}" register-only --base base)" +_v="$(_verdict "${_out}")" +case "${_v}" in + *FAIL*) _ok "the forgotten app version fails" ;; + *) _bad "expected FAIL, got: ${_v:-}" ;; +esac +if printf '%s\n' "${_out}" | grep -q 'lib/Settings/fixture_register.json: info.version 0.96.0 -> 0.97.0'; then + _ok "the finding names the document and both versions" +else + _bad "the finding does not name lib/Settings/fixture_register.json 0.96.0 -> 0.97.0" +fi + +echo "-- ARM 2: both raised: PASS with the census line --" +_out="$(_run "${APP}" both --base base)" +_v="$(_verdict "${_out}")" +case "${_v}" in + *PASS*) _ok "raising both passes" ;; + *) _bad "expected PASS, got: ${_v:-}" ;; +esac +if printf '%s\n' "${_out}" | grep -q '^\[gate-120\] register-version-needs-app-version: checked 1 document(s), 1 version change(s)'; then + _ok "the census line counts what was compared" +else + _bad "no census line in the PASS output" +fi + +echo "-- ARM 3: only a mock moved: PASS --" +_v="$(_verdict "$(_run "${APP}" mock-only --base base)")" +case "${_v}" in + *PASS*) _ok "a mock's version is not a register change" ;; + *) _bad "expected PASS for a mock-only change, got: ${_v:-}" ;; +esac + +echo "-- ARM 4: no delta base: NOT APPLICABLE --" +_v="$(_verdict "$(HYDRA_GATE_BASE_REF='' _run "${APP}" register-only)")" +case "${_v}" in + *"NOT APPLICABLE"*) _ok "without a base the gate says it compared nothing" ;; + *PASS*) _bad "PASS without a base: a delta rule that judged nothing" ;; + *) _bad "expected NOT APPLICABLE without a base, got: ${_v:-}" ;; +esac + +echo "-- ARM 5: no lib/Settings: NOT APPLICABLE --" +BARE="${WORK}/bare" +mkdir -p "${BARE}/appinfo" "${BARE}/lib/Service" +( + cd "${BARE}" || exit 1 + git init --quiet . && git config user.email f@x.invalid && git config user.name F + _info 1.0.0 + printf ' lib/Service/A.php + git add -A && git commit --quiet -m base && git branch -M main +) +_v="$(_verdict "$(_run "${BARE}" main --base main)")" +case "${_v}" in + *"NOT APPLICABLE"*) _ok "a repo with no register configuration is not applicable" ;; + *) _bad "expected NOT APPLICABLE without lib/Settings, got: ${_v:-}" ;; +esac + +echo "-- ARM 6: unreadable register: SKIPPED (wiring), never PASS or FAIL --" +_v="$(_verdict "$(_run "${APP}" broken --base base)")" +case "${_v}" in + *SKIPPED*) _ok "a document the checker cannot read is wiring, not a verdict" ;; + *) _bad "expected SKIPPED for unreadable JSON, got: ${_v:-}" ;; +esac + +echo +if [ "${_fail_n}" -eq 0 ]; then + echo "gate-120 scope: all arms passed" + exit 0 +fi +echo "gate-120 scope: ${_fail_n} arm(s) failed" +exit 1 diff --git a/hydra-gates/scripts/run-hydra-gates.sh b/hydra-gates/scripts/run-hydra-gates.sh index 0cee968d..1dfb8bf3 100755 --- a/hydra-gates/scripts/run-hydra-gates.sh +++ b/hydra-gates/scripts/run-hydra-gates.sh @@ -13444,6 +13444,72 @@ else _skip 119 "procedure-code" na "this repo has neither lib/ nor src/, so there is no code to count." fi +# --------------------------------------------------------------------------- +# gate-120: register-version-needs-app-version +# +# An app's register configuration (lib/Settings/_register.json and its +# register.d fragments) reaches an existing instance only through the app's +# InitializeSettings repair step, and `occ upgrade` runs repair steps only when +# appinfo/info.xml changes. A PR that raises a register document's +# info.version, or a register's effective version (components.registers. +# .version after the register.d merge), and leaves alone ships a +# register change no running instance imports. Measured: portaliq #1549 and pipelinq #2471, one week of +# October 2026, both closing a register to a staff group, both live-open until +# a follow-up release PR raised the app version. +# +# A DELTA RULE. Without a base there is no change to judge, so it is NOT +# APPLICABLE, never PASS. A mock (`x-openregister.type: mock`) is not counted: +# sample data is imported on demand, never by a repair step. +# --------------------------------------------------------------------------- +if [ -f appinfo/info.xml ] && [ -d lib/Settings ]; then + _rv_log=${HYDRA_GATE_LOG_DIR}/hydra-gate-register-version.log + : > "${_rv_log}" + _rv_helper="${SCRIPT_DIR}/lib/check_register_version_bump.py" + if [ ! -f "${_rv_helper}" ]; then + _skip 120 "register-version-needs-app-version" wiring "check_register_version_bump.py not found at ${_rv_helper}, so register version changes were NOT checked by this run." + elif ! command -v python3 > /dev/null 2>&1; then + _skip 120 "register-version-needs-app-version" wiring "python3 is not on PATH, so register version changes were NOT checked by this run." + elif [ "${HAVE_DELTA_BASE}" != "1" ] || [ -z "${BASE_REF}" ] || [ "${BASE_REF}" = "${_empty_tree:-}" ]; then + _skip 120 "register-version-needs-app-version" na "no delta base, and this rule compares a change against its base. Give the run a base (--base or HYDRA_GATE_BASE_REF) to judge it." + else + set +e + python3 "${_rv_helper}" . --base "${BASE_REF}" > "${_rv_log}" 2>&1 + _rv_rc=$? + set +e + grep -E '^\[gate-120\] register-version-needs-app-version: checked ' "${_rv_log}" 2>/dev/null | tail -1 + case "${_rv_rc}" in + 0) + if _helper_finished "${_rv_log}" '^\[gate-120\] register-version-needs-app-version: checked [0-9]+ document'; then + _pass 120 "register-version-needs-app-version" + else + _skip 120 "register-version-needs-app-version" wiring "check_register_version_bump.py exited 0 without its census line, so the change is UNVERIFIED by this run. See ${_rv_log}." + fi + ;; + 1) + if _helper_finished "${_rv_log}" '^\[gate-120\] register-version-needs-app-version: FAIL: [0-9]+ finding'; then + grep -E '^ ' "${_rv_log}" 2>/dev/null + _rv_n=$(sed -n 's/^\[gate-120\] register-version-needs-app-version: FAIL: \([0-9]*\) finding.*/\1/p' "${_rv_log}" | tail -1) + _fail 120 "register-version-needs-app-version" "${_rv_n:-1} register document(s) changed info.version while appinfo/info.xml did not, so no existing instance imports the change. Raise in this PR. See ${_rv_log}" + else + # A CRASH IS NOT A FINDING. A Python traceback also exits 1. + _rv_why=$(tail -3 "${_rv_log}" 2>/dev/null | tr '\n' ' ' | cut -c1-200) + _skip 120 "register-version-needs-app-version" wiring "check_register_version_bump.py exited 1 without its FAIL summary, so it did not finish and the change is UNVERIFIED by this run. Checker output: ${_rv_why:-}. See ${_rv_log}." + fi + ;; + 4) + _rv_why=$(tail -1 "${_rv_log}" 2>/dev/null | cut -c1-200) + _skip 120 "register-version-needs-app-version" na "${_rv_why:-the checker found nothing to compare}" + ;; + *) + _rv_why=$(tail -3 "${_rv_log}" 2>/dev/null | tr '\n' ' ' | cut -c1-200) + _skip 120 "register-version-needs-app-version" wiring "check_register_version_bump.py exited ${_rv_rc} without a verdict, so the change is UNVERIFIED by this run. Checker output: ${_rv_why:-}. See ${_rv_log}." + ;; + esac + fi +else + _skip 120 "register-version-needs-app-version" na "this repo has no appinfo/info.xml or no lib/Settings, so it ships no register configuration a repair step imports." +fi + # --------------------------------------------------------------------------- # Summary + COVERAGE ACCOUNTING # diff --git a/hydra-gates/scripts/test-fixtures/gate-acceptance/COVERED-ELSEWHERE.md b/hydra-gates/scripts/test-fixtures/gate-acceptance/COVERED-ELSEWHERE.md index e869b018..0c76fffd 100644 --- a/hydra-gates/scripts/test-fixtures/gate-acceptance/COVERED-ELSEWHERE.md +++ b/hydra-gates/scripts/test-fixtures/gate-acceptance/COVERED-ELSEWHERE.md @@ -55,6 +55,7 @@ with the missing half named. | gate-114 | header-action-budget | `test_gate114_header_action_budget_scope.sh` | builds a real two-commit history in a tmpdir. A delta gate cannot be covered by a bundle: the ratchet asks whether a page's header actions bar is LONGER than at the base, and the generic format has no git. ARM 1 plants a sixth button on a page whose base carried five and asserts the WARNING names the page and both counts, plus the `base=5 head=6 delta=+1` line on stdout. ARM 2 is the warning-first pin: the run's own failure count equals its FAIL lines, so the advisory reached neither, and gate-114 never printed a FAIL verdict. ARM 3 pins that a commit touching no manifest reports nothing, though the base already carries a five-button bar. ARM 4 is the anti-widening arm: a page that did not exist at the base is censused (`max=7 on BezwaarDetail`) and NOT ratcheted. ARM 5 pins that a removal is clean and that the negative delta is still printed, so a burn-down can be evidenced. ARM 6 is the load-bearing one: with no delta base the ratchet cannot run, and the run must NAME the half that did not run rather than printing a bare PASS. That is the exact defect gates 52, 68 and 69 carry, measured on dossiq PR #1985 where gate-69 printed PASS over a custom-page increase with its delta base resolved. ARM 7 pins the per-repo promotion path, `HYDRA_GATE_HEADER_ACTION_BUDGET_BLOCKING=1`. | | gate-116 | connections-declaration | `test_gate116_connections_declaration_scope.sh` | builds a real git history in a tmpdir. A delta gate cannot be covered by a bundle, which has no git. ARM 1 plants a duplicate connection key and asserts the WARNING and a log line naming the key and both positions. ARM 2 is the warning-first pin: no FAIL verdict, and the planted branch carries as many FAIL lines as the clean branch. ARM 3 pins inherited debt: a base already carrying a mismatched app id, and a README-only change, is NOT APPLICABLE. ARM 4 is the two-sided rule: renaming the settings section under src/ with the declaration untouched still warns and names `#section-zgw`. ARM 5 is the clean arm, PASS plus the census line on stdout. ARM 6 pins that no delta base is NOT APPLICABLE, never PASS. ARM 7 pins the per-repo promotion, `HYDRA_GATE_CONNECTIONS_DECLARATION_BLOCKING=1`. ARM 8 pins that an app with no declaration is NOT APPLICABLE. The rule-by-rule planted and near-miss arms (schema, app id, duplicate key, anchor defined versus only linked, `section-zgw-extra`, node_modules, invalid JSON, missing Ajv, unsourced schema) are in `test_check_connections_declaration.js`. | | gate-119 | procedure-code | `test_gate119_procedure_code_scope.sh` + `test_check_procedure_code.py` | builds a real git history in a tmpdir. A ratchet against a committed baseline cannot be covered by a bundle, which has no history to lift a baseline in. ARM 1 pins the convention for the PR that adds the gate: no `.procedure-code-baseline.json` is NOT APPLICABLE, never PASS. ARM 2 is equality, PASS with the census line. ARM 3 adds a procedure-named class and FAILS. ARM 4 removes one without lowering the baseline and FAILS naming the number to write. ARM 5 removes and lowers together and PASSES. ARM 6 raises the baseline and FAILS. ARM 7 adds a `lib/Service/Zgw/` file and a `Stuf` name and the count does not move. ARM 8 is a baseline that is not JSON: FAIL, not NOT APPLICABLE. ARM 9 is a config the checker cannot read: SKIPPED (wiring). ARM 10 runs full scope, since the gate is not a delta gate. The rule-by-rule arms (every configured token, class-name matches, whole-word short tokens, the allowlist by word and by path, scope, ref mode agreeing with working-tree mode) are in the Python suite. | +| gate-120 | register-version-needs-app-version | `test_gate120_register_version_scope.sh` + `test_check_register_version_bump.py` | builds a real git history in a tmpdir. A delta rule needs a base and a change, which a bundle does not have. ARM 1 raises `lib/Settings/fixture_register.json` info.version 0.96.0 to 0.97.0 and leaves ``: FAIL, naming the document and both versions. ARM 2 raises both: PASS with the census line. ARM 3 raises only a mock: PASS. ARM 4 has no delta base: NOT APPLICABLE, never PASS. ARM 5 is a repo with no lib/Settings: NOT APPLICABLE. ARM 6 is a register document that is not JSON: SKIPPED (wiring), never a verdict. The rule-by-rule arms (fragments count, a register's effective version raised by a new register.d fragment counts as in pipelinq #2471, an added document counts and a deleted one does not, only watched paths, no info.xml, ref mode agreeing with working-tree mode) are in the Python suite. | | gate-100 | setup-demo-data-first | `test_gate100_setup_demo_first_scope.sh` | real two-commit history. ARM 3 pins that an app declaring NO setup block is not failed — twenty of thirty fleet manifests are in that state, so failing them would block unrelated manifest edits fleet-wide. Adoption is a rollout; the gate holds the line on the ten that exist. | | gate-19 | e2e-coverage | `test_gate19_coverage_credibility.sh` | `test-fixtures/e2e-credibility/{req-inherit,scenario-level,file-level-tag,honest}` — `.github#356`, `#343`, `#345` and the never-false `test.skip` guard | | gate-23 | or-abstraction-anti-patterns | `test_gates_23_33_never_green_over_nothing.sh` | `test-fixtures/gates-23-33/{planted,clean}` |