Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2041,6 +2041,26 @@ jobs:
- name: Require cited OCC evidence to be merged/durable before product merge
run: python3 scripts/ci/check_occ_companion_merged.py

# OMN-15378 AC3: scripts/deploy-agent's own pytest root (201 tests) ran in a
# SEPARATELY-TRIGGERED workflow, so the CI Summary poller — which reads
# `actions/runs/${RUN_ID}/jobs` for its own run only — structurally could not
# observe it: a RED deploy-agent run left the sole required context on this
# branch ("CI Summary") green. Visibility without enforcement. Calling the
# same workflow from HERE puts its job in ci.yml's own run as the check-run
# "Deploy Agent Tests (OMN-15378) / deploy-agent-tests", which is registered
# in scripts/ci/ci_summary_gate.py STRICT_GATE_JOBS (pinned by
# tests/ci/test_ci_summary_gate.py) — so absent/red/skipped now fails the
# required context. Unconditional (no needs/if) exactly as a STRICT gate
# requires, which also closes the old path-filter hole where a breaking
# change outside scripts/deploy-agent/** produced no signal at all.
# DO NOT rename this job — the display name is the first segment of the
# STRICT_GATE_JOBS entry; a rename makes the gate permanently PENDING.
deploy-agent-tests:
name: Deploy Agent Tests (OMN-15378)
permissions:
contents: read
uses: ./.github/workflows/deploy-agent-tests.yml

ci-summary:
name: CI Summary
# OMN-14127: REQUIRED branch-protection context — the single umbrella gate for
Expand Down
41 changes: 26 additions & 15 deletions .github/workflows/deploy-agent-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,25 +26,36 @@ name: Deploy Agent Tests (OMN-15378)
# workflow as the CI wiring behind scripts/deploy-agent's
# STANDALONE_PROJECT_ROOTS entry — if this file is renamed/removed without
# updating that registration, the guard fails closed.
#
# OMN-15378 AC3 (enforcement, not just visibility): this workflow is
# `workflow_call`-only and is invoked by the `deploy-agent-tests` job in
# .github/workflows/ci.yml, so its job surfaces in ci.yml's OWN run as the
# check-run "Deploy Agent Tests (OMN-15378) / deploy-agent-tests" and is
# registered in scripts/ci/ci_summary_gate.py STRICT_GATE_JOBS. That is what
# makes it GATE merge: the CI Summary poller reads
# `actions/runs/${RUN_ID}/jobs` for its own run only, so while these tests
# lived in a separately-triggered workflow a RED result left the sole required
# context ("CI Summary") green — the tests ran but blocked nothing.
#
# DO NOT re-add `pull_request:`/`push:`/`merge_group:` triggers here — the
# caller in ci.yml already covers those events, and self-triggering would run
# the suite twice per PR (duplicate producer). DO NOT rename this file or the
# `deploy-agent-tests` job: the file path is the guard's registration key and
# the job name is the second segment of the STRICT_GATE_JOBS entry.
#
# The caller's path filters are also deliberately gone: the previous
# `paths: [scripts/deploy-agent/**]` filter meant a change ANYWHERE ELSE that
# broke these tests produced no signal at all. The suite is 201 tests in ~6s,
# so it now runs unconditionally on every ci.yml event.
#
# No `concurrency:` block: in a called workflow `github.workflow` resolves to
# the CALLER's workflow name, so a group key here would shadow ci.yml's own
# (already-correct) PR-keyed cancel-in-progress group.

on:
pull_request:
branches: [main, dev]
paths:
- "scripts/deploy-agent/**"
- ".github/workflows/deploy-agent-tests.yml"
push:
branches: [main, dev]
paths:
- "scripts/deploy-agent/**"
- ".github/workflows/deploy-agent-tests.yml"
merge_group:
workflow_call:
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

env:
UV_VERSION: "0.6.14"
PYTHON_VERSION: "3.12"
Expand Down
9 changes: 9 additions & 0 deletions scripts/ci/ci_summary_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,15 @@
"no-noncanonical-lifecycle-classes", # OMN-14350 non-canonical lifecycle-class ratchet
"Effect-Assertion Gate (RT-5)", # OMN-14467 deploy-trigger fails closed on zero output
"OCC Companion Merged Gate (OMN-15214)", # occ-companion-merged — cited OCC evidence must be MERGED before product merge
# OMN-15378 AC3: scripts/deploy-agent's standalone pytest root. ci.yml's
# `deploy-agent-tests` job CALLS .github/workflows/deploy-agent-tests.yml,
# so the inner job surfaces as "<caller display name> / <inner job name>"
# (same shape as "occ-preflight / eligibility"). Registering it here is what
# makes those 201 tests GATE merge: while they lived in a separately-
# triggered workflow this poller could not observe them at all (different
# run_id), so a RED run left "CI Summary" — the sole required context on
# dev — green.
"Deploy Agent Tests (OMN-15378) / deploy-agent-tests",
)

# Gates the old ci-summary accepted as ``success`` OR ``skipped``. Each carries
Expand Down
29 changes: 25 additions & 4 deletions scripts/enforcement_parity_manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,31 @@ repos:
coverage: direct
rule: >-
OMN-14556: node-migration-sync.yml is its OWN workflow file (separate run_id from ci.yml), so ci_summary_gate.py's poll of actions/runs/${RUN_ID}/jobs structurally cannot observe it -- a needs_child declaration would be a false COVERED. Confirmed live on PR #2288 (2026-07-13): node-migration-sync=FAIL, CI Summary=PASS, different run_ids. Absent from required_status_checks today (MISSING) -- this entry makes the report-only ratchet flag it until the branch-protection PUT lands.
- context: "deploy-agent-tests"
coverage: direct
rule: >-
OMN-15378: deploy-agent-tests.yml is its OWN workflow file (separate run_id from ci.yml, path-triggered on scripts/deploy-agent/**), so ci_summary_gate.py's poll of actions/runs/${RUN_ID}/jobs structurally cannot observe it -- same class as node-migration-sync (OMN-14556) above. Wiring the tests to RUN on PR (this ticket's fix) does not make them GATE merge: a red deploy-agent-tests run today leaves CI Summary (the sole required context on this branch) green, so the exact "test exists but doesn't block anything" failure mode the ticket was filed to close is only partially closed -- visibility yes, enforcement no. Absent from required_status_checks today (MISSING) -- this entry makes the report-only ratchet flag it until the branch-protection PUT lands.
# OMN-15378 AC3 -- RESOLVED, so the former `deploy-agent-tests` direct-gate
# entry is deliberately GONE rather than left as a permanent MISSING finding.
# The gap it recorded was real: deploy-agent-tests.yml was its own
# separately-triggered workflow (own run_id), so ci_summary_gate.py's poll of
# actions/runs/${RUN_ID}/jobs structurally could not observe it and a RED run
# left "CI Summary" -- the sole required context here -- green. The fix was NOT
# the branch-protection PUT this entry anticipated: that workflow is path-filtered
# (scripts/deploy-agent/**), and a required context that does not report on every
# PR shape wedges merges indefinitely. Instead ci.yml now CALLS the workflow from
# an unconditional `deploy-agent-tests` job, so it runs inside ci.yml's own run as
# the check-run "Deploy Agent Tests (OMN-15378) / deploy-agent-tests", registered
# in scripts/ci/ci_summary_gate.py STRICT_GATE_JOBS -- absent/red/skipped all fail
# the required context. Pinned by tests/ci/test_ci_summary_gate.py
# (test_deploy_agent_tests_gate_is_strict_and_fails_closed +
# test_every_gate_name_resolves_to_a_ci_yml_job).
#
# RESIDUAL (flagged, not silently dropped): this manifest has no coverage mode
# for "strict-gated by the CI Summary poller". `direct` is false (no own
# context) and `needs_child` is false (ci-summary is a NO-`needs` poller, so a
# needs-closure check reports NEEDS_CLOSURE for every poller-strict gate). Until
# a `poller_strict_gate` mode exists -- verifying aggregator-required AND
# membership in STRICT_GATE_JOBS AND job-present-in-ci.yml -- the ratchet cannot
# audit this class, and the tests above are the enforcement of record. The same
# limitation applies to the 17 other STRICT_GATE_JOBS entries, none of which this
# manifest declares either.
# --- The two repos the audit CONFIRMED are missing the gates (M1-M4) ---
omnimarket:
dev:
Expand Down
106 changes: 100 additions & 6 deletions scripts/validation/validate_test_root_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,12 @@
governed-selector CI jobs in ``.github/workflows/ci.yml``; or
2. A registered :data:`STANDALONE_PROJECT_ROOTS` entry — a directory that owns
its own ``pyproject.toml`` (a genuinely separate uv (sub-)project, e.g.
``scripts/deploy-agent``) AND has a live GitHub Actions workflow file that
runs its tests. Both must be real on disk or the guard fails closed —
an allowlist entry with no wiring behind it IS the OMN-15378 defect; or
``scripts/deploy-agent``) AND has a live GitHub Actions workflow that runs
its tests. All three legs are verified: the ``pyproject.toml`` exists, the
workflow file exists and references the root, and the workflow is reachable
on a pull request (its own ``pull_request`` trigger, or a PR-reachable
workflow calls it via ``uses:``). An allowlist entry with no wiring behind
it IS the OMN-15378 defect; or
3. Named in :data:`KNOWN_UNCOLLECTED_DEBT` — pre-existing uncollected roots
present at the time this guard landed, called out explicitly rather than
silently grandfathered. Do NOT add a new root here to make a violation
Expand All @@ -45,6 +48,9 @@

import sys
from pathlib import Path
from typing import Any

import yaml

REPO_ROOT = Path(__file__).resolve().parents[2]

Expand All @@ -55,9 +61,15 @@

# Directories that own a private pyproject.toml (a genuine separate uv
# (sub-)project) and are exercised by a DEDICATED CI workflow rather than
# folded into the main `tests/` tree. Both the pyproject.toml and the workflow
# file are verified to actually exist on disk -- an unwired allowlist entry is
# exactly the class of defect this guard exists to catch.
# folded into the main `tests/` tree. The pyproject.toml and the workflow file
# must exist on disk, the workflow must REFERENCE the root, and the workflow
# must be reachable on a pull request -- either it triggers on `pull_request`
# itself, or a workflow that does calls it via `uses:` (OMN-15378 AC3: the
# deploy-agent workflow is `workflow_call`-only and is invoked by ci.yml's
# `deploy-agent-tests` job so its result lands under the required "CI Summary"
# context). An unwired allowlist entry -- a workflow file that exists but never
# runs on a PR, or runs but does not touch the root -- is exactly the class of
# defect this guard exists to catch.
STANDALONE_PROJECT_ROOTS: dict[str, str] = {
"scripts/deploy-agent": ".github/workflows/deploy-agent-tests.yml",
}
Expand Down Expand Up @@ -108,6 +120,75 @@ def _is_collected(test_dir: str) -> bool:
return test_dir.startswith(COLLECTED_ROOT)


def _load_workflow(path: Path) -> dict[str, Any]:
"""Parse a workflow file; an unparseable file yields an empty mapping."""
try:
loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError):
return {}
return loaded if isinstance(loaded, dict) else {}


def _workflow_triggers(workflow: dict[str, Any]) -> set[str]:
"""Return the workflow's trigger names.

PyYAML resolves the bare ``on:`` key to the boolean ``True`` (YAML 1.1), so
both spellings are checked. ``on: [push, pull_request]`` (list form) and
``on: push`` (scalar form) are normalized alongside the mapping form.
"""
raw = workflow.get(True, workflow.get("on"))
if isinstance(raw, dict):
return {str(key) for key in raw}
if isinstance(raw, list):
return {str(item) for item in raw}
if isinstance(raw, str):
return {raw}
return set()


def _workflow_runs_on_pull_request(
workflow_rel: str, repo_root: Path, _seen: frozenset[str] = frozenset()
) -> bool:
"""True when ``workflow_rel`` actually executes on a pull request.

Either it declares a ``pull_request`` trigger itself, or it is a reusable
(``workflow_call``) workflow invoked via ``uses:`` by another workflow that
is itself PR-reachable. A registered wiring workflow that no event and no
caller can ever reach runs zero tests -- the OMN-15378 defect wearing a
workflow file as a disguise.
"""
if workflow_rel in _seen: # cycle guard
return False
workflow = _load_workflow(repo_root / workflow_rel)
triggers = _workflow_triggers(workflow)
if "pull_request" in triggers or "pull_request_target" in triggers:
return True
if "workflow_call" not in triggers:
return False
reference = f"./{workflow_rel}"
workflows_dir = repo_root / ".github" / "workflows"
candidates = sorted(workflows_dir.glob("*.yml")) + sorted(
workflows_dir.glob("*.yaml")
)
for candidate in candidates:
candidate_rel = candidate.relative_to(repo_root).as_posix()
if candidate_rel == workflow_rel:
continue
jobs = _load_workflow(candidate).get("jobs")
if not isinstance(jobs, dict):
continue
calls_it = any(
str((body or {}).get("uses") or "") == reference
for body in jobs.values()
if isinstance(body, dict)
)
if calls_it and _workflow_runs_on_pull_request(
candidate_rel, repo_root, _seen | {workflow_rel}
):
return True
return False


def _standalone_problem(test_dir: str, repo_root: Path) -> str | None:
"""Return None if legitimately wired, 'unregistered' if no match, else a
description of what is wrong with the registered entry."""
Expand All @@ -126,6 +207,19 @@ def _standalone_problem(test_dir: str, repo_root: Path) -> str | None:
f"registered as a STANDALONE_PROJECT_ROOTS entry but "
f"the wiring workflow {workflow} does not exist"
)
if root.rstrip("/") not in workflow_path.read_text(encoding="utf-8"):
return (
f"registered as a STANDALONE_PROJECT_ROOTS entry but the "
f"wiring workflow {workflow} never references {root} — it "
f"cannot be running those tests"
)
if not _workflow_runs_on_pull_request(workflow, repo_root):
return (
f"registered as a STANDALONE_PROJECT_ROOTS entry but the "
f"wiring workflow {workflow} never runs on a pull request "
f"(no pull_request trigger, and no PR-reachable workflow "
f"calls it via uses:) — the tests are invisible again"
)
return None
return "unregistered"

Expand Down
Loading
Loading