diff --git a/.github/actions/required-check-skip-guard/reconcile_manifest_vs_live.py b/.github/actions/required-check-skip-guard/reconcile_manifest_vs_live.py index 955db2f59e..b1b84021b0 100644 --- a/.github/actions/required-check-skip-guard/reconcile_manifest_vs_live.py +++ b/.github/actions/required-check-skip-guard/reconcile_manifest_vs_live.py @@ -58,13 +58,28 @@ def fetch_live_contexts(repo: str, branch: str) -> list[str]: def reconcile( - manifest_path: Path, live_contexts: list[str] + manifest_path: Path, live_contexts: list[str], branch: str | None = None ) -> tuple[set[str], set[str]]: + """Compare manifest REQUIRED rows against a branch's live required contexts. + + OMN-15117: the manifest is otherwise single-branch (dev-scoped, see the + file header), but a REQUIRED row may carry an optional `branch:` field to + mark it as applying to one specific branch only (e.g. `verify / verify`, + live-required on `main` and absent from `dev`'s required set). A row + without `branch:` is treated as applicable regardless of which branch is + being reconciled (legacy/default behavior, unchanged). A row WITH + `branch:` is only counted toward `manifest_required` when it matches the + `branch` argument — this prevents a main-only addition from surfacing as + a false `stale_in_manifest` when reconciling against `dev` (and vice + versa). When `branch` is None (no branch context given), every REQUIRED + row counts, matching pre-OMN-15117 behavior. + """ manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {} manifest_required = { gate["name"] for gate in manifest.get("gates", []) if gate.get("mode") == "REQUIRED" + and (branch is None or gate.get("branch", branch) == branch) } live_set = set(live_contexts) @@ -81,7 +96,9 @@ def main() -> int: args = parser.parse_args() live_contexts = fetch_live_contexts(args.repo, args.branch) - missing_from_manifest, stale_in_manifest = reconcile(args.manifest, live_contexts) + missing_from_manifest, stale_in_manifest = reconcile( + args.manifest, live_contexts, branch=args.branch + ) if missing_from_manifest or stale_in_manifest: if missing_from_manifest: diff --git a/.github/required-checks.yaml b/.github/required-checks.yaml index ead4e83f5e..d0ceb5a574 100644 --- a/.github/required-checks.yaml +++ b/.github/required-checks.yaml @@ -460,6 +460,23 @@ gates: skip_semantics: never rationale: "Central OCC eligibility gate (cross-repo reusable hosted in omnibase_core)." + - name: "verify / verify" + mode: REQUIRED + branch: main + producer_kind: cross_repo + caller_workflow: call-receipt-gate.yml + caller_job: verify + cross_repo_ref: "OmniNode-ai/omnibase_core/.github/workflows/receipt-gate.yml@main" + skip_semantics: never + rationale: "Receipt Gate — live required status check on omniclaude `main` branch protection + (dev→main promotion boundary), absent from `dev`'s required set. This manifest is otherwise + dev-scoped (see file header); this row is a deliberate main-only addition (OMN-15117) closing + a guard blind spot: `verify / verify` was live-required on main since OMN-10411 but had zero + row here, so validate_no_required_check_skip_vectors.py never had a context to check it + against and OMN-15057's remediation (#1931) never touched this file. `verify`'s `needs: + occ-preflight` had no `if:` at all (vector-5, OMN-15057 shape) until this ticket added + `if: always()`." + - name: "call-reject-skip-token / scan / reject-skip-gate-token" mode: REQUIRED producer_kind: local diff --git a/.github/workflows/call-receipt-gate.yml b/.github/workflows/call-receipt-gate.yml index 45a59c0ce8..7ad10d0087 100644 --- a/.github/workflows/call-receipt-gate.yml +++ b/.github/workflows/call-receipt-gate.yml @@ -34,8 +34,29 @@ jobs: contracts-dir: contracts receipts-dir: drift/dod_receipts + # OMN-15117 (OMN-15057 vector-5 shape, missed by #1931 because `verify / verify` + # had no required-checks.yaml row for the guard to check): `needs: occ-preflight` + # with no `if:` override means GitHub's implicit job-level `if:` — `success()` + # evaluated over `needs:` — SKIPS this job on any occ-preflight failure/ + # cancellation, and a skipped job satisfies branch protection (skipped counts + # as passing). `verify / verify` is live-required on `main` (the dev->main + # promotion boundary), so that was a silent-pass bypass on the load-bearing gate. + # + # `if: always()` makes `verify` run unconditionally so it can never silently + # skip-as-pass. This is a PARTIAL fix in isolation — a job whose body is + # `uses:` (calls a reusable workflow) cannot also declare `steps:`, so there + # is no way to insert an inline `needs.occ-preflight.result != 'success'` -> + # `exit 1` guard step ahead of the call (unlike Shape-A jobs with their own + # steps, e.g. main-target-guard.yml, url-authority-gate.yml). Real enforcement + # does not depend on this job's own result: `occ-preflight / eligibility` + # (produced unconditionally, no `needs`/`if`, by call-occ-preflight.yml) is + # independently required on this repo's dev+main branch protection, so an + # occ-preflight failure still blocks merge via that separate required context + # even though this job decouples. Same precedent/rationale as omnibase_core#1499 + # applied to its own `deploy-gate`/`verify` jobs (identical `uses:`-only shape). verify: needs: occ-preflight + if: always() # self-gating-ok: intentional cross-repo caller — omniclaude does not host # receipt-gate.yml itself; the reusable workflow lives in omnibase_core. uses: OmniNode-ai/omnibase_core/.github/workflows/receipt-gate.yml@main diff --git a/.github/workflows/required-check-manifest-reconcile.yml b/.github/workflows/required-check-manifest-reconcile.yml index 65db63c452..8f7fc5e4ef 100644 --- a/.github/workflows/required-check-manifest-reconcile.yml +++ b/.github/workflows/required-check-manifest-reconcile.yml @@ -9,15 +9,21 @@ # branch protection needs a token scope (administration:read) that must not be # granted to a PR-triggered workflow (read/exfil risk from a malicious PR). # -# SCOPE NOTE (OMN-14854): `main`'s required_status_checks set is verifiably -# NOT identical to `dev`'s (confirmed live 2026-07-20 — e.g. main additionally -# requires "Security Gate" and "verify / verify"; dev additionally requires -# "Cross-Repo Boundary Parity" and "no-noncanonical-lifecycle-classes"). The -# manifest schema in this PR is single-branch (dev). A `main`-scoped manifest -# section is an explicit open follow-up, not resolved here — do not add a -# `main` reconcile step until the manifest schema grows a per-branch gates -# list; a naive main-vs-this-manifest comparison would fail on every run by -# construction, which is worse than no signal. +# SCOPE NOTE (OMN-14854, updated OMN-15117): `main`'s required_status_checks +# set is verifiably NOT identical to `dev`'s (confirmed live 2026-07-20 — e.g. +# main additionally requires "Security Gate" and "verify / verify"; dev +# additionally requires "Cross-Repo Boundary Parity" and +# "no-noncanonical-lifecycle-classes"). The manifest is still single-branch +# (dev) in the sense that this job only ever reconciles against `dev` — no +# `main` reconcile step exists here, and adding one against the current +# manifest would still fail on every run by construction (most rows have no +# `branch:` field, i.e. dev-only in practice). OMN-15117 added an optional +# per-row `branch:` field (`reconcile()` in reconcile_manifest_vs_live.py) so +# a specific main-only-required context (`verify / verify`) can be represented +# without corrupting this dev-only reconcile — reconcile() excludes any row +# whose `branch:` doesn't match the branch being checked. This is NOT a full +# `main`-scoped manifest section (the remaining main-only gap, "Security +# Gate", still has no row) — that remains an open follow-up. # # This job is NOT itself a required status check (push/schedule cannot be # gated by required_status_checks anyway). Its failure surfaces as a diff --git a/.secrets.baseline b/.secrets.baseline index ad45c8dea8..499901bf66 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -364,5 +364,5 @@ } ] }, - "generated_at": "2026-07-25T06:22:53Z" + "generated_at": "2026-07-25T14:10:03Z" } diff --git a/tests/ci/test_reconcile_manifest_vs_live.py b/tests/ci/test_reconcile_manifest_vs_live.py index c2dbb8af87..2abdb218db 100644 --- a/tests/ci/test_reconcile_manifest_vs_live.py +++ b/tests/ci/test_reconcile_manifest_vs_live.py @@ -74,3 +74,65 @@ def test_advisory_rows_are_excluded_from_manifest_required_set(tmp_path: Path) - missing, stale = reconcile(manifest_path, ["A"]) assert missing == set() assert stale == set() + + +def test_branch_scoped_row_excluded_when_reconciling_a_different_branch( + tmp_path: Path, +) -> None: + """OMN-15117: a REQUIRED row carrying `branch: main` must not surface as + `stale_in_manifest` when reconciling `dev` — it simply isn't in scope for + that branch's comparison.""" + manifest_path = _manifest( + tmp_path, + [ + {"name": "A", "mode": "REQUIRED"}, + {"name": "verify / verify", "mode": "REQUIRED", "branch": "main"}, + ], + ) + missing, stale = reconcile(manifest_path, ["A"], branch="dev") + assert missing == set() + assert stale == set() + + +def test_branch_scoped_row_included_when_reconciling_its_own_branch( + tmp_path: Path, +) -> None: + """The same row IS in scope, and must resolve cleanly, when reconciling + against the branch it declares.""" + manifest_path = _manifest( + tmp_path, + [ + {"name": "A", "mode": "REQUIRED"}, + {"name": "verify / verify", "mode": "REQUIRED", "branch": "main"}, + ], + ) + missing, stale = reconcile(manifest_path, ["A", "verify / verify"], branch="main") + assert missing == set() + assert stale == set() + + +def test_branch_scoped_row_missing_from_live_is_still_flagged_on_its_own_branch( + tmp_path: Path, +) -> None: + """A branch-scoped row is not exempt from detection on its own branch — + only exempt from cross-branch noise.""" + manifest_path = _manifest( + tmp_path, + [{"name": "verify / verify", "mode": "REQUIRED", "branch": "main"}], + ) + missing, stale = reconcile(manifest_path, [], branch="main") + assert missing == set() + assert stale == {"verify / verify"} + + +def test_no_branch_argument_preserves_legacy_behavior(tmp_path: Path) -> None: + """Calling reconcile() without a `branch` kwarg (pre-OMN-15117 call sites) + must count every REQUIRED row regardless of any `branch:` field — this is + the pre-existing, un-scoped comparison.""" + manifest_path = _manifest( + tmp_path, + [{"name": "verify / verify", "mode": "REQUIRED", "branch": "main"}], + ) + missing, stale = reconcile(manifest_path, ["verify / verify"]) + assert missing == set() + assert stale == set()