diff --git a/.github/workflows/version-bump-gate.yml b/.github/workflows/version-bump-gate.yml index 1a8bfbf..a568408 100644 --- a/.github/workflows/version-bump-gate.yml +++ b/.github/workflows/version-bump-gate.yml @@ -162,16 +162,21 @@ name: Version bump gate # GITHUB_TOKEN, which has no access to it. Handing five repos a cross-repo PAT # to run a PR check costs more than the drift it would close, so the caller # declares the values instead and this job echoes both into the summary on -# every run, where a wrong one is visible rather than silent. Keeping them in -# step is a two-file edit, exactly like repos.yml vs repo-inventory.yml's -# `release_train` flag. Measured from repos.yml@main on 2026-08-06, the five -# version_file repos and their exact values are: +# every run, where a wrong one is visible rather than silent. That echo was not +# enough on its own: measured 2026-09-01, five of the six version_file repos' +# callers had drifted NARROWER than repos.yml, each dropping a published path +# the gate then stopped watching, and a summary is only seen by whoever opens +# the run (backend#2953). # -# tracebloc-py-package pyproject.toml tracebloc/* pyproject.toml -# cli VERSION cmd/* internal/* go.mod go.sum VERSION -# design-system package.json src/* -# data-ingestors tracebloc_ingestor/__init__.py tracebloc_ingestor/* -# client client/Chart.yaml client/* +# THE CROSS-CHECK THAT CLOSES THAT lives where both sides can be read at once, +# which is NOT here: tracebloc/.github's caller-drift audit runs under an +# org-wide-read PAT that can see the private repos.yml, DERIVES each repo's +# version-file/publish-paths from it, and fails when a caller disagrees +# (scripts/caller-drift.py, `version_bump_input_findings`). Keeping the caller +# in step is still a two-file edit, but a wrong one is now caught rather than +# merely echoed. A per-repo table used to sit here; it was itself a hand-copy +# that went stale, so it is gone rather than refreshed - the authority is +# repos.yml and the guard reads it directly. # # OUT OF SCOPE, on purpose # version_preflight also refuses a PRERELEASE version on a prod hop. That is a diff --git a/repo-inventory.yml b/repo-inventory.yml index 97e25fc..6daa68e 100644 --- a/repo-inventory.yml +++ b/repo-inventory.yml @@ -206,6 +206,14 @@ caller_inputs: code-quality.yml: soft-fail: false action-pins-soft-fail: false + # version-bump-gate.yml's `soft-fail` defaults TRUE (annotate-only): a caller + # that drops `soft-fail: false` reverts the gate to advisory, and the + # caller-drift check that derives version-file/publish-paths from repos.yml + # does NOT compare soft-fail -- so without this row nothing would catch that + # revert anywhere (@LukasWodka on .github#447). Measured green: all six + # version-bump-gate callers pass `soft-fail: false` on develop. + version-bump-gate.yml: + soft-fail: false protection_policy: develop: @@ -334,7 +342,10 @@ shared_reasons: version_gate_not_applicable: &version_gate_not_applicable >- This repo has no version_file in release-train/repos.yml, so it publishes no tag-versioned artifact through the train and the version-bump gate does - not apply. The five version-published repos declare it required. + not apply. The repos that DO declare a version_file mark it required, and + caller-drift derives their version-file/publish-paths from repos.yml + (backend#2953) - no count is restated here, so this reason cannot drift as + that set grows. wip_limit_check_has_no_callers: &wip_limit_check_has_no_callers >- wip-limit-check has ZERO callers anywhere in the org (measured 2026-08-03, all 20 active diff --git a/scripts/caller-drift.py b/scripts/caller-drift.py index b2dd143..b1dd5f4 100755 --- a/scripts/caller-drift.py +++ b/scripts/caller-drift.py @@ -197,6 +197,10 @@ ) HTTP_STATUS = re.compile(r"\(HTTP (\d{3})\)") +# The reusable whose caller inputs are derived from repos.yml rather than trusted +# (backend#2953). Matches the `reusables` list and the `?P` group of ORG_USES. +VERSION_BUMP_GATE = "version-bump-gate.yml" + # The repo that holds the authoritative release-train membership list. TRAIN_REPO = "release-train" TRAIN_FILE = "repos.yml" @@ -1063,8 +1067,34 @@ def list_active_repos(org: str) -> "dict[str, dict]": return active -def load_release_train(org: str) -> "set[str]": - """Authoritative train membership: release-train/repos.yml (RFC-BACKEND-0008 D14).""" +def load_release_train(org: str) -> "dict[str, dict]": + """Authoritative train membership: release-train/repos.yml (RFC-BACKEND-0008 D14). + + Returns {name: {"version_file": str|None, "publish_paths": str|None}}. The KEYS + are the membership set the audit reads as `name in train`; the values carry the + two fields the version-bump-gate caller must not disagree with (backend#2953). + + THOSE TWO FIELDS ARE READ HERE AND HELD NOWHERE ELSE. repos.yml is the single + source of `version_file`/`publish_paths`; the version-bump-gate caller in each + repo restates them, and five of six had drifted NARROWER than the source, so a + PR touching only an unwatched published path shipped green. The audit derives + the expected values from this read and compares -- the env-vocabulary-agreement + shape the issue names, holding no copy of its own. The reusable itself cannot do + this: repos.yml is PRIVATE and a reusable runs with the caller repo's token + (see version-bump-gate.yml's header), which is why the check lives in this + privileged audit rather than in the gate. + + RELATION TO release-train's OWN CHECK. `release-train/scripts/publish-inventory-check.sh` + (family 3, release-train#147 / backend#2953) compares the same caller + `publish-paths`/`version-file` against repos.yml, but on release-train's + weekly cron. That one is the periodic sweep FROM THE TRAIN'S SIDE; this one is + the PR-time gate FROM THE FLEET-AUDIT SIDE (it blocks a contract PR at open, + not a week later). They are deliberately redundant across vantage points, not + a second authority: repos.yml is the single source both read, so neither can + disagree with it without the other catching the same drift. Keep them in step + -- a change to what "agreement" means here belongs in both (@LukasWodka on + .github#447). + """ for ref in ("develop", None): path = f"repos/{org}/{TRAIN_REPO}/contents/{TRAIN_FILE}" if ref: @@ -1089,14 +1119,38 @@ def load_release_train(org: str) -> "set[str]": f"{TRAIN_REPO}/{TRAIN_FILE} has no non-empty `repos:` list. An empty " "train list would silently clear release_train for every repo." ) - names = set() + members: "dict[str, dict]" = {} for item in entries: if not isinstance(item, dict) or not item.get("name"): die(f"{TRAIN_REPO}/{TRAIN_FILE}: entry {item!r} has no `name`.") - names.add(item["name"]) - return names + name = item["name"] + version_file = item.get("version_file") + publish_paths = item.get("publish_paths") + # A non-string/empty `version_file` is a malformed source -- fail closed. + # A `version_file` with NO `publish_paths`, by contrast, is repos.yml's + # own SANCTIONED strict mode ("Omit the key to keep the strict behaviour: + # absent is never permission to skip the version guard" -- its + # data-ingestors/cli blocks), NOT a malformed source. It is carried + # through here and reported as a PER-REPO finding by + # version_bump_input_findings, so that one repo's run reddens (the caller + # cannot be made conformant against an absent publish set) while every + # other entry survives -- rather than a whole-audit `die` that discards + # all 19 other repos' findings the moment one entry omits the key + # (@LukasWodka on #447). + if version_file is not None and ( + not isinstance(version_file, str) or not version_file.strip() + ): + die( + f"{TRAIN_REPO}/{TRAIN_FILE}: {name} has a non-string or empty " + f"`version_file` ({version_file!r})." + ) + members[name] = { + "version_file": version_file, + "publish_paths": publish_paths, + } + return members die(f"could not locate {TRAIN_FILE} in {org}/{TRAIN_REPO} on any branch.") - return set() # unreachable; keeps the return type honest + return {} # unreachable; keeps the return type honest def collect_uses(node, found: "list[tuple[str, dict]]") -> None: @@ -1127,6 +1181,159 @@ def collect_uses(node, found: "list[tuple[str, dict]]") -> None: collect_uses(item, found) +def version_bump_input_findings( + name: str, version_file: str, publish_paths: "str | None", + hits: "list[tuple[str, str, dict]]", caller_state: "str | None" = None, +) -> "list[str]": + """Compare a repo's version-bump-gate caller inputs against repos.yml. + + backend#2953. `version_file` and `publish_paths` come from release-train's + repos.yml (via load_release_train) -- this function HOLDS NEITHER, it only + compares, so it can never itself drift from the source. `hits` is the list of + (filename, ref, inputs) collect_uses parsed for the version-bump-gate reusable + in this repo; a repo with no such caller yields no hits and no findings here + (a MISSING required caller is the inventory's own check, not this one). + + ANY disagreement is a finding, both directions, but they are DIFFERENT bugs and + say so: + * a NARROWER caller under-watches -- the published path it dropped ships + changed bytes under an already-released version and the gate stays green. + This is the unsafe direction and the one all five live drifts were in. + * a WIDER caller only over-watches (a nag), but it still disagrees with the + source, so it is reconciled rather than tolerated. + + `exclude-paths` is not COMPARED to repos.yml (it lives only in the caller, + backend#2758, names files INSIDE publish-paths that do not ship, and has no + counterpart in the source), but one degenerate use of it IS a finding: an + exclude glob EQUAL to a repos.yml publish glob hollows that path back out of the + watched set, so a PR touching only it ships changed bytes under a released + version with a green check -- the same unsafe end state as a narrower + `publish-paths` (@LukasWodka on #447). + + `publish_paths` is `None`/empty when repos.yml is in its sanctioned strict mode + for this repo (a `version_file` with no `publish_paths`). That is a PER-REPO + finding, not a comparison: the caller cannot be made conformant against an + absent publish set, so this repo's run reddens and the audit continues for the + rest (@LukasWodka on #447) -- see load_release_train for why it is not a die. + The finding names two remedies, "add `publish_paths` or exempt the caller", so + `caller_state == "exempt"` must actually clear it -- otherwise the advice is a + lie and an exempted repo stays red forever (Bugbot on #447). + """ + findings: "list[str]" = [] + if not isinstance(publish_paths, str) or not publish_paths.strip(): + if caller_state == "exempt": + # The operator took the second remedy the finding offers: the caller is + # exempt, so there is no gate run to be non-conformant. Silent. + return [] + return [ + f"{name}: release-train/repos.yml declares `version_file: {version_file}` " + f"but no usable `publish_paths` ({publish_paths!r}) -- its sanctioned " + "strict mode. The version-bump-gate caller cannot be conformant against " + "an absent publish set: add `publish_paths` to the repos.yml entry, or " + "exempt the caller. (repos.yml is the source; this audit only compares.)" + ] + want_paths = set(publish_paths.split()) + for filename, _ref, got in hits: + got_vf = got.get("version-file") + if got_vf != version_file: + findings.append( + f"{name}: {filename} passes `version-file: {got_vf!r}` to " + f"version-bump-gate, but release-train/repos.yml declares " + f"`version_file: {version_file}`. The gate would parse a different " + "file than the train tags from, so their verdicts can disagree." + ) + got_excl = set(str(got.get("exclude-paths") or "").split()) + hollowed = sorted(got_excl & want_paths) + if hollowed: + findings.append( + f"{name}: {filename} passes `exclude-paths` containing {hollowed} to " + "version-bump-gate, which release-train/repos.yml lists as published " + "path(s). Excluding a published glob hollows the gate: a PR touching " + "only it ships changed bytes under an already-released version and " + f"still gets a green check (backend#2953). repos.yml publish_paths: " + f"{publish_paths}." + ) + got_pp = got.get("publish-paths") + if got_pp is None: + findings.append( + f"{name}: {filename} calls version-bump-gate with no `publish-paths`, " + f"but release-train/repos.yml declares `publish_paths: {publish_paths}`. " + "The gate cannot tell a published change from a docs change without it." + ) + continue + got_paths = set(str(got_pp).split()) + missing = sorted(want_paths - got_paths) + extra = sorted(got_paths - want_paths) + if missing: + findings.append( + f"{name}: {filename} declares `publish-paths` NARROWER than " + f"release-train/repos.yml -- it omits {missing}. Those paths are " + "published, so a PR touching only them ships changed bytes under an " + "already-released version and still gets a green check (backend#2953). " + f"repos.yml publish_paths: {publish_paths}." + ) + if extra: + findings.append( + f"{name}: {filename} declares `publish-paths` WIDER than " + f"release-train/repos.yml -- it adds {extra}, which repos.yml does not " + "list as published. Harmless to the gate's verdict but still a " + "disagreement with the source; reconcile the two. repos.yml " + f"publish_paths: {publish_paths}." + ) + return findings + + +def zero_pair_die_message( + train: "dict[str, dict]", inventory_repos: "dict[str, dict]", +) -> "str | None": + """The fleet-wide zero-pair guard: message to `die` with, or None if intact. + + version_bump_input_findings only fires for a train entry that carries + `version_file`. If that key is renamed or dropped across ALL of repos.yml, + every entry yields None, the derived check runs on nobody, and the audit goes + green while the inventory still marks the caller `required` -- the exact silent + no-op this guards. Fail closed only when BOTH hold: no entry carries a + `version_file` AND some inventory row marks version-bump-gate `required` (an + intentionally train-free fleet is not an error). @LukasWodka on #447. + """ + if any(m.get("version_file") for m in train.values()): + return None + required = sorted( + n for n, e in inventory_repos.items() + if (e.get("callers") or {}).get(VERSION_BUMP_GATE, (None,))[0] == "required" + ) + if not required: + return None + return ( + f"release-train/{TRAIN_FILE} carries no `version_file` on any entry, but " + f"repo-inventory.yml marks {VERSION_BUMP_GATE} `required` on {required}. The " + "version-bump-gate input audit would run on nobody and pass in silence -- a " + "renamed or dropped `version_file` key, not an empty train. Fix repos.yml." + ) + + +def version_bump_missing_pair_finding( + name: str, train_entry: "dict | None", caller_state: "str | None", +) -> "str | None": + """Per-repo counterpart to zero_pair_die_message: a finding, or None. + + The inventory marks version-bump-gate `required` for this repo, but repos.yml + carries no `version_file` for it, so version_bump_input_findings has nothing to + compare and would pass in silence. Fail the one repo rather than let a missing + pair read as conformance (@LukasWodka on #447). + """ + if caller_state != "required": + return None + if train_entry and train_entry.get("version_file"): + return None + return ( + f"{name}: repo-inventory.yml marks {VERSION_BUMP_GATE} `required`, but " + f"release-train/{TRAIN_FILE} carries no `version_file` for {name} -- the " + "caller's `version-file`/`publish-paths` cannot be checked against the " + "source. Add `version_file` to the repos.yml entry, or exempt the caller." + ) + + class RepoRead: """What the guard managed to learn about one repo, or why it could not.""" @@ -2127,6 +2334,12 @@ def main() -> int: active = list_active_repos(org) train = load_release_train(org) + # backend#2953, zero pairs (fleet-wide): fail closed if repos.yml lost every + # `version_file` while the inventory still requires the caller (@LukasWodka #447). + _zero_pair = zero_pair_die_message(train, inventory["repos"]) + if _zero_pair: + die(_zero_pair) + findings: "list[str]" = [] unreadable: "list[str]" = [] # Kept separate until `evaluated` is computed - see the note at the call site. @@ -2321,6 +2534,33 @@ def main() -> int: "that has never run." ) + # backend#2953: the version-bump-gate caller's `version-file`/`publish-paths` + # are a hand-copy of release-train/repos.yml, and five of six had drifted + # narrower than the source -- published paths the gate no longer watched. + # DERIVED, not restated: the expected values are read live from repos.yml by + # load_release_train, so the caller can no longer disagree with the source + # unnoticed. Keyed off repos.yml carrying a `version_file`, not off the + # inventory's required/exempt flag, so a repo that gains a version_file is + # measured even if the inventory has not caught up. Counts in `callers`. + train_entry = train.get(name) + _vbg_state = entry["callers"].get(VERSION_BUMP_GATE, (None,))[0] + if train_entry and train_entry.get("version_file"): + findings.extend(version_bump_input_findings( + name, + train_entry["version_file"], + train_entry["publish_paths"], + read.callers.get(VERSION_BUMP_GATE, []), + _vbg_state, + )) + else: + # backend#2953, zero pairs (per repo): caller required but no version_file + # in repos.yml -> nothing to compare, so fail the repo (@LukasWodka #447). + _missing_pair = version_bump_missing_pair_finding( + name, train_entry, _vbg_state, + ) + if _missing_pair: + findings.append(_missing_pair) + _m["callers"] = len(findings) - _mark _mark = len(findings) diff --git a/scripts/tests/caller-drift-selftest.py b/scripts/tests/caller-drift-selftest.py index b888df9..0fe521a 100644 --- a/scripts/tests/caller-drift-selftest.py +++ b/scripts/tests/caller-drift-selftest.py @@ -14,8 +14,10 @@ import ast import base64 +import contextlib import copy import importlib.util +import io import inspect import inspect as _inspect import json @@ -1926,6 +1928,240 @@ def _exit(**kw): record(_c == 1, "exit: one PR does not make nine findings green", f"{_c} {_r!r}") +# ---------------------------------------- version-bump-gate caller vs repos.yml +# +# backend#2953. The caller's `version-file`/`publish-paths` restate release-train's +# repos.yml, and five of six had drifted NARROWER than the source, publishing paths +# the gate no longer watched. `version_bump_input_findings` derives the expected +# values from repos.yml (holding neither itself) and flags any disagreement. These +# cases pin the direction wording, the set semantics, and the two fields it must +# NOT compare on. + +VF = "package.json" +PP = "src/* tokens/*" + + +def _vbg_hit(inputs, filename="version-bump-gate-caller.yml"): + """One (filename, ref, inputs) tuple, the shape collect_uses yields.""" + return [(filename, "main", inputs)] + + +# Exact agreement: no finding. +_f = guard.version_bump_input_findings( + "ds", VF, PP, _vbg_hit({"version-file": VF, "publish-paths": PP})) +record(_f == [], "vbg: a caller that matches repos.yml is clean", str(_f)) + +# Order-independent: publish-paths is a set of globs, and the gate loops over them, +# so a reordered-but-equal list is NOT drift. +_f = guard.version_bump_input_findings( + "ds", VF, PP, _vbg_hit({"version-file": VF, "publish-paths": "tokens/* src/*"})) +record(_f == [], "vbg: publish-paths compared as a set, order does not matter", str(_f)) + +# THE ISSUE'S CASE: design-system-v2 declared `src/*` while repos.yml publishes +# `src/* tokens/*`. Narrower -> one finding, the unsafe direction, naming the gap. +_f = guard.version_bump_input_findings( + "design-system-v2", VF, PP, + _vbg_hit({"version-file": VF, "publish-paths": "src/*"})) +record(len(_f) == 1 and "NARROWER" in _f[0] and "tokens/*" in _f[0], + "vbg: a NARROWER caller is a finding that names the dropped path", + str(_f)) + +# Wider -> a finding too (a nag), and it must read as the WIDER direction, not the +# narrower one -- the two are different bugs. +_f = guard.version_bump_input_findings( + "ds", VF, PP, + _vbg_hit({"version-file": VF, "publish-paths": "src/* tokens/* extra/*"})) +record(len(_f) == 1 and "WIDER" in _f[0] and "extra/*" in _f[0], + "vbg: a WIDER caller is a distinct finding, not silently tolerated", + str(_f)) + +# version-file drift is caught even when publish-paths agree (the issue's "also +# worth checking" note -- keep them agreeing). +_f = guard.version_bump_input_findings( + "ds", VF, PP, _vbg_hit({"version-file": "VERSION", "publish-paths": PP})) +record(len(_f) == 1 and "version-file" in _f[0] and "VERSION" in _f[0], + "vbg: a drifted version-file is a finding on its own", str(_f)) + +# publish-paths omitted entirely -> a finding (the reusable requires it; an absent +# one is not permission to skip). +_f = guard.version_bump_input_findings( + "ds", VF, PP, _vbg_hit({"version-file": VF})) +record(len(_f) == 1 and "no `publish-paths`" in _f[0], + "vbg: a caller with no publish-paths is a finding", str(_f)) + +# exclude-paths is caller-only (backend#2758) and has no repos.yml counterpart, so +# a caller that passes it while otherwise matching is CLEAN -- comparing it would +# manufacture drift. +_f = guard.version_bump_input_findings( + "ds", VF, PP, + _vbg_hit({"version-file": VF, "publish-paths": PP, "exclude-paths": "src/*.test.ts"})) +record(_f == [], "vbg: exclude-paths is not compared against repos.yml", str(_f)) + +# soft-fail is likewise not this check's concern (the inventory's caller_inputs +# floor owns it); an otherwise-matching caller with soft-fail is clean here. +_f = guard.version_bump_input_findings( + "ds", VF, PP, + _vbg_hit({"version-file": VF, "publish-paths": PP, "soft-fail": False})) +record(_f == [], "vbg: soft-fail is not compared here (caller_inputs owns it)", str(_f)) + +# No caller at all -> nothing from this check. A MISSING required caller is the +# inventory's own finding, and double-reporting it here would be noise. +record(guard.version_bump_input_findings("ds", VF, PP, []) == [], + "vbg: no caller yields no finding here (missing-caller is the inventory's)", "") + +# Both fields wrong on the same caller -> both findings, so one masking the other +# cannot happen. +_f = guard.version_bump_input_findings( + "ds", VF, PP, _vbg_hit({"version-file": "VERSION", "publish-paths": "src/*"})) +record(len(_f) == 2, "vbg: version-file AND publish-paths drift both reported", str(_f)) + + +# --- load_release_train now carries version_file/publish_paths ------------------ +# It is the ONE reader of those two fields; everything downstream derives from it. + +def _train_stub(reposyml_text): + """Stub gh so load_release_train reads this repos.yml body off develop.""" + def h(args): + joined = " ".join(args) + if "release-train/contents/repos.yml" in joined: + return reposyml_text + raise guard.GhError(500, f"unexpected call {args!r}") + return h + + +_GOOD_TRAIN = textwrap.dedent("""\ + repos: + - name: hub + - name: cli + version_file: VERSION + publish_paths: cmd/* VERSION +""") + +stub(_train_stub(_GOOD_TRAIN)) +_train = guard.load_release_train("acme") +record( + set(_train) == {"hub", "cli"} + and _train["hub"]["version_file"] is None + and _train["cli"]["version_file"] == "VERSION" + and _train["cli"]["publish_paths"] == "cmd/* VERSION", + "load_release_train: membership as keys, version_file/publish_paths as values", + repr(_train)) + +# A `version_file` with NO `publish_paths` is repos.yml's SANCTIONED strict mode +# ("Omit the key to keep the strict behaviour"), not a malformed source: +# load_release_train now CARRIES it (publish_paths None) rather than dying, so one +# entry omitting the key no longer discards all 19 others' findings. The per-repo +# consequence is version_bump_input_findings' business (below). @LukasWodka #447. +stub(_train_stub("repos:\n - name: cli\n version_file: VERSION\n")) +_train = guard.load_release_train("acme") +record( + _train["cli"]["version_file"] == "VERSION" + and _train["cli"]["publish_paths"] is None, + "load_release_train: version_file with no publish_paths is carried, not a die", + repr(_train)) + +# An empty/whitespace publish_paths is carried the same way -- the strip that turns +# it into the per-repo finding happens downstream, not here. +stub(_train_stub("repos:\n - name: cli\n version_file: VERSION\n publish_paths: ' '\n")) +_train = guard.load_release_train("acme") +record( + _train["cli"]["version_file"] == "VERSION" + and (_train["cli"]["publish_paths"] or "").strip() == "", + "load_release_train: empty publish_paths is carried, not a die", + repr(_train)) + +# A non-string/empty `version_file`, by contrast, IS malformed and still fails +# closed -- exit 2 AND the specific refusal (not any `die`, which also exits 2). +stub(_train_stub("repos:\n - name: cli\n version_file: ' '\n")) +_err = io.StringIO() +try: + with contextlib.redirect_stderr(_err): + guard.load_release_train("acme") +except SystemExit as exc: + record(exc.code == 2 and "non-string or empty `version_file`" in _err.getvalue(), + "load_release_train: empty version_file still fails closed", + f"SystemExit({exc.code}) stderr={_err.getvalue()!r}") +else: + record(False, "load_release_train: empty version_file still fails closed", + "accepted an empty version_file") + +# ask 3: the strict-mode case (version_file, no usable publish_paths) is now a +# PER-REPO finding whose text the ask-2 assertions pin. None and empty both yield +# it, and it fires WITHOUT a caller -- the source, not the caller, is the problem +# (@LukasWodka on #447). +for _pp in (None, " "): + _f = guard.version_bump_input_findings("cli", VF, _pp, []) + record( + len(_f) == 1 + and "strict mode" in _f[0] + and "no usable `publish_paths`" in _f[0], + f"vbg: version_file with no publish_paths ({_pp!r}) -> per-repo finding", + str(_f)) + +# ...and the finding's own advice ("or exempt the caller") must actually work: an +# EXEMPT caller clears the strict-mode finding, a required one keeps it. Otherwise +# an exempted repo stays red forever (Bugbot on #447). +record(guard.version_bump_input_findings("cli", VF, None, [], "exempt") == [], + "vbg: an exempt caller clears the strict-mode finding", "") +_f = guard.version_bump_input_findings("cli", VF, None, [], "required") +record(len(_f) == 1 and "strict mode" in _f[0], + "vbg: a required caller keeps the strict-mode finding", str(_f)) + +# ask 4: an `exclude-paths` glob EQUAL to a repos.yml publish glob hollows that path +# back out of the watched set -- a finding, even though exclude-paths is otherwise +# not compared. LukasWodka's repro: publish `src/* tokens/*`, exclude `tokens/*` +# (@LukasWodka on #447). +_f = guard.version_bump_input_findings( + "ds", VF, PP, + _vbg_hit({"version-file": VF, "publish-paths": PP, "exclude-paths": "tokens/*"})) +record(len(_f) == 1 and "hollows" in _f[0] and "tokens/*" in _f[0], + "vbg: an exclude glob equal to a publish glob is a hollowing finding", str(_f)) + +# ask 1a: zero_pair_die_message fires only when NO entry carries version_file AND +# the inventory requires the caller. An intentionally train-free fleet, or one that +# still carries a version_file, is not an error (@LukasWodka on #447). +_INV_REQ = {"cli": {"callers": {guard.VERSION_BUMP_GATE: ("required", "")}}} +_msg = guard.zero_pair_die_message({"cli": {"version_file": None}}, _INV_REQ) +record(bool(_msg) and "run on nobody" in _msg, + "zero_pair: no version_file anywhere + a required caller -> die message", + repr(_msg)) +record( + guard.zero_pair_die_message({"cli": {"version_file": "VERSION"}}, _INV_REQ) is None, + "zero_pair: some entry still carries version_file -> no die", "") +_INV_EXEMPT = {"cli": {"callers": {guard.VERSION_BUMP_GATE: ("exempt", "x")}}} +record( + guard.zero_pair_die_message({"cli": {"version_file": None}}, _INV_EXEMPT) is None, + "zero_pair: no required caller -> no die even with no version_file", "") + +# ask 1b: version_bump_missing_pair_finding fires when the caller is required but the +# train entry has no version_file; silent when it has one, or the caller is not +# required (@LukasWodka on #447). +_mp = guard.version_bump_missing_pair_finding("cli", None, "required") +record(bool(_mp) and "no `version_file`" in _mp, + "missing_pair: required caller + no train version_file -> finding", repr(_mp)) +record( + guard.version_bump_missing_pair_finding( + "cli", {"version_file": "VERSION"}, "required") is None, + "missing_pair: required caller WITH version_file -> no finding (compared instead)", + "") +record( + guard.version_bump_missing_pair_finding("cli", None, "exempt") is None, + "missing_pair: exempt caller -> no finding", "") + +# Optional pin (@LukasWodka on #447): the REAL inventory carries the soft-fail floor +# for the version-bump-gate caller, so a silent deletion of that caller_inputs row +# cannot pass unnoticed (the selftest comment "caller_inputs owns it" is now true +# AND held up). +_REAL_INV = os.path.join(HERE, os.pardir, os.pardir, "repo-inventory.yml") +_real_inv = guard.load_inventory(_REAL_INV) +record( + (_real_inv.get("caller_inputs") or {}) + .get(guard.VERSION_BUMP_GATE, {}) + .get("soft-fail") is False, + "inventory: version-bump-gate caller_inputs pins soft-fail: false", + repr((_real_inv.get("caller_inputs") or {}).get(guard.VERSION_BUMP_GATE))) + + failed = [row for row in RESULTS if not row[0]] print(f"\npass={len(RESULTS) - len(failed)} fail={len(failed)}") if failed: