From 2cdfc1bf5aba598407f1d295ea29b23291d0f4d0 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 14 Oct 2026 15:53:21 +0200 Subject: [PATCH] feat(hydra-gates): gate 120 fails a register version change without an app version change A register change reaches an existing instance only through the app's InitializeSettings repair step, and occ upgrade runs it only when appinfo/info.xml changes. portaliq #1549 and pipelinq #2471 raised the register version and left the app version, so no running instance imported the closed registers. The gate compares each register document's info.version and each register's effective version after the register.d merge against the base. A move without a change fails. Mocks do not count. Without a delta base the gate is not applicable, never a pass. --- .../lib/check_register_version_bump.py | 244 ++++++++++++++++++ .../lib/test_check_register_version_bump.py | 207 +++++++++++++++ .../test_gate120_register_version_scope.sh | 153 +++++++++++ hydra-gates/scripts/run-hydra-gates.sh | 66 +++++ .../gate-acceptance/COVERED-ELSEWHERE.md | 1 + 5 files changed, 671 insertions(+) create mode 100755 hydra-gates/scripts/lib/check_register_version_bump.py create mode 100755 hydra-gates/scripts/lib/test_check_register_version_bump.py create mode 100755 hydra-gates/scripts/lib/test_gate120_register_version_scope.sh 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}` |