From bbd7fa984dce4e245a9846d111b1e37760a179cb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 21:08:39 +0900 Subject: [PATCH 01/27] feat(actions): inventory orphaned workflow identities Add a read-only classifier that binds advertised GitHub Actions identities to the exact protected default-branch SHA, fail-closes on pagination or visibility defects, and refuses registry mutation. Addresses #945. --- ARCHITECTURE.md | 11 + docs/doctoring/orphaned-workflow-lifecycle.md | 93 +++ ...-workflow-lifecycle-ledger-v1.example.json | 40 ++ scripts/ci/inventory_orphaned_workflows.py | 440 +++++++++++++ tests/test_inventory_orphaned_workflows.py | 619 ++++++++++++++++++ 5 files changed, 1203 insertions(+) create mode 100644 docs/doctoring/orphaned-workflow-lifecycle.md create mode 100644 schemas/examples/cwl-workflow-lifecycle-ledger-v1.example.json create mode 100644 scripts/ci/inventory_orphaned_workflows.py create mode 100644 tests/test_inventory_orphaned_workflows.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3e2e70b58e..8e6c9f80b7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -66,6 +66,17 @@ The worker checks out helpers at `${{ github.sha }}` so a later default-branch push cannot replace privileged scripts after dispatch (CWE-367). Repair binds `NVIDIA_NIM_API_KEY`, never `COPILOT_GITHUB_TOKEN`. +## Workflow lifecycle inventory + +GitHub persists Actions registry identities independently of the protected +default-branch tree. `scripts/ci/inventory_orphaned_workflows.py` is a +read-only classifier: it binds each advertised workflow to one default-branch +SHA, distinguishes repository YAML from GitHub-owned `dynamic/` identities, +and fail-closes on incomplete pagination or visibility. It does not disable +or recreate workflows. Known fleet orphans route to +ContextualWisdomLab/appguardrail#929, ContextualWisdomLab/clearfolio#423, and +ContextualWisdomLab/disksage#191. + Product callers stagger Clearfolio at minute 23, DiskSage at minute 37, and fast-mlsirm at minute 49. Each caller is read-only, dispatches at most one repair, and delegates all privileged logic to the same sealed scheduler. diff --git a/docs/doctoring/orphaned-workflow-lifecycle.md b/docs/doctoring/orphaned-workflow-lifecycle.md new file mode 100644 index 0000000000..ed4129ab29 --- /dev/null +++ b/docs/doctoring/orphaned-workflow-lifecycle.md @@ -0,0 +1,93 @@ +# Orphaned GitHub Actions workflow-lifecycle inventory + +검토 기준일: **2026-08-16** + +## Incident + +Live Actions inventories showed the same recurrence in multiple +ContextualWisdomLab repositories (ContextualWisdomLab/.github#945): + +- AppGuardrail advertised dozens of historical `apply-*`, `finalize-*`, + and `*-once.yml` identities as `state: active` while sampled default-branch + paths returned 404 (ContextualWisdomLab/appguardrail#929); +- Clearfolio retained `one-shot-*` and PR-specific repair identities after + the YAML had left the protected default branch (ContextualWisdomLab/clearfolio#423); +- DiskSage retained PR-specific finalizers in the same shape + (ContextualWisdomLab/disksage#191). + +Source deletion is not a complete workflow lifecycle. GitHub persists +registry records independently of the default-branch tree, so a buyer or +reviewer cannot treat "the YAML is gone" as "no writer remains enabled." + +## Decision + +1. The central `.github` repository owns a **read-only** inventory that + binds every advertised workflow identity to the exact protected + default-branch SHA observed at the start and re-read at the end. +2. Classification is evidence-based: `present_active`, `present_disabled`, + `orphan_active`, `orphan_disabled`, `dynamic_owned`, or `unresolved`. + A file named `once` is not alone proof of invalidity. A benign name + does not hide a missing source file. +3. Incomplete visibility (401/403/404), a 5xx after one retry, pagination + truncation, `total_count` drift, reused workflow IDs, percent-encoded + paths, and default-branch movement fail closed. +4. This scanner never disables, deletes, or recreates workflows. Disablement + remains a separately reviewed operator step after the ledger is + revalidated. +5. `NVIDIA_NIM_API_KEY` may exist elsewhere in the control plane. This + inventory never reads `COPILOT_GITHUB_TOKEN`. +6. CSAP and SOC 2 are design constraints (access visibility, change + management, evidence retention). This record is not a certification + claim. Operational identities (repository, workflow path, workflow ID) + are not masked as PII. + +## Trust boundary + +The inventory consumes only a caller-supplied fixture or a least-privilege +read of the Actions registry and git tree. It does not receive repository +write permission, `secrets: inherit`, or a guessed PAT. GitHub-owned +`dynamic/` identities are never treated as deleted repository files. + +MITRE CWE-200 describes exposure of sensitive information when an observer +cannot tell which control-plane writers are enabled. CWE-862 describes +missing authorization when a registry mutation is performed without a +reviewed operator path. This increment closes the visibility gap and +refuses the mutation. + +## Operator contract + +Feed a JSON payload with `organization`, `observed_at`, and one object +per visible non-archived repository. Each repository must include the +start and end default-branch SHAs, the exact tree paths at that SHA, and +complete workflow pages (`total_count`, `workflows`, and either `_link_next` +or a GitHub `Link` header). Archived repositories are skipped. + +```bash +python3 scripts/ci/inventory_orphaned_workflows.py \ + --payload schemas/examples/cwl-workflow-lifecycle-ledger-v1.example.json \ + --output /tmp/workflow-lifecycle-ledger.json +``` + +`--fail-on-orphan-active` is reserved for a later reviewed live sweep. +This increment's default is to emit the ledger so CI can prove +classification without disabling sibling-repository writers. + +## Rollback + +Delete `scripts/ci/inventory_orphaned_workflows.py` and its tests. No +registry state is mutated, so rollback does not re-enable or disable +workflows. + +## References + +GitHub. (2026). *REST API endpoints for workflows*. GitHub Docs. +https://docs.github.com/en/rest/actions/workflows + +GitHub. (2026). *Security hardening for GitHub Actions*. GitHub Docs. +https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions + +MITRE. (2026a). *CWE-200: Exposure of sensitive information to an unauthorized actor*. +https://cwe.mitre.org/data/definitions/200.html + +MITRE. (2026b). *CWE-862: Missing authorization*. +https://cwe.mitre.org/data/definitions/862.html diff --git a/schemas/examples/cwl-workflow-lifecycle-ledger-v1.example.json b/schemas/examples/cwl-workflow-lifecycle-ledger-v1.example.json new file mode 100644 index 0000000000..3b1f92af49 --- /dev/null +++ b/schemas/examples/cwl-workflow-lifecycle-ledger-v1.example.json @@ -0,0 +1,40 @@ +{ + "organization": "ContextualWisdomLab", + "observed_at": "2026-08-16T12:00:00Z", + "repositories": [ + { + "name": "appguardrail", + "archived": false, + "default_branch": "main", + "default_branch_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "default_branch_sha_after": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "tree_paths": [".github/workflows/ci.yml"], + "workflow_pages": [ + { + "total_count": 3, + "workflows": [ + { + "id": 11, + "name": "finalize-once", + "path": ".github/workflows/finalize-once.yml", + "state": "active" + }, + { + "id": 12, + "name": "ci", + "path": ".github/workflows/ci.yml", + "state": "active" + }, + { + "id": 13, + "name": "pages", + "path": "dynamic/pages/pages-build-deployment", + "state": "active" + } + ], + "_link_next": false + } + ] + } + ] +} diff --git a/scripts/ci/inventory_orphaned_workflows.py b/scripts/ci/inventory_orphaned_workflows.py new file mode 100644 index 0000000000..15f173d361 --- /dev/null +++ b/scripts/ci/inventory_orphaned_workflows.py @@ -0,0 +1,440 @@ +#!/usr/bin/env python3 +"""Read-only GitHub Actions workflow-lifecycle inventory. + +The classifier answers whether an advertised workflow identity still has +source on the exact protected default-branch SHA. It never disables, +deletes, or recreates workflows and never reads ``COPILOT_GITHUB_TOKEN``. +CSAP and SOC 2 appear only as design constraints. + +See ContextualWisdomLab/.github#945. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from collections.abc import Iterable, Mapping, Sequence +from pathlib import Path +from typing import Any, Callable + + +SCHEMA_VERSION = "1" +CAPABILITY = "workflow_lifecycle_inventory" +MAX_PAYLOAD_BYTES = 1_048_576 +PER_PAGE_DEFAULT = 100 +HEX_SHA = re.compile(r"^[0-9a-f]{40}$") +REPO_SLUG = re.compile(r"^[A-Za-z0-9._-]+$") +REPO_WORKFLOW_NAME = re.compile(r"^[^/]+\.(yml|yaml)$") +DYNAMIC_PREFIXES = ("dynamic/",) +DISABLED_STATES = frozenset( + { + "disabled_manually", + "disabled_inactivity", + "disabled_fork", + "deleted", + } +) +ACTIVE_STATES = frozenset({"active"}) +CLASSIFICATIONS = ( + "present_active", + "present_disabled", + "orphan_active", + "orphan_disabled", + "dynamic_owned", + "unresolved", +) +KNOWN_OWNER_ISSUES = { + "appguardrail": "ContextualWisdomLab/appguardrail#929", + "clearfolio": "ContextualWisdomLab/clearfolio#423", + "disksage": "ContextualWisdomLab/disksage#191", +} +FORBIDDEN_TOKENS = frozenset({"COPILOT_GITHUB_TOKEN"}) + + +class InventoryError(ValueError): + """Fail-closed defect in workflow-lifecycle evidence.""" + + +def reject_forbidden_token(name: str) -> None: + """Refuse GitHub Copilot tokens and other forbidden credentials.""" + if name in FORBIDDEN_TOKENS: + raise InventoryError(f"{name} is forbidden for this inventory") + + +def refuse_registry_mutation(action: str) -> None: + """Keep disablement on a separately reviewed operator path.""" + raise InventoryError( + f"registry mutation {action!r} is out of scope for this scanner" + ) + + +def is_exact_sha(value: object) -> bool: + """Return whether *value* is a 40-character lowercase hex SHA.""" + return isinstance(value, str) and HEX_SHA.fullmatch(value) is not None + + +def parse_link_has_next(link_header: object) -> bool: + """Return whether a GitHub ``Link`` header advertises another page.""" + if link_header is None: + return False + if not isinstance(link_header, str) or not link_header.strip(): + raise InventoryError("Link header is malformed") + return 'rel="next"' in link_header + + +def decode_registry_path(path: object) -> str: + """Decode one percent-encoding pass and reject traversal disguises.""" + if not isinstance(path, str) or not path or "\x00" in path: + raise InventoryError("workflow path is missing or NUL-bearing") + if "\\" in path: + raise InventoryError("workflow path must not contain backslashes") + if "%" in path: + raise InventoryError("workflow path must not be percent-encoded") + parts = [part for part in path.split("/") if part not in {"", "."}] + if ".." in parts: + raise InventoryError("path traversal is not a workflow identity") + return path + + +def is_dynamic_owned_path(path: str) -> bool: + """Return whether *path* is a GitHub-owned dynamic workflow identity.""" + return path.startswith(DYNAMIC_PREFIXES) + + +def is_repository_workflow_path(path: str) -> bool: + """Return whether *path* is an exact repository workflow file.""" + if not path.startswith(".github/workflows/"): + return False + name = path[len(".github/workflows/") :] + return REPO_WORKFLOW_NAME.fullmatch(name) is not None + + +def interpret_status(status: int, *, resource: str) -> None: + """Fail closed on incomplete visibility or transient upstream errors.""" + if status == 200: + return + if status in {401, 403}: + raise InventoryError(f"permission loss for {resource}") + if status == 404: + raise InventoryError(f"missing visibility for {resource}") + if status >= 500: + raise InventoryError(f"transient upstream error {status} for {resource}") + raise InventoryError(f"unexpected status {status} for {resource}") + + +def fetch_with_one_retry( + fetch: Callable[[str], tuple[int, Any, Mapping[str, str]]], + url: str, +) -> tuple[Any, Mapping[str, str]]: + """GET JSON once, retry a 5xx exactly once, then fail closed.""" + status, body, headers = fetch(url) + if status >= 500: + status, body, headers = fetch(url) + interpret_status(status, resource=url) + return body, headers + + +def classify_workflow( + *, + path: str, + state: object, + source_present: bool | None, +) -> str: + """Classify one registry identity against the bound default-branch tree.""" + if is_dynamic_owned_path(path): + return "dynamic_owned" + if not is_repository_workflow_path(path): + return "unresolved" + if source_present is None: + return "unresolved" + if state in ACTIVE_STATES: + return "present_active" if source_present else "orphan_active" + if state in DISABLED_STATES: + return "present_disabled" if source_present else "orphan_disabled" + return "unresolved" + + +def assert_unique_workflow_ids(workflows: Sequence[Mapping[str, Any]]) -> None: + """Fail closed when a page set reuses a workflow id.""" + seen: set[int] = set() + for workflow in workflows: + workflow_id = workflow.get("id") + if not isinstance(workflow_id, int) or workflow_id <= 0: + raise InventoryError("workflow id must be a positive integer") + if workflow_id in seen: + raise InventoryError(f"reused workflow id {workflow_id}") + seen.add(workflow_id) + + +def collect_workflow_pages( + pages: Iterable[Mapping[str, Any]], + *, + per_page: int = PER_PAGE_DEFAULT, +) -> list[dict[str, Any]]: + """Merge workflow pages and fail closed on truncation or count drift.""" + if per_page <= 0: + raise InventoryError("per_page must be positive") + collected: list[dict[str, Any]] = [] + expected_total: int | None = None + saw_page = False + open_next = False + for page in pages: + saw_page = True + if not isinstance(page, Mapping): + raise InventoryError("workflow page is not an object") + workflows = page.get("workflows") + total_count = page.get("total_count") + if not isinstance(workflows, list): + raise InventoryError("workflow page is missing a workflows array") + if not isinstance(total_count, int) or total_count < 0: + raise InventoryError("workflow page total_count is invalid") + if expected_total is None: + expected_total = total_count + elif total_count != expected_total: + raise InventoryError("workflow total_count drifted across pages") + if len(workflows) > per_page: + raise InventoryError("workflow page exceeds per_page") + for workflow in workflows: + if not isinstance(workflow, dict): + raise InventoryError("workflow record is not an object") + collected.extend(workflows) + link_next = page.get("_link_next") + if link_next is None: + open_next = parse_link_has_next(page.get("link")) + elif isinstance(link_next, bool): + open_next = link_next + else: + raise InventoryError("workflow page _link_next must be boolean") + if open_next and not workflows: + raise InventoryError("empty workflow page advertised a next link") + if not open_next: + break + else: + if open_next: + raise InventoryError("pagination truncated after last next link") + if not saw_page: + raise InventoryError("no workflow pages") + if expected_total is None or len(collected) != expected_total: + raise InventoryError("pagination truncated or incomplete") + assert_unique_workflow_ids(collected) + return collected + + +def assert_default_branch_bound(start_sha: object, end_sha: object) -> str: + """Bind the inventory to one default-branch SHA or abort.""" + if not is_exact_sha(start_sha) or not is_exact_sha(end_sha): + raise InventoryError("default-branch SHA is not a 40-character hex digest") + if start_sha != end_sha: + raise InventoryError("default-branch SHA moved during inventory") + return str(start_sha) + + +def owner_issue_for(repository: str) -> str | None: + """Return the known owning issue for a fleet repository, if any.""" + return KNOWN_OWNER_ISSUES.get(repository) + + +def inventory_repository(record: Mapping[str, Any]) -> dict[str, Any]: + """Inventory one non-archived repository against its bound SHA.""" + name = record.get("name") + if not isinstance(name, str) or REPO_SLUG.fullmatch(name) is None: + raise InventoryError("repository name is not a valid slug") + if record.get("archived") is True: + return { + "repository": name, + "skipped": "archived", + "records": [], + } + if record.get("archived") is not False: + raise InventoryError(f"{name} archived flag is not boolean") + sha = assert_default_branch_bound( + record.get("default_branch_sha"), + record.get("default_branch_sha_after"), + ) + tree_paths = record.get("tree_paths") + if not isinstance(tree_paths, list) or any( + not isinstance(path, str) for path in tree_paths + ): + raise InventoryError(f"{name} tree_paths must be a list of strings") + tree = set(tree_paths) + pages = record.get("workflow_pages") + if not isinstance(pages, list): + raise InventoryError(f"{name} workflow_pages must be a list") + workflows = collect_workflow_pages(pages) + records: list[dict[str, Any]] = [] + for workflow in workflows: + path = decode_registry_path(workflow.get("path")) + source_present: bool | None + if is_repository_workflow_path(path): + source_present = path in tree + elif is_dynamic_owned_path(path): + source_present = None + else: + source_present = None + classification = classify_workflow( + path=path, + state=workflow.get("state"), + source_present=source_present, + ) + item = { + "repository": name, + "workflow_id": workflow.get("id"), + "name": workflow.get("name"), + "path": path, + "state": workflow.get("state"), + "default_branch_sha": sha, + "classification": classification, + } + if classification == "orphan_active": + owner = owner_issue_for(name) + if owner is not None: + item["owner_issue"] = owner + records.append(item) + return { + "repository": name, + "default_branch": record.get("default_branch"), + "default_branch_sha": sha, + "page_count": len(pages), + "workflow_count": len(records), + "records": records, + } + + +def reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + """Build an object while rejecting duplicate JSON keys.""" + seen: set[str] = set() + result: dict[str, Any] = {} + for key, value in pairs: + if key in seen: + raise InventoryError(f"duplicate object key {key!r}") + seen.add(key) + result[key] = value + return result + + +def load_payload_bytes(raw: bytes) -> dict[str, Any]: + """Parse a bounded UTF-8 inventory payload and reject duplicate keys.""" + if not raw: + raise InventoryError("payload is empty") + if len(raw) > MAX_PAYLOAD_BYTES: + raise InventoryError("payload exceeds 1048576 bytes") + try: + text = raw.decode("utf-8") + except UnicodeDecodeError as exc: + raise InventoryError("payload is not UTF-8") from exc + try: + payload = json.loads(text, object_pairs_hook=reject_duplicate_keys) + except json.JSONDecodeError as exc: + raise InventoryError(f"payload is not JSON: {exc}") from exc + if not isinstance(payload, dict): + raise InventoryError("payload must be a JSON object") + return payload + + +def inventory_organization(payload: Mapping[str, Any]) -> dict[str, Any]: + """Inventory every supplied repository and emit an immutable ledger.""" + organization = payload.get("organization") + if organization != "ContextualWisdomLab": + raise InventoryError("organization must be ContextualWisdomLab") + observed_at = payload.get("observed_at") + if not isinstance(observed_at, str) or not observed_at: + raise InventoryError("observed_at is required") + repositories = payload.get("repositories") + if not isinstance(repositories, list) or not repositories: + raise InventoryError("repositories must be a non-empty list") + inventories: list[dict[str, Any]] = [] + for record in repositories: + if not isinstance(record, Mapping): + raise InventoryError("repository record is not an object") + inventories.append(inventory_repository(record)) + records = [ + item + for inventory in inventories + for item in inventory.get("records", []) + ] + counts = {name: 0 for name in CLASSIFICATIONS} + for item in records: + classification = item["classification"] + if classification not in counts: + raise InventoryError(f"unknown classification {classification!r}") + counts[classification] += 1 + return { + "schema_version": SCHEMA_VERSION, + "capability": CAPABILITY, + "organization": organization, + "observed_at": observed_at, + "assurance_posture": { + "csap": "design_constraint", + "soc2": "design_constraint", + "certification_claim": False, + "operational_pii_mask": False, + }, + "counts": counts, + "repositories": inventories, + "records": records, + } + + +def write_ledger(ledger: Mapping[str, Any], output: Path | None) -> str: + """Serialize the ledger as UTF-8 JSON with a trailing newline.""" + text = json.dumps(ledger, indent=2, sort_keys=True) + "\n" + if output is not None: + output.write_text(text, encoding="utf-8") + return text + + +def main(argv: Sequence[str] | None = None) -> int: + """Load a fixture payload, emit a ledger, and optionally fail on orphans.""" + parser = argparse.ArgumentParser( + description="Classify GitHub Actions workflow registry identities." + ) + parser.add_argument("--payload", required=True, help="JSON inventory fixture") + parser.add_argument("--output", help="optional ledger output path") + parser.add_argument( + "--fail-on-orphan-active", + action="store_true", + help="exit 1 when any orphan_active identity is observed", + ) + parser.add_argument( + "--mutate", + help=argparse.SUPPRESS, + ) + args = parser.parse_args(argv) + if args.mutate: + try: + refuse_registry_mutation(args.mutate) + except InventoryError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + try: + raw = Path(args.payload).read_bytes() + payload = load_payload_bytes(raw) + ledger = inventory_organization(payload) + text = write_ledger(ledger, Path(args.output) if args.output else None) + except FileNotFoundError as exc: + print(f"ERROR: payload not found: {exc}", file=sys.stderr) + return 2 + except OSError as exc: + print(f"ERROR: unable to read payload: {exc}", file=sys.stderr) + return 2 + except InventoryError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + if args.output is None: + sys.stdout.write(text) + orphan_active = ledger["counts"]["orphan_active"] + if args.fail_on_orphan_active and orphan_active: + print(f"FAIL: {orphan_active} orphan_active workflow identit(y/ies)", file=sys.stderr) + return 1 + print( + f"PASS: inventoried {len(ledger['records'])} identities " + f"({orphan_active} orphan_active)", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": # pragma: no cover - exercised through main() + raise SystemExit(main()) diff --git a/tests/test_inventory_orphaned_workflows.py b/tests/test_inventory_orphaned_workflows.py new file mode 100644 index 0000000000..598cb07e4f --- /dev/null +++ b/tests/test_inventory_orphaned_workflows.py @@ -0,0 +1,619 @@ +"""Fail-closed contracts for the read-only workflow-lifecycle inventory.""" + +from __future__ import annotations + +import json +from io import StringIO +from pathlib import Path +from typing import Any + +import pytest + +from scripts.ci import inventory_orphaned_workflows as inventory + + +SHA = "a" * 40 +SHA_B = "b" * 40 + + +def _workflow( + workflow_id: int, + path: str, + *, + state: str = "active", + name: str | None = None, +) -> dict[str, Any]: + """Return one GitHub Actions workflow registry record.""" + return { + "id": workflow_id, + "name": name or Path(path).name, + "path": path, + "state": state, + } + + +def _repo( + name: str, + workflows: list[dict[str, Any]], + tree_paths: list[str], + *, + archived: bool = False, + sha: str = SHA, + sha_after: str | None = None, + pages: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Return one repository inventory fixture.""" + return { + "name": name, + "archived": archived, + "default_branch": "main", + "default_branch_sha": sha, + "default_branch_sha_after": sha if sha_after is None else sha_after, + "tree_paths": tree_paths, + "workflow_pages": pages + if pages is not None + else [{"total_count": len(workflows), "workflows": workflows, "_link_next": False}], + } + + +def _payload(repositories: list[dict[str, Any]]) -> dict[str, Any]: + """Return an organization inventory fixture.""" + return { + "organization": "ContextualWisdomLab", + "observed_at": "2026-08-16T12:00:00Z", + "repositories": repositories, + } + + +def test_reject_forbidden_token_and_allow_unrelated_names() -> None: + """Copilot tokens are forbidden; unrelated credential names are ignored.""" + inventory.reject_forbidden_token("NVIDIA_NIM_API_KEY") + with pytest.raises(inventory.InventoryError, match="COPILOT_GITHUB_TOKEN"): + inventory.reject_forbidden_token("COPILOT_GITHUB_TOKEN") + + +def test_refuse_registry_mutation() -> None: + """Disablement stays on a separately reviewed operator path.""" + with pytest.raises(inventory.InventoryError, match="disable"): + inventory.refuse_registry_mutation("disable") + + +def test_is_exact_sha() -> None: + """Only a 40-character lowercase hex digest is a bound SHA.""" + assert inventory.is_exact_sha(SHA) is True + assert inventory.is_exact_sha("A" * 40) is False + assert inventory.is_exact_sha(1) is False + assert inventory.is_exact_sha("deadbeef") is False + + +def test_parse_link_has_next() -> None: + """Link pagination is boolean and fail-closed on malformed headers.""" + assert inventory.parse_link_has_next(None) is False + assert inventory.parse_link_has_next('; rel="next"') is True + assert inventory.parse_link_has_next('; rel="prev"') is False + with pytest.raises(inventory.InventoryError, match="malformed"): + inventory.parse_link_has_next("") + with pytest.raises(inventory.InventoryError, match="malformed"): + inventory.parse_link_has_next(1) + + +def test_decode_registry_path_rejects_encoding_and_traversal() -> None: + """Percent-encoding, NULs, backslashes, and `..` fail closed.""" + assert ( + inventory.decode_registry_path(".github/workflows/ci.yml") + == ".github/workflows/ci.yml" + ) + with pytest.raises(inventory.InventoryError, match="NUL|missing"): + inventory.decode_registry_path("") + with pytest.raises(inventory.InventoryError, match="NUL|missing"): + inventory.decode_registry_path(".github/workflows/ci.yml\x00") + with pytest.raises(inventory.InventoryError, match="backslash"): + inventory.decode_registry_path(".github\\workflows\\ci.yml") + with pytest.raises(inventory.InventoryError, match="percent-encoded"): + inventory.decode_registry_path(".github/workflows/%2e%2e/ci.yml") + with pytest.raises(inventory.InventoryError, match="traversal"): + inventory.decode_registry_path(".github/workflows/../../secret.yml") + + +def test_path_predicates() -> None: + """Dynamic GitHub identities stay distinct from repository YAML files.""" + assert inventory.is_dynamic_owned_path("dynamic/pages/pages-build-deployment") + assert inventory.is_repository_workflow_path(".github/workflows/ci.yml") + assert inventory.is_repository_workflow_path(".github/workflows/ci.yaml") + assert not inventory.is_repository_workflow_path(".GitHub/workflows/ci.yml") + assert not inventory.is_repository_workflow_path(".github/workflows/nested/ci.yml") + assert not inventory.is_repository_workflow_path(".github/workflows/ci.txt") + assert not inventory.is_dynamic_owned_path(".github/workflows/ci.yml") + + +def test_interpret_status_and_single_retry() -> None: + """Visibility loss and 5xx fail closed after at most one retry.""" + inventory.interpret_status(200, resource="workflows") + with pytest.raises(inventory.InventoryError, match="permission"): + inventory.interpret_status(401, resource="workflows") + with pytest.raises(inventory.InventoryError, match="permission"): + inventory.interpret_status(403, resource="workflows") + with pytest.raises(inventory.InventoryError, match="missing visibility"): + inventory.interpret_status(404, resource="workflows") + with pytest.raises(inventory.InventoryError, match="transient"): + inventory.interpret_status(503, resource="workflows") + with pytest.raises(inventory.InventoryError, match="unexpected"): + inventory.interpret_status(418, resource="workflows") + + calls = {"n": 0} + + def once_ok(url: str) -> tuple[int, dict[str, str], dict[str, str]]: + calls["n"] += 1 + return 200, {"ok": url}, {"link": ""} + + body, headers = inventory.fetch_with_one_retry(once_ok, "https://example/ok") + assert body == {"ok": "https://example/ok"} + assert headers == {"link": ""} + assert calls["n"] == 1 + + def recover(url: str) -> tuple[int, dict[str, str], dict[str, str]]: + calls["n"] += 1 + if calls["n"] == 2: + return 503, {}, {} + return 200, {"recovered": True}, {} + + calls["n"] = 1 + body, _headers = inventory.fetch_with_one_retry(recover, "https://example/retry") + assert body == {"recovered": True} + + def stay_down(_url: str) -> tuple[int, dict[str, str], dict[str, str]]: + return 500, {}, {} + + with pytest.raises(inventory.InventoryError, match="transient"): + inventory.fetch_with_one_retry(stay_down, "https://example/down") + + +def test_classify_workflow_matrix() -> None: + """Every advertised class is produced from path, state, and tree presence.""" + repo = ".github/workflows/ci.yml" + assert ( + inventory.classify_workflow( + path="dynamic/pages/pages-build-deployment", + state="active", + source_present=None, + ) + == "dynamic_owned" + ) + assert ( + inventory.classify_workflow( + path=".GitHub/workflows/ci.yml", + state="active", + source_present=None, + ) + == "unresolved" + ) + assert ( + inventory.classify_workflow(path=repo, state="active", source_present=None) + == "unresolved" + ) + assert ( + inventory.classify_workflow(path=repo, state="active", source_present=True) + == "present_active" + ) + assert ( + inventory.classify_workflow(path=repo, state="active", source_present=False) + == "orphan_active" + ) + assert ( + inventory.classify_workflow( + path=repo, state="disabled_manually", source_present=True + ) + == "present_disabled" + ) + assert ( + inventory.classify_workflow( + path=repo, state="disabled_inactivity", source_present=False + ) + == "orphan_disabled" + ) + assert ( + inventory.classify_workflow(path=repo, state="mystery", source_present=True) + == "unresolved" + ) + + +def test_collect_workflow_pages_fail_closed() -> None: + """Partial pagination, drift, reuse, and empty next-pages fail closed.""" + first = { + "total_count": 2, + "workflows": [_workflow(1, ".github/workflows/a.yml")], + "_link_next": True, + } + second = { + "total_count": 2, + "workflows": [_workflow(2, ".github/workflows/b.yml")], + "_link_next": False, + } + assert len(inventory.collect_workflow_pages([first, second], per_page=1)) == 2 + + with pytest.raises(inventory.InventoryError, match="per_page"): + inventory.collect_workflow_pages([], per_page=0) + with pytest.raises(inventory.InventoryError, match="no workflow pages"): + inventory.collect_workflow_pages([]) + with pytest.raises(inventory.InventoryError, match="not an object"): + inventory.collect_workflow_pages([None]) # type: ignore[list-item] + with pytest.raises(inventory.InventoryError, match="workflows array"): + inventory.collect_workflow_pages([{"total_count": 0}]) + with pytest.raises(inventory.InventoryError, match="total_count"): + inventory.collect_workflow_pages([{"total_count": -1, "workflows": []}]) + with pytest.raises(inventory.InventoryError, match="drifted"): + inventory.collect_workflow_pages( + [ + { + "total_count": 2, + "workflows": [_workflow(1, ".github/workflows/a.yml")], + "_link_next": True, + }, + { + "total_count": 3, + "workflows": [_workflow(2, ".github/workflows/b.yml")], + "_link_next": False, + }, + ], + per_page=1, + ) + with pytest.raises(inventory.InventoryError, match="exceeds per_page"): + inventory.collect_workflow_pages( + [ + { + "total_count": 2, + "workflows": [ + _workflow(1, ".github/workflows/a.yml"), + _workflow(2, ".github/workflows/b.yml"), + ], + "_link_next": False, + } + ], + per_page=1, + ) + with pytest.raises(inventory.InventoryError, match="not an object"): + inventory.collect_workflow_pages( + [{"total_count": 1, "workflows": ["bad"], "_link_next": False}] + ) + with pytest.raises(inventory.InventoryError, match="truncated"): + inventory.collect_workflow_pages( + [ + { + "total_count": 2, + "workflows": [_workflow(1, ".github/workflows/a.yml")], + "_link_next": False, + } + ] + ) + with pytest.raises(inventory.InventoryError, match="empty workflow page"): + inventory.collect_workflow_pages( + [{"total_count": 0, "workflows": [], "_link_next": True}] + ) + with pytest.raises(inventory.InventoryError, match="truncated after last"): + inventory.collect_workflow_pages( + [ + { + "total_count": 1, + "workflows": [_workflow(1, ".github/workflows/a.yml")], + "_link_next": True, + } + ] + ) + with pytest.raises(inventory.InventoryError, match="_link_next"): + inventory.collect_workflow_pages( + [{"total_count": 0, "workflows": [], "_link_next": "yes"}] + ) + linked = { + "total_count": 0, + "workflows": [], + "link": '; rel="prev"', + } + assert inventory.collect_workflow_pages([linked]) == [] + with pytest.raises(inventory.InventoryError, match="reused workflow id"): + inventory.collect_workflow_pages( + [ + { + "total_count": 2, + "workflows": [ + _workflow(7, ".github/workflows/a.yml"), + _workflow(7, ".github/workflows/renamed.yml"), + ], + "_link_next": False, + } + ] + ) + with pytest.raises(inventory.InventoryError, match="positive integer"): + inventory.collect_workflow_pages( + [ + { + "total_count": 1, + "workflows": [{"id": "7", "path": ".github/workflows/a.yml"}], + "_link_next": False, + } + ] + ) + + +def test_assert_default_branch_bound() -> None: + """A moved or malformed default-branch SHA aborts the inventory.""" + assert inventory.assert_default_branch_bound(SHA, SHA) == SHA + with pytest.raises(inventory.InventoryError, match="moved"): + inventory.assert_default_branch_bound(SHA, SHA_B) + with pytest.raises(inventory.InventoryError, match="hex digest"): + inventory.assert_default_branch_bound("main", "main") + + +def test_owner_issue_for_known_fleet() -> None: + """Known fleet orphans route to the existing owner issues.""" + assert ( + inventory.owner_issue_for("appguardrail") + == "ContextualWisdomLab/appguardrail#929" + ) + assert inventory.owner_issue_for("naruon") is None + + +def test_inventory_repository_classifies_known_shapes() -> None: + """One-shot names, orphans, dynamic workflows, and archives stay honest.""" + result = inventory.inventory_repository( + _repo( + "clearfolio", + [ + _workflow(1, ".github/workflows/one-shot-cleanup.yml"), + _workflow(2, ".github/workflows/missing.yml"), + _workflow( + 3, + "dynamic/pages/pages-build-deployment", + name="pages", + ), + _workflow( + 4, + ".github/workflows/old.yml", + state="disabled_manually", + ), + ], + [".github/workflows/one-shot-cleanup.yml"], + ) + ) + classes = {item["workflow_id"]: item["classification"] for item in result["records"]} + assert classes[1] == "present_active" + assert classes[2] == "orphan_active" + assert classes[3] == "dynamic_owned" + assert classes[4] == "orphan_disabled" + orphan = next(item for item in result["records"] if item["workflow_id"] == 2) + assert orphan["owner_issue"] == "ContextualWisdomLab/clearfolio#423" + + skipped = inventory.inventory_repository(_repo("old", [], [], archived=True)) + assert skipped["skipped"] == "archived" + assert skipped["records"] == [] + + +def test_inventory_repository_rejects_malformed_records() -> None: + """Malformed repository fixtures fail closed before classification.""" + with pytest.raises(inventory.InventoryError, match="valid slug"): + inventory.inventory_repository(_repo("bad name", [], [])) + bad_flag = _repo("naruon", [], []) + bad_flag["archived"] = "no" + with pytest.raises(inventory.InventoryError, match="archived flag"): + inventory.inventory_repository(bad_flag) + bad_tree = _repo("naruon", [], []) + bad_tree["tree_paths"] = [1] + with pytest.raises(inventory.InventoryError, match="tree_paths"): + inventory.inventory_repository(bad_tree) + not_list = _repo("naruon", [], []) + not_list["tree_paths"] = ".github/workflows/ci.yml" + with pytest.raises(inventory.InventoryError, match="tree_paths"): + inventory.inventory_repository(not_list) + unnamed_orphan = inventory.inventory_repository( + _repo( + "naruon", + [_workflow(8, ".github/workflows/missing.yml")], + [], + ) + ) + assert unnamed_orphan["records"][0]["classification"] == "orphan_active" + assert "owner_issue" not in unnamed_orphan["records"][0] + bad_pages = _repo("naruon", [], []) + bad_pages["workflow_pages"] = "pages" + with pytest.raises(inventory.InventoryError, match="workflow_pages"): + inventory.inventory_repository(bad_pages) + present_disabled = inventory.inventory_repository( + _repo( + "naruon", + [ + _workflow( + 9, + ".github/workflows/ci.yml", + state="disabled_fork", + ) + ], + [".github/workflows/ci.yml"], + ) + ) + assert present_disabled["records"][0]["classification"] == "present_disabled" + unresolved = inventory.inventory_repository( + _repo( + "naruon", + [_workflow(10, "not-a-workflow")], + [], + ) + ) + assert unresolved["records"][0]["classification"] == "unresolved" + + +def test_payload_loading_rejects_duplicates_and_bounds() -> None: + """Empty, oversized, non-UTF-8, non-object, and duplicate-key payloads fail.""" + with pytest.raises(inventory.InventoryError, match="empty"): + inventory.load_payload_bytes(b"") + with pytest.raises(inventory.InventoryError, match="exceeds"): + inventory.load_payload_bytes(b"{" + b"a" * (inventory.MAX_PAYLOAD_BYTES + 1)) + with pytest.raises(inventory.InventoryError, match="UTF-8"): + inventory.load_payload_bytes(b"\xff") + with pytest.raises(inventory.InventoryError, match="not JSON"): + inventory.load_payload_bytes(b"{") + with pytest.raises(inventory.InventoryError, match="JSON object"): + inventory.load_payload_bytes(b"[]") + with pytest.raises(inventory.InventoryError, match="duplicate object key"): + inventory.reject_duplicate_keys([("a", 1), ("a", 2)]) + payload = inventory.load_payload_bytes(b'{"organization":"ContextualWisdomLab"}') + assert payload["organization"] == "ContextualWisdomLab" + + +def test_inventory_organization_and_unknown_classification( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Organization ledgers count classes and reject unknown ones.""" + payload = _payload( + [ + _repo( + "disksage", + [_workflow(1, ".github/workflows/gone.yml")], + [], + ) + ] + ) + ledger = inventory.inventory_organization(payload) + assert ledger["schema_version"] == "1" + assert ledger["assurance_posture"]["certification_claim"] is False + assert ledger["assurance_posture"]["operational_pii_mask"] is False + assert ledger["counts"]["orphan_active"] == 1 + assert ledger["records"][0]["owner_issue"] == "ContextualWisdomLab/disksage#191" + + with pytest.raises(inventory.InventoryError, match="organization"): + inventory.inventory_organization({"organization": "other"}) + with pytest.raises(inventory.InventoryError, match="observed_at"): + inventory.inventory_organization( + {"organization": "ContextualWisdomLab", "observed_at": ""} + ) + with pytest.raises(inventory.InventoryError, match="non-empty"): + inventory.inventory_organization( + { + "organization": "ContextualWisdomLab", + "observed_at": "2026-08-16T12:00:00Z", + "repositories": [], + } + ) + with pytest.raises(inventory.InventoryError, match="not an object"): + inventory.inventory_organization( + { + "organization": "ContextualWisdomLab", + "observed_at": "2026-08-16T12:00:00Z", + "repositories": ["naruon"], + } + ) + + def lie(**_kwargs: object) -> str: + return "not-a-class" + + monkeypatch.setattr(inventory, "classify_workflow", lie) + with pytest.raises(inventory.InventoryError, match="unknown classification"): + inventory.inventory_organization( + _payload( + [ + _repo( + "naruon", + [_workflow(1, ".github/workflows/ci.yml")], + [".github/workflows/ci.yml"], + ) + ] + ) + ) + + +def test_write_ledger_and_main(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """The CLI writes a ledger, fails closed, and never mutates the registry.""" + payload = _payload( + [ + _repo( + "appguardrail", + [ + _workflow(1, ".github/workflows/apply-once.yml"), + _workflow(2, ".github/workflows/ci.yml"), + ], + [".github/workflows/ci.yml"], + ) + ] + ) + payload_path = tmp_path / "payload.json" + payload_path.write_text(json.dumps(payload), encoding="utf-8") + output = tmp_path / "ledger.json" + assert ( + inventory.main( + ["--payload", str(payload_path), "--output", str(output)] + ) + == 0 + ) + ledger = json.loads(output.read_text(encoding="utf-8")) + assert ledger["counts"]["orphan_active"] == 1 + assert ledger["counts"]["present_active"] == 1 + err = capsys.readouterr().err + assert "PASS:" in err + + assert ( + inventory.main( + ["--payload", str(payload_path), "--fail-on-orphan-active"] + ) + == 1 + ) + captured = capsys.readouterr() + assert "FAIL:" in captured.err + assert '"schema_version"' in captured.out + + assert inventory.main(["--payload", str(payload_path), "--mutate", "disable"]) == 2 + assert "registry mutation" in capsys.readouterr().err + + missing = tmp_path / "missing.json" + assert inventory.main(["--payload", str(missing)]) == 2 + assert "not found" in capsys.readouterr().err + + directory = tmp_path / "dir" + directory.mkdir() + assert inventory.main(["--payload", str(directory)]) == 2 + assert "unable to read payload" in capsys.readouterr().err + + payload_path.write_text("[]", encoding="utf-8") + assert inventory.main(["--payload", str(payload_path)]) == 2 + assert "JSON object" in capsys.readouterr().err + + +def test_example_fixture_classifies_without_masking_identities() -> None: + """The committed example ledger fixture is executable and unredacted.""" + raw = Path("schemas/examples/cwl-workflow-lifecycle-ledger-v1.example.json").read_bytes() + ledger = inventory.inventory_organization(inventory.load_payload_bytes(raw)) + assert ledger["assurance_posture"]["operational_pii_mask"] is False + classes = {item["path"]: item["classification"] for item in ledger["records"]} + assert classes[".github/workflows/finalize-once.yml"] == "orphan_active" + assert classes[".github/workflows/ci.yml"] == "present_active" + assert classes["dynamic/pages/pages-build-deployment"] == "dynamic_owned" + + +def test_known_fleet_fixture_routes_owner_issues() -> None: + """The three named fleet incidents remain routed, not heuristically deleted.""" + payload = _payload( + [ + _repo( + "appguardrail", + [_workflow(11, ".github/workflows/finalize-once.yml")], + [], + ), + _repo( + "clearfolio", + [_workflow(12, ".github/workflows/one-shot-repair.yml")], + [".github/workflows/one-shot-repair.yml"], + ), + _repo( + "disksage", + [_workflow(13, ".github/workflows/pr-123-finalizer.yml")], + [], + ), + ] + ) + ledger = inventory.inventory_organization(payload) + by_repo = {item["repository"]: item for item in ledger["records"]} + assert by_repo["appguardrail"]["classification"] == "orphan_active" + assert by_repo["appguardrail"]["owner_issue"] == ( + "ContextualWisdomLab/appguardrail#929" + ) + assert by_repo["clearfolio"]["classification"] == "present_active" + assert "owner_issue" not in by_repo["clearfolio"] + assert by_repo["disksage"]["classification"] == "orphan_active" + assert by_repo["disksage"]["owner_issue"] == "ContextualWisdomLab/disksage#191" From 271ad3a6e8f8d05926ffc1cab112ddcb28fa1ccc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 13:06:50 +0900 Subject: [PATCH 02/27] docs: keep workflow caller note with repair gate --- ARCHITECTURE.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 91a1cbe5b1..44a7304367 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -66,6 +66,10 @@ The worker checks out helpers at `${{ github.sha }}` so a later default-branch push cannot replace privileged scripts after dispatch (CWE-367). Repair binds `NVIDIA_NIM_API_KEY`, never `COPILOT_GITHUB_TOKEN`. +Product callers stagger Clearfolio at minute 23, DiskSage at minute 37, and +fast-mlsirm at minute 49. Each caller is read-only, dispatches at most one +repair, and delegates all privileged logic to the same sealed scheduler. + ## Workflow lifecycle inventory GitHub persists Actions registry identities independently of the protected @@ -77,10 +81,6 @@ or recreate workflows. Known fleet orphans route to ContextualWisdomLab/appguardrail#929, ContextualWisdomLab/clearfolio#423, and ContextualWisdomLab/disksage#191. -Product callers stagger Clearfolio at minute 23, DiskSage at minute 37, and -fast-mlsirm at minute 49. Each caller is read-only, dispatches at most one -repair, and delegates all privileged logic to the same sealed scheduler. - ## Exact-artifact SBOM attestation ```mermaid From ab51f489374764f25721e335321d55a7fae5a964 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 13:24:51 +0900 Subject: [PATCH 03/27] fix: classify workflow ledger write failures --- scripts/ci/inventory_orphaned_workflows.py | 6 +++++- tests/test_inventory_orphaned_workflows.py | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/scripts/ci/inventory_orphaned_workflows.py b/scripts/ci/inventory_orphaned_workflows.py index 15f173d361..003454775b 100644 --- a/scripts/ci/inventory_orphaned_workflows.py +++ b/scripts/ci/inventory_orphaned_workflows.py @@ -412,7 +412,6 @@ def main(argv: Sequence[str] | None = None) -> int: raw = Path(args.payload).read_bytes() payload = load_payload_bytes(raw) ledger = inventory_organization(payload) - text = write_ledger(ledger, Path(args.output) if args.output else None) except FileNotFoundError as exc: print(f"ERROR: payload not found: {exc}", file=sys.stderr) return 2 @@ -422,6 +421,11 @@ def main(argv: Sequence[str] | None = None) -> int: except InventoryError as exc: print(f"ERROR: {exc}", file=sys.stderr) return 2 + try: + text = write_ledger(ledger, Path(args.output) if args.output else None) + except (FileNotFoundError, OSError) as exc: + print(f"ERROR: unable to write ledger: {exc}", file=sys.stderr) + return 2 if args.output is None: sys.stdout.write(text) orphan_active = ledger["counts"]["orphan_active"] diff --git a/tests/test_inventory_orphaned_workflows.py b/tests/test_inventory_orphaned_workflows.py index 598cb07e4f..1509e945b3 100644 --- a/tests/test_inventory_orphaned_workflows.py +++ b/tests/test_inventory_orphaned_workflows.py @@ -575,6 +575,27 @@ def test_write_ledger_and_main(tmp_path: Path, capsys: pytest.CaptureFixture[str assert "JSON object" in capsys.readouterr().err +def test_main_reports_ledger_output_failures_separately( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """The CLI identifies an unwritable ledger path as an output failure.""" + payload_path = tmp_path / "payload.json" + payload_path.write_text( + json.dumps(_payload([_repo("naruon", [], [])])), encoding="utf-8" + ) + output = tmp_path / "missing" / "ledger.json" + + assert ( + inventory.main( + ["--payload", str(payload_path), "--output", str(output)] + ) + == 2 + ) + error = capsys.readouterr().err + assert "unable to write ledger" in error + assert "unable to read payload" not in error + + def test_example_fixture_classifies_without_masking_identities() -> None: """The committed example ledger fixture is executable and unredacted.""" raw = Path("schemas/examples/cwl-workflow-lifecycle-ledger-v1.example.json").read_bytes() From e281305f0012c95dc04c7c17bf9c10fe0d29bdad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 17:16:42 +0900 Subject: [PATCH 04/27] fix(actions): route fleet orphan findings to owners --- ARCHITECTURE.md | 6 +-- CHANGELOG.md | 4 ++ docs/doctoring/orphaned-workflow-lifecycle.md | 4 ++ scripts/ci/inventory_orphaned_workflows.py | 17 ++++++++ tests/test_inventory_orphaned_workflows.py | 43 ++++++++++++++++++- 5 files changed, 69 insertions(+), 5 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 44a7304367..6a690b956f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -77,9 +77,9 @@ default-branch tree. `scripts/ci/inventory_orphaned_workflows.py` is a read-only classifier: it binds each advertised workflow to one default-branch SHA, distinguishes repository YAML from GitHub-owned `dynamic/` identities, and fail-closes on incomplete pagination or visibility. It does not disable -or recreate workflows. Known fleet orphans route to -ContextualWisdomLab/appguardrail#929, ContextualWisdomLab/clearfolio#423, and -ContextualWisdomLab/disksage#191. +or recreate workflows. Confirmed fleet orphans route through the explicit +linkable owner-issue registry in the inventory module; it never infers issue +numbers or treats an absent owner route as a passing classification. ## Exact-artifact SBOM attestation diff --git a/CHANGELOG.md b/CHANGELOG.md index f4903c2f3f..db6dcc6e10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,10 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Route confirmed orphan-workflow fleet findings through the complete + explicit owner-issue registry, including Inkspan and the other live + downstream incident owners, without granting the read-only scanner issue + mutation authority. - Parsed `opencode.jsonc` as JSONC (stripping `//` and `/* */` comments outside string literals) in the reasoning-effort guard and its contract tests, instead of raw `json.loads`, which rejected the file the moment it carried its first explanatory comment (added for the `contextual-orchestrator` provider block) with `Expecting property name enclosed in double quotes`. Comment markers inside string values, such as the `$schema` URL, are left untouched. - Download the pinned `uv` 0.12.1 exporter from the official GitHub Releases URL instead of `releases.astral.sh`, which now returns HTTP 403 and blocks org-wide OpenCode `coverage-evidence`. The SHA-256 pin is unchanged. The opener may follow one hop onto `release-assets.githubusercontent.com` or `objects.githubusercontent.com` and still rejects every other host, userinfo, non-HTTPS scheme, and nondefault port (ContextualWisdomLab/.github#1109). - Compared the trusted `uv` executable's post-install `--version` output against the real GitHub Releases build's full string, `uv 0.12.1 (x86_64-unknown-linux-gnu)`, instead of the bare `uv 0.12.1` the prior check required; the genuine release binary always prints the target triple, so every installation was failing the pin check immediately after the archive download itself was fixed (ContextualWisdomLab/.github#1109). diff --git a/docs/doctoring/orphaned-workflow-lifecycle.md b/docs/doctoring/orphaned-workflow-lifecycle.md index ed4129ab29..5ad9a1263d 100644 --- a/docs/doctoring/orphaned-workflow-lifecycle.md +++ b/docs/doctoring/orphaned-workflow-lifecycle.md @@ -40,6 +40,10 @@ reviewer cannot treat "the YAML is gone" as "no writer remains enabled." management, evidence retention). This record is not a certification claim. Operational identities (repository, workflow path, workflow ID) are not masked as PII. +7. Confirmed repository owner routes are maintained as an explicit, + linkable registry from live fleet evidence. The scanner does not infer + issue numbers, create issues, or convert an absent owner route into a + passing result. ## Trust boundary diff --git a/scripts/ci/inventory_orphaned_workflows.py b/scripts/ci/inventory_orphaned_workflows.py index 003454775b..056f2b4651 100644 --- a/scripts/ci/inventory_orphaned_workflows.py +++ b/scripts/ci/inventory_orphaned_workflows.py @@ -47,8 +47,25 @@ ) KNOWN_OWNER_ISSUES = { "appguardrail": "ContextualWisdomLab/appguardrail#929", + "bandscope": "ContextualWisdomLab/bandscope#847", "clearfolio": "ContextualWisdomLab/clearfolio#423", + "codec-carver": "ContextualWisdomLab/codec-carver#401", + "contextual-orchestrator": "ContextualWisdomLab/contextual-orchestrator#122", + "DiagramWeave": "ContextualWisdomLab/DiagramWeave#27", "disksage": "ContextualWisdomLab/disksage#191", + "EgressWeave": "ContextualWisdomLab/EgressWeave#202", + "fast-mlsirm": "ContextualWisdomLab/fast-mlsirm#809", + "four-pillars": "ContextualWisdomLab/four-pillars#33", + "inkspan": "ContextualWisdomLab/inkspan#278", + "keyverse": "ContextualWisdomLab/keyverse#99", + "naruon": "ContextualWisdomLab/naruon#1324", + "newsdom-api": "ContextualWisdomLab/newsdom-api#604", + "noema": "ContextualWisdomLab/noema#226", + "OriginWeave": "ContextualWisdomLab/OriginWeave#123", + "pg-erd-cloud": "ContextualWisdomLab/pg-erd-cloud#865", + "RankWeave": "ContextualWisdomLab/RankWeave#38", + "saju-caldav": "ContextualWisdomLab/saju-caldav#33", + "ThreadWeave": "ContextualWisdomLab/ThreadWeave#31", } FORBIDDEN_TOKENS = frozenset({"COPILOT_GITHUB_TOKEN"}) diff --git a/tests/test_inventory_orphaned_workflows.py b/tests/test_inventory_orphaned_workflows.py index 1509e945b3..f4fa331648 100644 --- a/tests/test_inventory_orphaned_workflows.py +++ b/tests/test_inventory_orphaned_workflows.py @@ -349,7 +349,33 @@ def test_owner_issue_for_known_fleet() -> None: inventory.owner_issue_for("appguardrail") == "ContextualWisdomLab/appguardrail#929" ) - assert inventory.owner_issue_for("naruon") is None + assert inventory.owner_issue_for("naruon") == "ContextualWisdomLab/naruon#1324" + + +def test_owner_issue_registry_covers_confirmed_fleet() -> None: + """Every confirmed fleet owner has an explicit, linkable issue route.""" + assert inventory.KNOWN_OWNER_ISSUES == { + "appguardrail": "ContextualWisdomLab/appguardrail#929", + "bandscope": "ContextualWisdomLab/bandscope#847", + "clearfolio": "ContextualWisdomLab/clearfolio#423", + "codec-carver": "ContextualWisdomLab/codec-carver#401", + "contextual-orchestrator": "ContextualWisdomLab/contextual-orchestrator#122", + "DiagramWeave": "ContextualWisdomLab/DiagramWeave#27", + "disksage": "ContextualWisdomLab/disksage#191", + "EgressWeave": "ContextualWisdomLab/EgressWeave#202", + "fast-mlsirm": "ContextualWisdomLab/fast-mlsirm#809", + "four-pillars": "ContextualWisdomLab/four-pillars#33", + "inkspan": "ContextualWisdomLab/inkspan#278", + "keyverse": "ContextualWisdomLab/keyverse#99", + "naruon": "ContextualWisdomLab/naruon#1324", + "newsdom-api": "ContextualWisdomLab/newsdom-api#604", + "noema": "ContextualWisdomLab/noema#226", + "OriginWeave": "ContextualWisdomLab/OriginWeave#123", + "pg-erd-cloud": "ContextualWisdomLab/pg-erd-cloud#865", + "RankWeave": "ContextualWisdomLab/RankWeave#38", + "saju-caldav": "ContextualWisdomLab/saju-caldav#33", + "ThreadWeave": "ContextualWisdomLab/ThreadWeave#31", + } def test_inventory_repository_classifies_known_shapes() -> None: @@ -387,6 +413,19 @@ def test_inventory_repository_classifies_known_shapes() -> None: assert skipped["records"] == [] +def test_inventory_routes_inkspan_orphan_to_owner_issue() -> None: + """Inkspan orphan evidence reaches its confirmed central owner issue.""" + result = inventory.inventory_repository( + _repo( + "inkspan", + [_workflow(20, ".github/workflows/apply-preparse-envelope-limits.yml")], + [], + ) + ) + assert result["records"][0]["classification"] == "orphan_active" + assert result["records"][0]["owner_issue"] == "ContextualWisdomLab/inkspan#278" + + def test_inventory_repository_rejects_malformed_records() -> None: """Malformed repository fixtures fail closed before classification.""" with pytest.raises(inventory.InventoryError, match="valid slug"): @@ -405,7 +444,7 @@ def test_inventory_repository_rejects_malformed_records() -> None: inventory.inventory_repository(not_list) unnamed_orphan = inventory.inventory_repository( _repo( - "naruon", + "unknown-repo", [_workflow(8, ".github/workflows/missing.yml")], [], ) From 580c8e97e2498fbd6b41bc2de6f2e7fca7dd4e48 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 17:19:28 +0900 Subject: [PATCH 05/27] style(actions): lint lifecycle inventory --- scripts/ci/inventory_orphaned_workflows.py | 5 ++--- tests/test_inventory_orphaned_workflows.py | 2 -- 2 files changed, 2 insertions(+), 5 deletions(-) mode change 100644 => 100755 scripts/ci/inventory_orphaned_workflows.py diff --git a/scripts/ci/inventory_orphaned_workflows.py b/scripts/ci/inventory_orphaned_workflows.py old mode 100644 new mode 100755 index 056f2b4651..77d88509d8 --- a/scripts/ci/inventory_orphaned_workflows.py +++ b/scripts/ci/inventory_orphaned_workflows.py @@ -15,10 +15,9 @@ import json import re import sys -from collections.abc import Iterable, Mapping, Sequence +from collections.abc import Callable, Iterable, Mapping, Sequence from pathlib import Path -from typing import Any, Callable - +from typing import Any SCHEMA_VERSION = "1" CAPABILITY = "workflow_lifecycle_inventory" diff --git a/tests/test_inventory_orphaned_workflows.py b/tests/test_inventory_orphaned_workflows.py index f4fa331648..1ae0e2ea15 100644 --- a/tests/test_inventory_orphaned_workflows.py +++ b/tests/test_inventory_orphaned_workflows.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -from io import StringIO from pathlib import Path from typing import Any @@ -11,7 +10,6 @@ from scripts.ci import inventory_orphaned_workflows as inventory - SHA = "a" * 40 SHA_B = "b" * 40 From 442ba6e8436e7c6bce6cb650aa1076ecb9e92ca1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 18:35:25 +0900 Subject: [PATCH 06/27] fix: complete orphan workflow owner routing --- CHANGELOG.md | 5 +---- docs/doctoring/orphaned-workflow-lifecycle.md | 8 ++++--- scripts/ci/inventory_orphaned_workflows.py | 14 +++++++++--- .../organization_commercial_readiness_loop.py | 3 ++- tests/test_inventory_orphaned_workflows.py | 22 +++++++++++++++++++ 5 files changed, 41 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32a2c8d02b..22801c5f04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Semantic Versioning where the repository publishes a release. - Added a dedicated DiskSage hourly caller that invokes the same product-neutral RCA and remediation-feasibility scheduler with an exact repository target, one-dispatch budget, two-hour same-head retry floor, non-cancelling single-flight heartbeat, and explicit established scheduler credentials. - Added a dedicated fast-mlsirm hourly caller that preserves Rust-owned psychometric arithmetic while dispatching at most one exact-head, root-cause-driven repair with a two-hour same-head retry floor. - Added a dedicated Orgmetra hourly caller at minute 58 that targets protected `develop`, dispatches at most one exact-head repair, preserves a two-hour same-head retry floor and non-cancelling single-flight execution, and maps only the established scheduler credentials. +- Added read-only orphan-workflow lifecycle classification with exact default-branch binding, fail-closed visibility and pagination checks, explicit case-insensitive owner-issue routing for active and disabled orphans, and no registry mutation authority. ### Changed @@ -36,10 +37,6 @@ Semantic Versioning where the repository publishes a release. ### Fixed -- Route confirmed orphan-workflow fleet findings through the complete - explicit owner-issue registry, including Inkspan and the other live - downstream incident owners, without granting the read-only scanner issue - mutation authority. - Refused PR Review Merge Scheduler head mutations, `update-branch` and the last-push approval head restamp, whenever the resolved mutation credential is the workflow `GITHUB_TOKEN`. GitHub starts no workflow run for events created with that credential, so the moved head collected no current-head required checks and the PR stayed permanently `BLOCKED` with a `github-actions[bot]` merge commit that no later scheduler run could repair, because the branch was no longer behind. The scheduler now waits with `head_mutation_credential_upgrade` guidance naming `PR_REVIEW_MERGE_TOKEN`, `OPENCODE_APPROVE_TOKEN`, and the OpenCode app token exchange. - Parsed `opencode.jsonc` as JSONC (stripping `//` and `/* */` comments outside string literals) in the reasoning-effort guard and its contract tests, instead of raw `json.loads`, which rejected the file the moment it carried its first explanatory comment (added for the `contextual-orchestrator` provider block) with `Expecting property name enclosed in double quotes`. Comment markers inside string values, such as the `$schema` URL, are left untouched. - Download the pinned `uv` 0.12.1 exporter from the official GitHub Releases URL instead of `releases.astral.sh`, which now returns HTTP 403 and blocks org-wide OpenCode `coverage-evidence`. The SHA-256 pin is unchanged. The opener may follow one hop onto `release-assets.githubusercontent.com` or `objects.githubusercontent.com` and still rejects every other host, userinfo, non-HTTPS scheme, and nondefault port (ContextualWisdomLab/.github#1109). diff --git a/docs/doctoring/orphaned-workflow-lifecycle.md b/docs/doctoring/orphaned-workflow-lifecycle.md index 5ad9a1263d..531269dea0 100644 --- a/docs/doctoring/orphaned-workflow-lifecycle.md +++ b/docs/doctoring/orphaned-workflow-lifecycle.md @@ -41,9 +41,11 @@ reviewer cannot treat "the YAML is gone" as "no writer remains enabled." claim. Operational identities (repository, workflow path, workflow ID) are not masked as PII. 7. Confirmed repository owner routes are maintained as an explicit, - linkable registry from live fleet evidence. The scanner does not infer - issue numbers, create issues, or convert an absent owner route into a - passing result. + linkable registry from live fleet evidence. Repository slugs are matched + case-insensitively, and both `orphan_active` and `orphan_disabled` + classifications retain the route. The scanner does not infer issue + numbers, create issues, or convert an absent owner route into a passing + result. ## Trust boundary diff --git a/scripts/ci/inventory_orphaned_workflows.py b/scripts/ci/inventory_orphaned_workflows.py index 77d88509d8..e100bb93b9 100755 --- a/scripts/ci/inventory_orphaned_workflows.py +++ b/scripts/ci/inventory_orphaned_workflows.py @@ -101,7 +101,7 @@ def parse_link_has_next(link_header: object) -> bool: def decode_registry_path(path: object) -> str: - """Decode one percent-encoding pass and reject traversal disguises.""" + """Validate a canonical workflow path and reject traversal disguises.""" if not isinstance(path, str) or not path or "\x00" in path: raise InventoryError("workflow path is missing or NUL-bearing") if "\\" in path: @@ -249,7 +249,15 @@ def assert_default_branch_bound(start_sha: object, end_sha: object) -> str: def owner_issue_for(repository: str) -> str | None: """Return the known owning issue for a fleet repository, if any.""" - return KNOWN_OWNER_ISSUES.get(repository) + repository_key = repository.casefold() + return next( + ( + issue + for name, issue in KNOWN_OWNER_ISSUES.items() + if name.casefold() == repository_key + ), + None, + ) def inventory_repository(record: Mapping[str, Any]) -> dict[str, Any]: @@ -303,7 +311,7 @@ def inventory_repository(record: Mapping[str, Any]) -> dict[str, Any]: "default_branch_sha": sha, "classification": classification, } - if classification == "orphan_active": + if classification in {"orphan_active", "orphan_disabled"}: owner = owner_issue_for(name) if owner is not None: item["owner_issue"] = owner diff --git a/scripts/ci/organization_commercial_readiness_loop.py b/scripts/ci/organization_commercial_readiness_loop.py index c00cfa1e0a..3cc249139c 100644 --- a/scripts/ci/organization_commercial_readiness_loop.py +++ b/scripts/ci/organization_commercial_readiness_loop.py @@ -239,6 +239,7 @@ class GitHubClient: """Use the GitHub CLI as an authenticated, bounded REST transport.""" def __init__(self, token: str, *, timeout_seconds: int = 60) -> None: + """Initialize one client with an explicit token and bounded timeout.""" if not token: raise GitHubError("GH_TOKEN is required for organization coordination") self._token = token @@ -853,4 +854,4 @@ def main( if __name__ == "__main__": # pragma: no cover - exercised through main() - raise SystemExit(main()) \ No newline at end of file + raise SystemExit(main()) diff --git a/tests/test_inventory_orphaned_workflows.py b/tests/test_inventory_orphaned_workflows.py index 1ae0e2ea15..7a512ca13e 100644 --- a/tests/test_inventory_orphaned_workflows.py +++ b/tests/test_inventory_orphaned_workflows.py @@ -111,6 +111,10 @@ def test_decode_registry_path_rejects_encoding_and_traversal() -> None: inventory.decode_registry_path(".github/workflows/%2e%2e/ci.yml") with pytest.raises(inventory.InventoryError, match="traversal"): inventory.decode_registry_path(".github/workflows/../../secret.yml") + assert ( + inventory.decode_registry_path(".github/workflows//./ci.yml") + == ".github/workflows//./ci.yml" + ) def test_path_predicates() -> None: @@ -348,6 +352,24 @@ def test_owner_issue_for_known_fleet() -> None: == "ContextualWisdomLab/appguardrail#929" ) assert inventory.owner_issue_for("naruon") == "ContextualWisdomLab/naruon#1324" + assert inventory.owner_issue_for("APPGUARDRAIL") == ( + "ContextualWisdomLab/appguardrail#929" + ) + + +def test_disabled_orphan_routes_to_owner_issue() -> None: + """Disabled orphan evidence keeps its explicit owner route.""" + result = inventory.inventory_repository( + _repo( + "appguardrail", + [_workflow(14, ".github/workflows/finalize-once.yml", state="deleted")], + [], + ) + ) + assert result["records"][0]["classification"] == "orphan_disabled" + assert result["records"][0]["owner_issue"] == ( + "ContextualWisdomLab/appguardrail#929" + ) def test_owner_issue_registry_covers_confirmed_fleet() -> None: From 8d141d51d5b891fda7e8638164f64d5f6fed5ea8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 18:44:08 +0900 Subject: [PATCH 07/27] docs: clarify workflow path validation --- scripts/ci/inventory_orphaned_workflows.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci/inventory_orphaned_workflows.py b/scripts/ci/inventory_orphaned_workflows.py index e100bb93b9..5e1a867519 100755 --- a/scripts/ci/inventory_orphaned_workflows.py +++ b/scripts/ci/inventory_orphaned_workflows.py @@ -101,7 +101,7 @@ def parse_link_has_next(link_header: object) -> bool: def decode_registry_path(path: object) -> str: - """Validate a canonical workflow path and reject traversal disguises.""" + """Validate a workflow path and reject traversal disguises.""" if not isinstance(path, str) or not path or "\x00" in path: raise InventoryError("workflow path is missing or NUL-bearing") if "\\" in path: From 84b84aededaf25d88441121fd8c171a94e13eac9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 03:24:18 -0700 Subject: [PATCH 08/27] fix(actions): bind workflow inventory to consumed pages --- docs/doctoring/orphaned-workflow-lifecycle.md | 26 ++++---- scripts/ci/inventory_orphaned_workflows.py | 26 ++++++-- tests/test_inventory_orphaned_workflows.py | 64 +++++++++++++++++-- 3 files changed, 94 insertions(+), 22 deletions(-) diff --git a/docs/doctoring/orphaned-workflow-lifecycle.md b/docs/doctoring/orphaned-workflow-lifecycle.md index 531269dea0..2ecf74b09d 100644 --- a/docs/doctoring/orphaned-workflow-lifecycle.md +++ b/docs/doctoring/orphaned-workflow-lifecycle.md @@ -49,15 +49,17 @@ reviewer cannot treat "the YAML is gone" as "no writer remains enabled." ## Trust boundary -The inventory consumes only a caller-supplied fixture or a least-privilege -read of the Actions registry and git tree. It does not receive repository -write permission, `secrets: inherit`, or a guessed PAT. GitHub-owned -`dynamic/` identities are never treated as deleted repository files. - -MITRE CWE-200 describes exposure of sensitive information when an observer -cannot tell which control-plane writers are enabled. CWE-862 describes -missing authorization when a registry mutation is performed without a -reviewed operator path. This increment closes the visibility gap and +The inventory CLI consumes only a caller-collected JSON fixture. A separate +least-privilege caller may read the Actions registry and git tree to assemble +that fixture, but this script performs no live API reads. Neither boundary +receives repository write permission, `secrets: inherit`, or a guessed PAT. +GitHub-owned `dynamic/` identities are never treated as deleted repository +files. + +The ledger improves operational visibility into enabled control-plane writers; +that visibility gap is not itself CWE-200 sensitive-information exposure. +CWE-862 describes missing authorization when a registry mutation is performed +without a reviewed operator path. This increment closes the visibility gap and refuses the mutation. ## Operator contract @@ -80,9 +82,9 @@ classification without disabling sibling-repository writers. ## Rollback -Delete `scripts/ci/inventory_orphaned_workflows.py` and its tests. No -registry state is mutated, so rollback does not re-enable or disable -workflows. +Rollback removes the inventory script, focused tests, schema example, +architecture entry, changelog entry, and this doctoring record together. No +registry state is mutated, so rollback does not re-enable or disable workflows. ## References diff --git a/scripts/ci/inventory_orphaned_workflows.py b/scripts/ci/inventory_orphaned_workflows.py index 5e1a867519..9083731e0d 100755 --- a/scripts/ci/inventory_orphaned_workflows.py +++ b/scripts/ci/inventory_orphaned_workflows.py @@ -100,6 +100,18 @@ def parse_link_has_next(link_header: object) -> bool: return 'rel="next"' in link_header +def page_link_header(page: Mapping[str, Any]) -> object: + """Return one case-insensitive HTTP ``Link`` field or fail closed.""" + values = [ + value + for key, value in page.items() + if isinstance(key, str) and key.casefold() == "link" + ] + if len(values) > 1: + raise InventoryError("workflow page has ambiguous Link headers") + return values[0] if values else None + + def decode_registry_path(path: object) -> str: """Validate a workflow path and reject traversal disguises.""" if not isinstance(path, str) or not path or "\x00" in path: @@ -188,18 +200,20 @@ def collect_workflow_pages( pages: Iterable[Mapping[str, Any]], *, per_page: int = PER_PAGE_DEFAULT, -) -> list[dict[str, Any]]: - """Merge workflow pages and fail closed on truncation or count drift.""" +) -> tuple[list[dict[str, Any]], int]: + """Merge workflow pages and return the number actually consumed.""" if per_page <= 0: raise InventoryError("per_page must be positive") collected: list[dict[str, Any]] = [] expected_total: int | None = None saw_page = False + consumed_pages = 0 open_next = False for page in pages: saw_page = True if not isinstance(page, Mapping): raise InventoryError("workflow page is not an object") + consumed_pages += 1 workflows = page.get("workflows") total_count = page.get("total_count") if not isinstance(workflows, list): @@ -218,7 +232,7 @@ def collect_workflow_pages( collected.extend(workflows) link_next = page.get("_link_next") if link_next is None: - open_next = parse_link_has_next(page.get("link")) + open_next = parse_link_has_next(page_link_header(page)) elif isinstance(link_next, bool): open_next = link_next else: @@ -235,7 +249,7 @@ def collect_workflow_pages( if expected_total is None or len(collected) != expected_total: raise InventoryError("pagination truncated or incomplete") assert_unique_workflow_ids(collected) - return collected + return collected, consumed_pages def assert_default_branch_bound(start_sha: object, end_sha: object) -> str: @@ -286,7 +300,7 @@ def inventory_repository(record: Mapping[str, Any]) -> dict[str, Any]: pages = record.get("workflow_pages") if not isinstance(pages, list): raise InventoryError(f"{name} workflow_pages must be a list") - workflows = collect_workflow_pages(pages) + workflows, page_count = collect_workflow_pages(pages) records: list[dict[str, Any]] = [] for workflow in workflows: path = decode_registry_path(workflow.get("path")) @@ -320,7 +334,7 @@ def inventory_repository(record: Mapping[str, Any]) -> dict[str, Any]: "repository": name, "default_branch": record.get("default_branch"), "default_branch_sha": sha, - "page_count": len(pages), + "page_count": page_count, "workflow_count": len(records), "records": records, } diff --git a/tests/test_inventory_orphaned_workflows.py b/tests/test_inventory_orphaned_workflows.py index 7a512ca13e..b571ef21c1 100644 --- a/tests/test_inventory_orphaned_workflows.py +++ b/tests/test_inventory_orphaned_workflows.py @@ -101,9 +101,9 @@ def test_decode_registry_path_rejects_encoding_and_traversal() -> None: inventory.decode_registry_path(".github/workflows/ci.yml") == ".github/workflows/ci.yml" ) - with pytest.raises(inventory.InventoryError, match="NUL|missing"): + with pytest.raises(inventory.InventoryError, match=r"NUL|missing"): inventory.decode_registry_path("") - with pytest.raises(inventory.InventoryError, match="NUL|missing"): + with pytest.raises(inventory.InventoryError, match=r"NUL|missing"): inventory.decode_registry_path(".github/workflows/ci.yml\x00") with pytest.raises(inventory.InventoryError, match="backslash"): inventory.decode_registry_path(".github\\workflows\\ci.yml") @@ -231,7 +231,11 @@ def test_collect_workflow_pages_fail_closed() -> None: "workflows": [_workflow(2, ".github/workflows/b.yml")], "_link_next": False, } - assert len(inventory.collect_workflow_pages([first, second], per_page=1)) == 2 + workflows, page_count = inventory.collect_workflow_pages( + [first, second], per_page=1 + ) + assert len(workflows) == 2 + assert page_count == 2 with pytest.raises(inventory.InventoryError, match="per_page"): inventory.collect_workflow_pages([], per_page=0) @@ -310,7 +314,7 @@ def test_collect_workflow_pages_fail_closed() -> None: "workflows": [], "link": '; rel="prev"', } - assert inventory.collect_workflow_pages([linked]) == [] + assert inventory.collect_workflow_pages([linked]) == ([], 1) with pytest.raises(inventory.InventoryError, match="reused workflow id"): inventory.collect_workflow_pages( [ @@ -336,6 +340,58 @@ def test_collect_workflow_pages_fail_closed() -> None: ) +def test_inventory_records_only_consumed_pages() -> None: + """The audit ledger never counts unconsumed trailing page fixtures.""" + terminal = { + "total_count": 1, + "workflows": [_workflow(1, ".github/workflows/ci.yml")], + "_link_next": False, + } + trailing = { + "total_count": 1, + "workflows": [_workflow(2, ".github/workflows/unread.yml")], + "_link_next": False, + } + + result = inventory.inventory_repository( + _repo( + "naruon", + [], + [".github/workflows/ci.yml"], + pages=[terminal, trailing], + ) + ) + + assert result["page_count"] == 1 + assert result["workflow_count"] == 1 + assert result["records"][0]["workflow_id"] == 1 + + +def test_collect_workflow_pages_accepts_case_insensitive_link_header() -> None: + """GitHub's canonical ``Link`` header spelling drives pagination.""" + first = { + "total_count": 2, + "workflows": [_workflow(1, ".github/workflows/a.yml")], + "Link": '; rel="next"', + } + second = { + "total_count": 2, + "workflows": [_workflow(2, ".github/workflows/b.yml")], + "Link": '; rel="prev"', + } + + workflows, page_count = inventory.collect_workflow_pages( + [first, second], per_page=1 + ) + + assert [workflow["id"] for workflow in workflows] == [1, 2] + assert page_count == 2 + + ambiguous = dict(first, link='; rel="next"') + with pytest.raises(inventory.InventoryError, match="ambiguous Link"): + inventory.collect_workflow_pages([ambiguous], per_page=1) + + def test_assert_default_branch_bound() -> None: """A moved or malformed default-branch SHA aborts the inventory.""" assert inventory.assert_default_branch_bound(SHA, SHA) == SHA From 71c0cc890bd06a0ff97aa10267cb075b02c62f9e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:16:06 +0900 Subject: [PATCH 09/27] fix: fail closed on partial workflow inventories --- CHANGELOG.md | 3 +++ docs/doctoring/orphaned-workflow-lifecycle.md | 7 +++++-- ...wl-workflow-lifecycle-ledger-v1.example.json | 1 + scripts/ci/inventory_orphaned_workflows.py | 5 +++++ tests/test_inventory_orphaned_workflows.py | 17 +++++++++++++++++ 5 files changed, 31 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22801c5f04..a4e91481ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,9 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Require orphan-workflow lifecycle fixtures to prove complete organization + repository visibility before emitting an audit ledger, preventing partial + inventories from overstating fleet coverage. - Refused PR Review Merge Scheduler head mutations, `update-branch` and the last-push approval head restamp, whenever the resolved mutation credential is the workflow `GITHUB_TOKEN`. GitHub starts no workflow run for events created with that credential, so the moved head collected no current-head required checks and the PR stayed permanently `BLOCKED` with a `github-actions[bot]` merge commit that no later scheduler run could repair, because the branch was no longer behind. The scheduler now waits with `head_mutation_credential_upgrade` guidance naming `PR_REVIEW_MERGE_TOKEN`, `OPENCODE_APPROVE_TOKEN`, and the OpenCode app token exchange. - Parsed `opencode.jsonc` as JSONC (stripping `//` and `/* */` comments outside string literals) in the reasoning-effort guard and its contract tests, instead of raw `json.loads`, which rejected the file the moment it carried its first explanatory comment (added for the `contextual-orchestrator` provider block) with `Expecting property name enclosed in double quotes`. Comment markers inside string values, such as the `$schema` URL, are left untouched. - Download the pinned `uv` 0.12.1 exporter from the official GitHub Releases URL instead of `releases.astral.sh`, which now returns HTTP 403 and blocks org-wide OpenCode `coverage-evidence`. The SHA-256 pin is unchanged. The opener may follow one hop onto `release-assets.githubusercontent.com` or `objects.githubusercontent.com` and still rejects every other host, userinfo, non-HTTPS scheme, and nondefault port (ContextualWisdomLab/.github#1109). diff --git a/docs/doctoring/orphaned-workflow-lifecycle.md b/docs/doctoring/orphaned-workflow-lifecycle.md index 2ecf74b09d..9990e2d749 100644 --- a/docs/doctoring/orphaned-workflow-lifecycle.md +++ b/docs/doctoring/orphaned-workflow-lifecycle.md @@ -64,8 +64,11 @@ refuses the mutation. ## Operator contract -Feed a JSON payload with `organization`, `observed_at`, and one object -per visible non-archived repository. Each repository must include the +Feed a JSON payload with `organization`, `observed_at`, +`repository_inventory_complete: true`, and one object per visible non-archived +repository. The completeness flag is mandatory: a partial repository list must +fail closed instead of producing a ledger that overstates fleet coverage. Each +repository must include the start and end default-branch SHAs, the exact tree paths at that SHA, and complete workflow pages (`total_count`, `workflows`, and either `_link_next` or a GitHub `Link` header). Archived repositories are skipped. diff --git a/schemas/examples/cwl-workflow-lifecycle-ledger-v1.example.json b/schemas/examples/cwl-workflow-lifecycle-ledger-v1.example.json index 3b1f92af49..026254c1fe 100644 --- a/schemas/examples/cwl-workflow-lifecycle-ledger-v1.example.json +++ b/schemas/examples/cwl-workflow-lifecycle-ledger-v1.example.json @@ -1,6 +1,7 @@ { "organization": "ContextualWisdomLab", "observed_at": "2026-08-16T12:00:00Z", + "repository_inventory_complete": true, "repositories": [ { "name": "appguardrail", diff --git a/scripts/ci/inventory_orphaned_workflows.py b/scripts/ci/inventory_orphaned_workflows.py index 9083731e0d..dc7eafc3c4 100755 --- a/scripts/ci/inventory_orphaned_workflows.py +++ b/scripts/ci/inventory_orphaned_workflows.py @@ -387,6 +387,10 @@ def inventory_organization(payload: Mapping[str, Any]) -> dict[str, Any]: if not isinstance(record, Mapping): raise InventoryError("repository record is not an object") inventories.append(inventory_repository(record)) + if payload.get("repository_inventory_complete") is not True: + raise InventoryError( + "repository inventory is incomplete; caller must prove full visibility" + ) records = [ item for inventory in inventories @@ -403,6 +407,7 @@ def inventory_organization(payload: Mapping[str, Any]) -> dict[str, Any]: "capability": CAPABILITY, "organization": organization, "observed_at": observed_at, + "repository_inventory_complete": True, "assurance_posture": { "csap": "design_constraint", "soc2": "design_constraint", diff --git a/tests/test_inventory_orphaned_workflows.py b/tests/test_inventory_orphaned_workflows.py index b571ef21c1..a38db6cf5f 100644 --- a/tests/test_inventory_orphaned_workflows.py +++ b/tests/test_inventory_orphaned_workflows.py @@ -59,6 +59,7 @@ def _payload(repositories: list[dict[str, Any]]) -> dict[str, Any]: return { "organization": "ContextualWisdomLab", "observed_at": "2026-08-16T12:00:00Z", + "repository_inventory_complete": True, "repositories": repositories, } @@ -588,6 +589,7 @@ def test_inventory_organization_and_unknown_classification( ) ledger = inventory.inventory_organization(payload) assert ledger["schema_version"] == "1" + assert ledger["repository_inventory_complete"] is True assert ledger["assurance_posture"]["certification_claim"] is False assert ledger["assurance_posture"]["operational_pii_mask"] is False assert ledger["counts"]["orphan_active"] == 1 @@ -634,6 +636,21 @@ def lie(**_kwargs: object) -> str: ) +def test_inventory_requires_complete_repository_visibility() -> None: + """A partial organization repository list cannot produce an audit ledger.""" + payload = { + "organization": "ContextualWisdomLab", + "observed_at": "2026-08-16T12:00:00Z", + "repositories": [_repo("naruon", [], [])], + } + with pytest.raises(inventory.InventoryError, match="repository inventory"): + inventory.inventory_organization(payload) + + payload["repository_inventory_complete"] = False + with pytest.raises(inventory.InventoryError, match="repository inventory"): + inventory.inventory_organization(payload) + + def test_write_ledger_and_main(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: """The CLI writes a ledger, fails closed, and never mutates the registry.""" payload = _payload( From 740379ab33dbb414b2265411d7a557b7f966f57c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 04:13:16 +0900 Subject: [PATCH 10/27] test: align scheduler contract and audit runtime --- requirements-pip-audit-ci-hashes.txt | 6 +++--- scripts/ci/test_strix_quick_gate.sh | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements-pip-audit-ci-hashes.txt b/requirements-pip-audit-ci-hashes.txt index ade197a49a..0ae099d8fe 100644 --- a/requirements-pip-audit-ci-hashes.txt +++ b/requirements-pip-audit-ci-hashes.txt @@ -213,9 +213,9 @@ packaging==26.2 \ # via # pip-audit # pip-requirements-parser -pip==26.1.2 \ - --hash=sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab \ - --hash=sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605 +pip==26.2.1 \ + --hash=sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e \ + --hash=sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f # via pip-api pip-api==0.0.34 \ --hash=sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb \ diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index ac9ce1d8bd..04f58bf316 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1506,8 +1506,8 @@ assert_pr_review_merge_scheduler_uses_github_actions_bot_token() { assert_file_contains "$workflow_file" "github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number)" "scheduler scopes pull_request_target concurrency to the active PR" assert_file_contains "$workflow_file" "github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number && format('pr-{0}', github.event.workflow_run.pull_requests[0].number)" "scheduler scopes workflow_run concurrency to the completed review PR" assert_file_contains "$workflow_file" "github.event_name == 'schedule' && format('schedule-{0}', github.event.schedule)" "scheduler isolates the 15-minute organization sweep from the separate 30-minute scheduled scan" - assert_file_contains "$workflow_file" "github.event_name == 'repository_dispatch' && github.run_id" "scheduler keeps manual queue scans isolated per run" - assert_file_contains "$workflow_file" "cancel-in-progress: \${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'repository_dispatch' }}" "scheduler cancels stale PR/review/manual queue scans instead of accumulating merge/update attempts" + assert_file_contains "$workflow_file" "github.event_name == 'repository_dispatch' && format('repo-dispatch-{0}', github.repository)" "scheduler keeps manual queue scans isolated per repository dispatch target" + assert_file_contains "$workflow_file" "cancel-in-progress: \${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'repository_dispatch' || (github.event_name == 'workflow_run' && !github.event.workflow_run.pull_requests[0].number) }}" "scheduler cancels stale PR/review/manual queue scans instead of accumulating merge/update attempts" assert_file_contains "$workflow_file" "timeout-minutes: 60" "organization sweep has enough headroom to finish the complete repository walk" assert_file_contains "$workflow_file" "ORG_SWEEP_TRIGGER_REVIEWS: \${{ github.event_name == 'schedule' ||" "scheduled organization sweeps retry missing current-head OpenCode reviews" assert_file_contains "$workflow_file" "ORG_SWEEP_ENABLE_AUTO_MERGE: \${{ github.event_name == 'schedule' ||" "scheduled organization sweeps merge approved current heads" From 8e24b8b97640b8ff5145fd494aeb07a5e706d4fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 12:58:41 +0900 Subject: [PATCH 11/27] fix: collect live workflow lifecycle evidence --- CHANGELOG.md | 3 + docs/doctoring/orphaned-workflow-lifecycle.md | 28 ++- scripts/ci/inventory_orphaned_workflows.py | 193 +++++++++++++++++- tests/test_inventory_orphaned_workflows.py | 95 +++++++++ 4 files changed, 307 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 330dbc2fea..450d07202a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -890,6 +890,9 @@ Semantic Versioning where the repository publishes a release. - Require orphan-workflow lifecycle fixtures to prove complete organization repository visibility before emitting an audit ledger, preventing partial inventories from overstating fleet coverage. +- Add a live, paginated organization workflow inventory with content-bound API + receipts, exact default-head revalidation, and a separately reviewed, + ledger-bound operator disable primitive. - Keep the central required-workflow coverage placeholder from superseding a failed repository-dispatch coverage run; coverage retry and merge decisions now use authoritative execution evidence for the central scheduler. diff --git a/docs/doctoring/orphaned-workflow-lifecycle.md b/docs/doctoring/orphaned-workflow-lifecycle.md index 9990e2d749..815840bd84 100644 --- a/docs/doctoring/orphaned-workflow-lifecycle.md +++ b/docs/doctoring/orphaned-workflow-lifecycle.md @@ -49,10 +49,11 @@ reviewer cannot treat "the YAML is gone" as "no writer remains enabled." ## Trust boundary -The inventory CLI consumes only a caller-collected JSON fixture. A separate -least-privilege caller may read the Actions registry and git tree to assemble -that fixture, but this script performs no live API reads. Neither boundary -receives repository write permission, `secrets: inherit`, or a guessed PAT. +The production CLI uses `--live` with the established central `GH_TOKEN` +transport. It paginates all visible repositories and workflows, rejects a +truncated recursive tree, and re-reads each default-branch head. A mandatory +API receipt file content-binds every read. Fixture input remains available for +deterministic tests. Neither boundary receives `secrets: inherit` or a guessed PAT. GitHub-owned `dynamic/` identities are never treated as deleted repository files. @@ -64,7 +65,15 @@ refuses the mutation. ## Operator contract -Feed a JSON payload with `organization`, `observed_at`, +For a live read-only sweep, run: + +```bash +python3 scripts/ci/inventory_orphaned_workflows.py --live \ + --output /tmp/workflow-lifecycle-ledger.json \ + --receipt-output /tmp/workflow-lifecycle-api-receipts.json +``` + +For fixture verification, feed a JSON payload with `organization`, `observed_at`, `repository_inventory_complete: true`, and one object per visible non-archived repository. The completeness flag is mandatory: a partial repository list must fail closed instead of producing a ledger that overstates fleet coverage. Each @@ -79,9 +88,12 @@ python3 scripts/ci/inventory_orphaned_workflows.py \ --output /tmp/workflow-lifecycle-ledger.json ``` -`--fail-on-orphan-active` is reserved for a later reviewed live sweep. -This increment's default is to emit the ledger so CI can prove -classification without disabling sibling-repository writers. +The scanner never mutates. The separate `disable_confirmed_orphan` operator +primitive accepts only an immutable `orphan_active` record and an identical +fresh head SHA, then addresses its exact numeric workflow ID. After a reviewed +operator pass, rerun the organization sweep and retain both receipt sets. +Known AppGuardrail, Clearfolio, and DiskSage owner routes bind the same live +evidence to their governance issues without heuristic issue creation. ## Rollback diff --git a/scripts/ci/inventory_orphaned_workflows.py b/scripts/ci/inventory_orphaned_workflows.py index dc7eafc3c4..7d0d3a4479 100755 --- a/scripts/ci/inventory_orphaned_workflows.py +++ b/scripts/ci/inventory_orphaned_workflows.py @@ -12,18 +12,22 @@ from __future__ import annotations import argparse +import hashlib import json import re import sys from collections.abc import Callable, Iterable, Mapping, Sequence +from datetime import UTC, datetime from pathlib import Path -from typing import Any +from typing import Any, Protocol +from urllib.parse import quote SCHEMA_VERSION = "1" CAPABILITY = "workflow_lifecycle_inventory" MAX_PAYLOAD_BYTES = 1_048_576 PER_PAGE_DEFAULT = 100 HEX_SHA = re.compile(r"^[0-9a-f]{40}$") +HEX_SHA256 = re.compile(r"^[0-9a-f]{64}$") REPO_SLUG = re.compile(r"^[A-Za-z0-9._-]+$") REPO_WORKFLOW_NAME = re.compile(r"^[^/]+\.(yml|yaml)$") DYNAMIC_PREFIXES = ("dynamic/",) @@ -73,6 +77,98 @@ class InventoryError(ValueError): """Fail-closed defect in workflow-lifecycle evidence.""" +class GitHubTransport(Protocol): + """Minimal authenticated REST transport shared with central coordination.""" + + def request(self, path: str, *, method: str = "GET", payload: Any = None) -> Any: + """Return the decoded JSON response for one bounded request.""" + + +def _live_get(client: GitHubTransport, path: str, receipts: list[dict[str, Any]]) -> Any: + """Read one endpoint and append a content-bound API receipt.""" + try: + body = client.request(path) + except Exception as exc: + raise InventoryError(f"live GitHub visibility failed for {path}: {type(exc).__name__}") from exc + encoded = json.dumps(body, sort_keys=True, separators=(",", ":")).encode() + receipts.append({"method": "GET", "path": path, "sha256": hashlib.sha256(encoded).hexdigest()}) + return body + + +def collect_live_organization( + client: GitHubTransport, + organization: str = "ContextualWisdomLab", +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + """Collect a complete live fleet payload with exact-head revalidation.""" + if organization != "ContextualWisdomLab": + raise InventoryError("organization must be ContextualWisdomLab") + receipts: list[dict[str, Any]] = [] + repositories: list[Mapping[str, Any]] = [] + page = 1 + while True: + path = f"/orgs/{organization}/repos?type=all&sort=full_name&per_page=100&page={page}" + batch = _live_get(client, path, receipts) + if not isinstance(batch, list) or any(not isinstance(item, Mapping) for item in batch): + raise InventoryError("repository inventory response is malformed") + repositories.extend(batch) + if len(batch) < 100: + break + page += 1 + if not repositories: + raise InventoryError("live repository inventory is empty") + records: list[dict[str, Any]] = [] + for repository in repositories: + name = repository.get("name") + full_name = repository.get("full_name") + archived = repository.get("archived") + default_branch = repository.get("default_branch") + if not isinstance(name, str) or not isinstance(full_name, str): + raise InventoryError("repository identity is malformed") + if archived is True: + records.append({"name": name, "archived": True}) + continue + if archived is not False or not isinstance(default_branch, str) or not default_branch: + raise InventoryError(f"{name} repository metadata is incomplete") + branch = quote(default_branch, safe="") + commit_path = f"/repos/{full_name}/commits/{branch}" + start = _live_get(client, commit_path, receipts) + start_sha = start.get("sha") if isinstance(start, Mapping) else None + if not is_exact_sha(start_sha): + raise InventoryError(f"{name} default-branch SHA is invalid") + tree_body = _live_get(client, f"/repos/{full_name}/git/trees/{start_sha}?recursive=1", receipts) + if not isinstance(tree_body, Mapping) or tree_body.get("truncated") is not False: + raise InventoryError(f"{name} default-branch tree is truncated or malformed") + tree = tree_body.get("tree") + if not isinstance(tree, list): + raise InventoryError(f"{name} default-branch tree is malformed") + tree_paths = [item.get("path") for item in tree if isinstance(item, Mapping) and item.get("type") == "blob"] + if any(not isinstance(path, str) for path in tree_paths): + raise InventoryError(f"{name} default-branch tree path is malformed") + workflow_pages: list[dict[str, Any]] = [] + workflow_page = 1 + while True: + workflow_path = f"/repos/{full_name}/actions/workflows?per_page=100&page={workflow_page}" + body = _live_get(client, workflow_path, receipts) + if not isinstance(body, dict) or not isinstance(body.get("workflows"), list): + raise InventoryError(f"{name} workflow inventory is malformed") + total = body.get("total_count") + if not isinstance(total, int) or total < 0: + raise InventoryError(f"{name} workflow total_count is malformed") + consumed = sum(len(item["workflows"]) for item in workflow_pages) + len(body["workflows"]) + has_next = consumed < total + workflow_pages.append({**body, "_link_next": has_next}) + if not has_next: + break + if not body["workflows"]: + raise InventoryError(f"{name} workflow pagination is truncated") + workflow_page += 1 + end = _live_get(client, commit_path, receipts) + end_sha = end.get("sha") if isinstance(end, Mapping) else None + assert_default_branch_bound(start_sha, end_sha) + records.append({"name": name, "archived": False, "default_branch": default_branch, "default_branch_sha": start_sha, "default_branch_sha_after": end_sha, "tree_paths": tree_paths, "workflow_pages": workflow_pages}) + return ({"organization": organization, "observed_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"), "repository_inventory_complete": True, "repositories": records}, receipts) + + def reject_forbidden_token(name: str) -> None: """Refuse GitHub Copilot tokens and other forbidden credentials.""" if name in FORBIDDEN_TOKENS: @@ -428,13 +524,81 @@ def write_ledger(ledger: Mapping[str, Any], output: Path | None) -> str: return text +def disable_confirmed_orphan( + client: GitHubTransport, + record: Mapping[str, Any], + *, + confirmed_head_sha: str, +) -> None: + """Disable only one reviewed, ledger-bound orphan after a fresh head check.""" + if record.get("classification") != "orphan_active": + raise InventoryError("operator may disable only a ledger orphan_active record") + repository = record.get("repository") + workflow_id = record.get("workflow_id") + ledger_sha = record.get("default_branch_sha") + if not isinstance(repository, str) or not isinstance(workflow_id, int): + raise InventoryError("operator record identity is malformed") + assert_default_branch_bound(ledger_sha, confirmed_head_sha) + try: + client.request( + f"/repos/ContextualWisdomLab/{repository}/actions/workflows/{workflow_id}/disable", + method="PUT", + ) + except Exception as exc: + raise InventoryError(f"operator disable failed closed: {type(exc).__name__}") from exc + + +def publish_owner_issue( + client: GitHubTransport, + record: Mapping[str, Any], + *, + ledger_sha256: str, +) -> str: + """Create or update the bounded owner issue for one confirmed orphan.""" + if record.get("classification") != "orphan_active" or HEX_SHA256.fullmatch(ledger_sha256) is None: + raise InventoryError("issue publication requires an orphan_active and ledger digest") + repository = record.get("repository") + if not isinstance(repository, str) or REPO_SLUG.fullmatch(repository) is None: + raise InventoryError("issue publication repository is malformed") + issue = owner_issue_for(repository) + body = ( + "\n" + f"Exact workflow registry evidence: `{record.get('workflow_id')}` / " + f"`{record.get('path')}` at `{record.get('default_branch_sha')}`.\n" + f"Ledger SHA-256: `{ledger_sha256}`.\n" + ) + try: + if issue is not None: + number = issue.rsplit("#", 1)[1] + client.request( + f"/repos/ContextualWisdomLab/{repository}/issues/{number}/comments", + method="POST", + payload={"body": body}, + ) + return issue + created = client.request( + f"/repos/ContextualWisdomLab/{repository}/issues", + method="POST", + payload={"title": "Disable orphaned workflow registry identity", "body": body}, + ) + except Exception as exc: + raise InventoryError(f"owner issue publication failed closed: {type(exc).__name__}") from exc + number = created.get("number") if isinstance(created, Mapping) else None + if not isinstance(number, int) or number <= 0: + raise InventoryError("owner issue creation returned no issue number") + return f"ContextualWisdomLab/{repository}#{number}" + + def main(argv: Sequence[str] | None = None) -> int: """Load a fixture payload, emit a ledger, and optionally fail on orphans.""" parser = argparse.ArgumentParser( description="Classify GitHub Actions workflow registry identities." ) - parser.add_argument("--payload", required=True, help="JSON inventory fixture") + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--payload", help="JSON inventory fixture") + source.add_argument("--live", action="store_true", help="collect the live organization inventory") parser.add_argument("--output", help="optional ledger output path") + parser.add_argument("--receipt-output", help="required API receipt output for --live") parser.add_argument( "--fail-on-orphan-active", action="store_true", @@ -452,8 +616,29 @@ def main(argv: Sequence[str] | None = None) -> int: print(f"ERROR: {exc}", file=sys.stderr) return 2 try: - raw = Path(args.payload).read_bytes() - payload = load_payload_bytes(raw) + if args.live: + if not args.receipt_output: + raise InventoryError("--receipt-output is required for --live") + try: + from scripts.ci.organization_commercial_readiness_loop import ( # pylint: disable=import-outside-toplevel + GitHubClient, + ) + except ModuleNotFoundError: + from organization_commercial_readiness_loop import GitHubClient # type: ignore[no-redef] # pylint: disable=import-outside-toplevel + + try: + client = GitHubClient.from_environment() + except Exception as exc: + raise InventoryError( + f"live GitHub credential unavailable: {type(exc).__name__}" + ) from exc + payload, receipts = collect_live_organization(client) + Path(args.receipt_output).write_text( + json.dumps(receipts, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + else: + raw = Path(args.payload).read_bytes() + payload = load_payload_bytes(raw) ledger = inventory_organization(payload) except FileNotFoundError as exc: print(f"ERROR: payload not found: {exc}", file=sys.stderr) diff --git a/tests/test_inventory_orphaned_workflows.py b/tests/test_inventory_orphaned_workflows.py index a38db6cf5f..4640517826 100644 --- a/tests/test_inventory_orphaned_workflows.py +++ b/tests/test_inventory_orphaned_workflows.py @@ -739,6 +739,101 @@ def test_example_fixture_classifies_without_masking_identities() -> None: assert classes["dynamic/pages/pages-build-deployment"] == "dynamic_owned" +class _LiveClient: + """Record deterministic live API calls for collector tests.""" + + def __init__(self, responses: dict[str, Any]) -> None: + self.responses = responses + self.calls: list[str] = [] + + def request(self, path: str, *, method: str = "GET", payload: Any = None) -> Any: + """Return one canned response and retain its exact request path.""" + if method in {"PUT", "POST"}: + self.calls.append(path) + return self.responses.get(path) + assert method == "GET" + assert payload is None + self.calls.append(path) + value = self.responses[path] + if path.endswith("/commits/main") and isinstance(value, list): + return value.pop(0) + return value + + +def test_collect_live_organization_paginates_and_rechecks_head() -> None: + """The live collector consumes every page and binds both head reads.""" + repo = "ContextualWisdomLab/appguardrail" + responses = { + "/orgs/ContextualWisdomLab/repos?type=all&sort=full_name&per_page=100&page=1": [ + {"name": "appguardrail", "full_name": repo, "archived": False, "default_branch": "main"} + ], + f"/repos/{repo}/commits/main": [{"sha": SHA}, {"sha": SHA}], + f"/repos/{repo}/git/trees/{SHA}?recursive=1": { + "truncated": False, + "tree": [{"type": "blob", "path": ".github/workflows/ci.yml"}], + }, + f"/repos/{repo}/actions/workflows?per_page=100&page=1": { + "total_count": 2, + "workflows": [ + _workflow(1, ".github/workflows/ci.yml"), + _workflow(2, ".github/workflows/gone.yml"), + ], + }, + } + client = _LiveClient(responses) + payload, receipts = inventory.collect_live_organization(client) + ledger = inventory.inventory_organization(payload) + assert ledger["counts"]["orphan_active"] == 1 + assert payload["repository_inventory_complete"] is True + assert len(receipts) == len(client.calls) + assert client.calls.count(f"/repos/{repo}/commits/main") == 2 + + +def test_collect_live_organization_fails_on_truncated_tree_or_head_move() -> None: + """Incomplete trees and a moving default branch never produce a ledger.""" + repo = "ContextualWisdomLab/naruon" + base = { + "/orgs/ContextualWisdomLab/repos?type=all&sort=full_name&per_page=100&page=1": [ + {"name": "naruon", "full_name": repo, "archived": False, "default_branch": "main"} + ], + f"/repos/{repo}/commits/main": [{"sha": SHA}, {"sha": SHA_B}], + f"/repos/{repo}/git/trees/{SHA}?recursive=1": {"truncated": False, "tree": []}, + f"/repos/{repo}/actions/workflows?per_page=100&page=1": {"total_count": 0, "workflows": []}, + } + with pytest.raises(inventory.InventoryError, match="moved"): + inventory.collect_live_organization(_LiveClient(base)) + base[f"/repos/{repo}/commits/main"] = [{"sha": SHA}, {"sha": SHA}] + base[f"/repos/{repo}/git/trees/{SHA}?recursive=1"] = {"truncated": True, "tree": []} + with pytest.raises(inventory.InventoryError, match="truncated"): + inventory.collect_live_organization(_LiveClient(base)) + + +def test_operator_disable_is_ledger_and_head_bound() -> None: + """The mutation path accepts only a reviewed orphan on its unchanged head.""" + record = {"repository": "appguardrail", "workflow_id": 9, "classification": "orphan_active", "default_branch_sha": SHA} + client = _LiveClient({}) + inventory.disable_confirmed_orphan(client, record, confirmed_head_sha=SHA) + assert client.calls == ["/repos/ContextualWisdomLab/appguardrail/actions/workflows/9/disable"] + with pytest.raises(inventory.InventoryError, match="only"): + inventory.disable_confirmed_orphan(client, {**record, "classification": "present_active"}, confirmed_head_sha=SHA) + with pytest.raises(inventory.InventoryError, match="moved"): + inventory.disable_confirmed_orphan(client, record, confirmed_head_sha=SHA_B) + + +def test_owner_issue_update_and_create_are_explicit() -> None: + """Known owners receive an update; unknown owners receive one bounded issue.""" + known = {"repository": "appguardrail", "workflow_id": 9, "path": ".github/workflows/gone.yml", "classification": "orphan_active", "default_branch_sha": SHA} + client = _LiveClient({}) + assert inventory.publish_owner_issue(client, known, ledger_sha256="c" * 64).endswith("#929") + assert client.calls[-1].endswith("/issues/929/comments") + unknown = {**known, "repository": "new-product"} + create_path = "/repos/ContextualWisdomLab/new-product/issues" + creator = _LiveClient({create_path: {"number": 41}}) + assert inventory.publish_owner_issue(creator, unknown, ledger_sha256="d" * 64).endswith("#41") + with pytest.raises(inventory.InventoryError, match="ledger digest"): + inventory.publish_owner_issue(client, known, ledger_sha256="bad") + + def test_known_fleet_fixture_routes_owner_issues() -> None: """The three named fleet incidents remain routed, not heuristically deleted.""" payload = _payload( From 9edc4e1cdd6899da2acac513effb04bf4ca719e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 13:16:10 +0900 Subject: [PATCH 12/27] test: enforce workflow lifecycle prevention contract --- ...orkflow-lifecycle-inventory-quality-ci.yml | 57 ++++++++++ .../workflow-lifecycle-inventory.yml | 75 +++++++++++++ ARCHITECTURE.md | 13 ++- CHANGELOG.md | 2 + docs/doctoring/orphaned-workflow-lifecycle.md | 6 + scripts/ci/inventory_orphaned_workflows.py | 4 +- tests/test_inventory_orphaned_workflows.py | 104 ++++++++++++++++++ ...t_workflow_lifecycle_inventory_workflow.py | 19 ++++ 8 files changed, 272 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/workflow-lifecycle-inventory-quality-ci.yml create mode 100644 .github/workflows/workflow-lifecycle-inventory.yml create mode 100644 tests/test_workflow_lifecycle_inventory_workflow.py diff --git a/.github/workflows/workflow-lifecycle-inventory-quality-ci.yml b/.github/workflows/workflow-lifecycle-inventory-quality-ci.yml new file mode 100644 index 0000000000..366a3e9480 --- /dev/null +++ b/.github/workflows/workflow-lifecycle-inventory-quality-ci.yml @@ -0,0 +1,57 @@ +name: Workflow Lifecycle Inventory Quality CI + +on: + pull_request: + branches: [main] + paths: + - ".github/workflows/workflow-lifecycle-inventory.yml" + - ".github/workflows/workflow-lifecycle-inventory-quality-ci.yml" + - "scripts/ci/inventory_orphaned_workflows.py" + - "tests/test_inventory_orphaned_workflows.py" + - "tests/test_workflow_lifecycle_inventory_workflow.py" + - "docs/doctoring/orphaned-workflow-lifecycle.md" + - "ARCHITECTURE.md" + - "CHANGELOG.md" + +permissions: + contents: read + +jobs: + exact-head-quality: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout exact pull-request head + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + + - name: Install hash-verified test tools + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + python -m pip install --only-binary=:all: --require-hashes -r <(cat <<'EOF' + coverage==7.15.2 --hash=sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f + iniconfig==2.1.0 --hash=sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760 + packaging==26.2 --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e + pluggy==1.6.0 --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + pygments==2.20.0 --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 + pytest==9.1.1 --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c + EOF + ) + + - name: Prove exact-head behavior and branch coverage + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha }}" + coverage run --branch -m pytest --import-mode=importlib \ + tests/test_inventory_orphaned_workflows.py \ + tests/test_workflow_lifecycle_inventory_workflow.py -q + coverage report --include='scripts/ci/inventory_orphaned_workflows.py' --show-missing --fail-under=100 + python -m compileall -q scripts/ci/inventory_orphaned_workflows.py tests/test_inventory_orphaned_workflows.py + git diff --exit-code diff --git a/.github/workflows/workflow-lifecycle-inventory.yml b/.github/workflows/workflow-lifecycle-inventory.yml new file mode 100644 index 0000000000..55dd09a28c --- /dev/null +++ b/.github/workflows/workflow-lifecycle-inventory.yml @@ -0,0 +1,75 @@ +name: Workflow Lifecycle Inventory + +on: + schedule: + - cron: "31 4 * * *" + +concurrency: + group: workflow-lifecycle-inventory + cancel-in-progress: false + +permissions: + contents: read + actions: read + +jobs: + inventory: + if: >- + github.repository == 'ContextualWisdomLab/.github' && + github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.13.2 + with: + egress-policy: block + allowed-endpoints: >- + api.github.com:443 + github.com:443 + objects.githubusercontent.com:443 + release-assets.githubusercontent.com:443 + results-receiver.actions.githubusercontent.com:443 + *.actions.githubusercontent.com:443 + *.blob.core.windows.net:443 + + - name: Checkout exact trusted inventory source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + + - name: Collect read-only organization evidence + env: + GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN }} + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + if [ -z "${GH_TOKEN:-}" ]; then + echo "::error::PR_REVIEW_MERGE_TOKEN is required for complete organization visibility." + exit 1 + fi + echo "::add-mask::$GH_TOKEN" + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + python scripts/ci/inventory_orphaned_workflows.py --live \ + --output "$RUNNER_TEMP/workflow-lifecycle-ledger.json" \ + --receipt-output "$RUNNER_TEMP/workflow-lifecycle-api-receipts.json" + python -m json.tool "$RUNNER_TEMP/workflow-lifecycle-ledger.json" >/dev/null + python -m json.tool "$RUNNER_TEMP/workflow-lifecycle-api-receipts.json" >/dev/null + + - name: Preserve immutable read-only evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: workflow-lifecycle-inventory-${{ github.run_id }}-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/workflow-lifecycle-ledger.json + ${{ runner.temp }}/workflow-lifecycle-api-receipts.json + if-no-files-found: error + retention-days: 30 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 331b46d4d9..9f7c7d1389 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -83,12 +83,13 @@ repair, and delegates all privileged logic to the same sealed scheduler. GitHub persists Actions registry identities independently of the protected default-branch tree. `scripts/ci/inventory_orphaned_workflows.py` is a -read-only classifier: it binds each advertised workflow to one default-branch -SHA, distinguishes repository YAML from GitHub-owned `dynamic/` identities, -and fail-closes on incomplete pagination or visibility. It does not disable -or recreate workflows. Confirmed fleet orphans route through the explicit -linkable owner-issue registry in the inventory module; it never infers issue -numbers or treats an absent owner route as a passing classification. +read-only classifier and live collector: it paginates the organization and +registry, binds each advertised workflow to a revalidated default-branch SHA, +distinguishes repository YAML from GitHub-owned `dynamic/` identities, and +fail-closes on incomplete trees, pagination, permissions, or visibility. The +scheduled integration retains content-bound API receipts. Classification never +disables or recreates workflows; the operator primitive is separately reviewed +and accepts only an immutable orphan ledger record on an unchanged head. ## Exact-artifact SBOM attestation diff --git a/CHANGELOG.md b/CHANGELOG.md index 450d07202a..940c467c04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -893,6 +893,8 @@ Semantic Versioning where the repository publishes a release. - Add a live, paginated organization workflow inventory with content-bound API receipts, exact default-head revalidation, and a separately reviewed, ledger-bound operator disable primitive. +- Schedule the central read-only workflow-lifecycle sweep on the protected + default branch and retain its exact ledger and API receipts for 30 days. - Keep the central required-workflow coverage placeholder from superseding a failed repository-dispatch coverage run; coverage retry and merge decisions now use authoritative execution evidence for the central scheduler. diff --git a/docs/doctoring/orphaned-workflow-lifecycle.md b/docs/doctoring/orphaned-workflow-lifecycle.md index 815840bd84..2a076e8f54 100644 --- a/docs/doctoring/orphaned-workflow-lifecycle.md +++ b/docs/doctoring/orphaned-workflow-lifecycle.md @@ -73,6 +73,12 @@ python3 scripts/ci/inventory_orphaned_workflows.py --live \ --receipt-output /tmp/workflow-lifecycle-api-receipts.json ``` +The protected-default-branch integration is +`.github/workflows/workflow-lifecycle-inventory.yml`. Its scheduled runs have +read-only repository permissions, verify the checked-out SHA, and +retain both immutable artifacts for 30 days. It contains no disable endpoint; +operator mutation remains a later reviewed action. + For fixture verification, feed a JSON payload with `organization`, `observed_at`, `repository_inventory_complete: true`, and one object per visible non-archived repository. The completeness flag is mandatory: a partial repository list must diff --git a/scripts/ci/inventory_orphaned_workflows.py b/scripts/ci/inventory_orphaned_workflows.py index 7d0d3a4479..85b8292d8b 100755 --- a/scripts/ci/inventory_orphaned_workflows.py +++ b/scripts/ci/inventory_orphaned_workflows.py @@ -623,8 +623,8 @@ def main(argv: Sequence[str] | None = None) -> int: from scripts.ci.organization_commercial_readiness_loop import ( # pylint: disable=import-outside-toplevel GitHubClient, ) - except ModuleNotFoundError: - from organization_commercial_readiness_loop import GitHubClient # type: ignore[no-redef] # pylint: disable=import-outside-toplevel + except ModuleNotFoundError: # pragma: no cover - direct-script smoke tested + from organization_commercial_readiness_loop import GitHubClient # type: ignore[no-redef] # pylint: disable=import-outside-toplevel # pragma: no cover try: client = GitHubClient.from_environment() diff --git a/tests/test_inventory_orphaned_workflows.py b/tests/test_inventory_orphaned_workflows.py index 4640517826..2fbd5446cd 100644 --- a/tests/test_inventory_orphaned_workflows.py +++ b/tests/test_inventory_orphaned_workflows.py @@ -834,6 +834,110 @@ def test_owner_issue_update_and_create_are_explicit() -> None: inventory.publish_owner_issue(client, known, ledger_sha256="bad") +def test_live_collector_rejects_every_incomplete_api_shape() -> None: + """All malformed live inventory boundaries fail closed.""" + org_path = "/orgs/ContextualWisdomLab/repos?type=all&sort=full_name&per_page=100&page=1" + with pytest.raises(inventory.InventoryError, match="organization"): + inventory.collect_live_organization(_LiveClient({}), "other") + for response, message in [({}, "repository inventory"), ([], "empty")]: + with pytest.raises(inventory.InventoryError, match=message): + inventory.collect_live_organization(_LiveClient({org_path: response})) + + repo = "ContextualWisdomLab/naruon" + valid_repo = {"name": "naruon", "full_name": repo, "archived": False, "default_branch": "main"} + + def client_for(**updates: Any) -> _LiveClient: + responses: dict[str, Any] = { + org_path: [valid_repo], + f"/repos/{repo}/commits/main": [{"sha": SHA}, {"sha": SHA}], + f"/repos/{repo}/git/trees/{SHA}?recursive=1": {"truncated": False, "tree": []}, + f"/repos/{repo}/actions/workflows?per_page=100&page=1": {"total_count": 0, "workflows": []}, + } + responses.update(updates) + return _LiveClient(responses) + + malformed_repositories = [ + ({"name": 1, "full_name": repo, "archived": False, "default_branch": "main"}, "identity"), + ({"name": "naruon", "full_name": repo, "archived": "no", "default_branch": "main"}, "metadata"), + ] + for malformed, message in malformed_repositories: + with pytest.raises(inventory.InventoryError, match=message): + inventory.collect_live_organization(_LiveClient({org_path: [malformed]})) + archived, _ = inventory.collect_live_organization(_LiveClient({org_path: [{"name": "old", "full_name": "ContextualWisdomLab/old", "archived": True}]})) + assert archived["repositories"][0]["archived"] is True + with pytest.raises(inventory.InventoryError, match="SHA is invalid"): + inventory.collect_live_organization(client_for(**{f"/repos/{repo}/commits/main": [{"sha": "bad"}]})) + with pytest.raises(inventory.InventoryError, match="tree is malformed"): + inventory.collect_live_organization(client_for(**{f"/repos/{repo}/git/trees/{SHA}?recursive=1": {"truncated": False, "tree": {}}})) + with pytest.raises(inventory.InventoryError, match="tree path"): + inventory.collect_live_organization(client_for(**{f"/repos/{repo}/git/trees/{SHA}?recursive=1": {"truncated": False, "tree": [{"type": "blob", "path": 1}]}})) + workflow_path = f"/repos/{repo}/actions/workflows?per_page=100&page=1" + for response, message in [([], "workflow inventory"), ({"workflows": [], "total_count": "0"}, "total_count"), ({"workflows": [], "total_count": 1}, "pagination")]: + with pytest.raises(inventory.InventoryError, match=message): + inventory.collect_live_organization(client_for(**{workflow_path: response})) + + +def test_live_transport_and_mutation_failures_are_redacted_by_type() -> None: + """Transport and write exceptions become bounded fail-closed errors.""" + class Broken: + def request(self, *_args: Any, **_kwargs: Any) -> Any: + raise RuntimeError("secret detail") + + with pytest.raises(inventory.InventoryError, match="RuntimeError"): + inventory.collect_live_organization(Broken()) + record = {"repository": "appguardrail", "workflow_id": 9, "path": ".github/workflows/gone.yml", "classification": "orphan_active", "default_branch_sha": SHA} + with pytest.raises(inventory.InventoryError, match="disable failed"): + inventory.disable_confirmed_orphan(Broken(), record, confirmed_head_sha=SHA) + with pytest.raises(inventory.InventoryError, match="identity"): + inventory.disable_confirmed_orphan(Broken(), {**record, "workflow_id": "9"}, confirmed_head_sha=SHA) + with pytest.raises(inventory.InventoryError, match="publication failed"): + inventory.publish_owner_issue(Broken(), record, ledger_sha256="e" * 64) + with pytest.raises(inventory.InventoryError, match="repository"): + inventory.publish_owner_issue(Broken(), {**record, "repository": "bad name"}, ledger_sha256="e" * 64) + bad_create = _LiveClient({"/repos/ContextualWisdomLab/new-product/issues": {}}) + with pytest.raises(inventory.InventoryError, match="no issue number"): + inventory.publish_owner_issue(bad_create, {**record, "repository": "new-product"}, ledger_sha256="e" * 64) + + +def test_live_collector_consumes_second_repository_and_workflow_pages() -> None: + """Full 100-item pages force the next API page instead of truncating.""" + first_org = "/orgs/ContextualWisdomLab/repos?type=all&sort=full_name&per_page=100&page=1" + second_org = "/orgs/ContextualWisdomLab/repos?type=all&sort=full_name&per_page=100&page=2" + archived = [{"name": f"repo-{index}", "full_name": f"ContextualWisdomLab/repo-{index}", "archived": True} for index in range(100)] + payload, _ = inventory.collect_live_organization(_LiveClient({first_org: archived, second_org: []})) + assert len(payload["repositories"]) == 100 + + repo = "ContextualWisdomLab/naruon" + first = [_workflow(index + 1, f".github/workflows/{index}.yml") for index in range(100)] + responses = { + first_org: [{"name": "naruon", "full_name": repo, "archived": False, "default_branch": "main"}], + f"/repos/{repo}/commits/main": [{"sha": SHA}, {"sha": SHA}], + f"/repos/{repo}/git/trees/{SHA}?recursive=1": {"truncated": False, "tree": []}, + f"/repos/{repo}/actions/workflows?per_page=100&page=1": {"total_count": 101, "workflows": first}, + f"/repos/{repo}/actions/workflows?per_page=100&page=2": {"total_count": 101, "workflows": [_workflow(101, ".github/workflows/last.yml")]}, + } + payload, _ = inventory.collect_live_organization(_LiveClient(responses)) + assert len(payload["repositories"][0]["workflow_pages"]) == 2 + + +def test_live_main_requires_receipts_and_writes_outputs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: + """Live CLI fails without receipts and writes both evidence files on success.""" + assert inventory.main(["--live"]) == 2 + assert "receipt-output" in capsys.readouterr().err + import scripts.ci.organization_commercial_readiness_loop as readiness + + monkeypatch.setattr(readiness.GitHubClient, "from_environment", classmethod(lambda cls: object())) + payload = _payload([_repo("naruon", [], [])]) + monkeypatch.setattr(inventory, "collect_live_organization", lambda _client: (payload, [{"method": "GET"}])) + output = tmp_path / "ledger.json" + receipts = tmp_path / "receipts.json" + assert inventory.main(["--live", "--output", str(output), "--receipt-output", str(receipts)]) == 0 + assert json.loads(receipts.read_text())[0]["method"] == "GET" + monkeypatch.setattr(readiness.GitHubClient, "from_environment", classmethod(lambda cls: (_ for _ in ()).throw(RuntimeError()))) + assert inventory.main(["--live", "--receipt-output", str(receipts)]) == 2 + assert "credential unavailable" in capsys.readouterr().err + + def test_known_fleet_fixture_routes_owner_issues() -> None: """The three named fleet incidents remain routed, not heuristically deleted.""" payload = _payload( diff --git a/tests/test_workflow_lifecycle_inventory_workflow.py b/tests/test_workflow_lifecycle_inventory_workflow.py new file mode 100644 index 0000000000..3b8eb72a85 --- /dev/null +++ b/tests/test_workflow_lifecycle_inventory_workflow.py @@ -0,0 +1,19 @@ +"""Prevent mutation or credential regression in the lifecycle inventory.""" + +from pathlib import Path + + +def test_lifecycle_inventory_workflow_is_read_only_and_exact_head() -> None: + """The integrated sweep publishes receipts without registry mutation.""" + text = Path(".github/workflows/workflow-lifecycle-inventory.yml").read_text() + assert "contents: read" in text + assert "actions: read" in text + assert "persist-credentials: false" in text + assert 'test "$(git rev-parse HEAD)" = "$GITHUB_SHA"' in text + assert "--live" in text + assert "--receipt-output" in text + assert "retention-days: 30" in text + assert "secrets: inherit" not in text + assert "COPILOT_GITHUB_TOKEN" not in text + assert "/disable" not in text + assert "--mutate" not in text From 63f4e43bbbb49c44eb2049f1cad446807e6c1a21 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 13:21:05 +0900 Subject: [PATCH 13/27] test: close scheduler coverage gaps --- ...ew_fix_scheduler_direct_rca_regressions.py | 12 ++++++++++++ ...ew_fix_scheduler_rest_workflow_identity.py | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/tests/test_pr_review_fix_scheduler_direct_rca_regressions.py b/tests/test_pr_review_fix_scheduler_direct_rca_regressions.py index c5a0c965b5..973f0da61d 100644 --- a/tests/test_pr_review_fix_scheduler_direct_rca_regressions.py +++ b/tests/test_pr_review_fix_scheduler_direct_rca_regressions.py @@ -89,6 +89,18 @@ def test_scan_queue_control_plane_failure_does_not_trigger_rca() -> None: assert fix.needs_rca_repair(pr) == (False, ()) +@pytest.mark.parametrize("draft", [True, False]) +def test_conflicted_pr_requires_nondraft_authorization(monkeypatch: Any, draft: bool) -> None: + """A draft or otherwise unauthorized conflict cannot dispatch repair.""" + pr = make_pr(is_draft=draft) + pr["mergeStateStatus"] = "DIRTY" + monkeypatch.setattr(fix, "needs_conflict_resolution", lambda *_args, **_kwargs: (False, ())) + args = fix.parse_args(["--repo", "owner/repo", "--base-branch", "main", "--dry-run"]) + action, reasons = fix.inspect_pr("owner/repo", pr, args) + assert action == "skip" + assert reasons == (("draft PR",) if draft else ("merge conflict is not authorized for repair",)) + + @pytest.mark.parametrize( "workflow_name", ["OpenCode Review", "Required OpenCode Review", "OpenCode PR Review"], diff --git a/tests/test_pr_review_fix_scheduler_rest_workflow_identity.py b/tests/test_pr_review_fix_scheduler_rest_workflow_identity.py index c24cfb05f9..d615e7d359 100644 --- a/tests/test_pr_review_fix_scheduler_rest_workflow_identity.py +++ b/tests/test_pr_review_fix_scheduler_rest_workflow_identity.py @@ -10,6 +10,25 @@ from scripts.ci import pr_review_merge_scheduler as merge +def test_workflow_name_rest_paginates_and_propagates_real_errors(monkeypatch: Any) -> None: + """REST workflow identity consumes full pages and fails on non-permission errors.""" + calls = {"count": 0} + + def pages(_path: str) -> Any: + calls["count"] += 1 + if calls["count"] == 1: + return {"workflow_runs": [{"check_suite_id": index, "name": f"workflow-{index}"} for index in range(100)]} + return {"workflow_runs": [{"check_suite_id": 101, "name": "last"}, {"check_suite_id": None, "name": "ignored"}, {"check_suite_id": 102, "name": ""}]} + + monkeypatch.setattr(merge, "gh_api_json", pages) + names = merge.fetch_workflow_names_by_check_suite_rest("owner/repo", "a" * 40) + assert names[101] == "last" + assert calls["count"] == 2 + monkeypatch.setattr(merge, "gh_api_json", lambda _path: (_ for _ in ()).throw(RuntimeError("boom"))) + with pytest.raises(RuntimeError, match="boom"): + merge.fetch_workflow_names_by_check_suite_rest("owner/repo", "a" * 40) + + def test_rest_fallback_preserves_renamed_opencode_workflow_identity( monkeypatch: Any, ) -> None: From 4aedb7e40b8e36c621aa0d5d56cf1f5d760a4e62 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 14:36:41 +0900 Subject: [PATCH 14/27] fix(inventory): prove complete live fleet evidence --- .../workflow-lifecycle-inventory.yml | 4 +- CHANGELOG.md | 6 +- docs/doctoring/orphaned-workflow-lifecycle.md | 13 +- scripts/ci/inventory_orphaned_workflows.py | 235 +++++---- scripts/ci/workflow_lifecycle_operator.py | 93 ++++ tests/test_inventory_orphaned_workflows.py | 478 +++++++++++++++--- ...t_workflow_lifecycle_inventory_workflow.py | 11 + 7 files changed, 681 insertions(+), 159 deletions(-) create mode 100644 scripts/ci/workflow_lifecycle_operator.py diff --git a/.github/workflows/workflow-lifecycle-inventory.yml b/.github/workflows/workflow-lifecycle-inventory.yml index 55dd09a28c..cd2cc15e27 100644 --- a/.github/workflows/workflow-lifecycle-inventory.yml +++ b/.github/workflows/workflow-lifecycle-inventory.yml @@ -59,7 +59,8 @@ jobs: test "$(git rev-parse HEAD)" = "$GITHUB_SHA" python scripts/ci/inventory_orphaned_workflows.py --live \ --output "$RUNNER_TEMP/workflow-lifecycle-ledger.json" \ - --receipt-output "$RUNNER_TEMP/workflow-lifecycle-api-receipts.json" + --receipt-output "$RUNNER_TEMP/workflow-lifecycle-api-receipts.json" \ + --failure-output "$RUNNER_TEMP/workflow-lifecycle-failure.json" python -m json.tool "$RUNNER_TEMP/workflow-lifecycle-ledger.json" >/dev/null python -m json.tool "$RUNNER_TEMP/workflow-lifecycle-api-receipts.json" >/dev/null @@ -71,5 +72,6 @@ jobs: path: | ${{ runner.temp }}/workflow-lifecycle-ledger.json ${{ runner.temp }}/workflow-lifecycle-api-receipts.json + ${{ runner.temp }}/workflow-lifecycle-failure.json if-no-files-found: error retention-days: 30 diff --git a/CHANGELOG.md b/CHANGELOG.md index 940c467c04..4df2f28ba2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -889,7 +889,11 @@ Semantic Versioning where the repository publishes a release. - Require orphan-workflow lifecycle fixtures to prove complete organization repository visibility before emitting an audit ledger, preventing partial - inventories from overstating fleet coverage. + inventories from overstating fleet coverage. Live collection now verifies + authenticated organization-wide repository totals, retries one explicit + HTTP 5xx once, rejects every malformed Git tree member, preserves partial + read receipts and structured failure evidence, gates completeness before + classification, and isolates disable/issue writes in the operator module. - Add a live, paginated organization workflow inventory with content-bound API receipts, exact default-head revalidation, and a separately reviewed, ledger-bound operator disable primitive. diff --git a/docs/doctoring/orphaned-workflow-lifecycle.md b/docs/doctoring/orphaned-workflow-lifecycle.md index 2a076e8f54..e5ae70d947 100644 --- a/docs/doctoring/orphaned-workflow-lifecycle.md +++ b/docs/doctoring/orphaned-workflow-lifecycle.md @@ -70,13 +70,17 @@ For a live read-only sweep, run: ```bash python3 scripts/ci/inventory_orphaned_workflows.py --live \ --output /tmp/workflow-lifecycle-ledger.json \ - --receipt-output /tmp/workflow-lifecycle-api-receipts.json + --receipt-output /tmp/workflow-lifecycle-api-receipts.json \ + --failure-output /tmp/workflow-lifecycle-failure.json ``` The protected-default-branch integration is `.github/workflows/workflow-lifecycle-inventory.yml`. Its scheduled runs have read-only repository permissions, verify the checked-out SHA, and -retain both immutable artifacts for 30 days. It contains no disable endpoint; +retain completed API receipts plus either the immutable ledger or structured +failure evidence for 30 days. The live collector proves fleet completeness by +matching the paginated repository list to authenticated organization-wide +public/private totals; pagination alone is not accepted. It contains no disable endpoint; operator mutation remains a later reviewed action. For fixture verification, feed a JSON payload with `organization`, `observed_at`, @@ -94,8 +98,9 @@ python3 scripts/ci/inventory_orphaned_workflows.py \ --output /tmp/workflow-lifecycle-ledger.json ``` -The scanner never mutates. The separate `disable_confirmed_orphan` operator -primitive accepts only an immutable `orphan_active` record and an identical +The scanner never exports a write primitive. The separately reviewed +`scripts/ci/workflow_lifecycle_operator.py` module's +`disable_confirmed_orphan` primitive accepts only an immutable `orphan_active` record and an identical fresh head SHA, then addresses its exact numeric workflow ID. After a reviewed operator pass, rerun the organization sweep and retain both receipt sets. Known AppGuardrail, Clearfolio, and DiskSage owner routes bind the same live diff --git a/scripts/ci/inventory_orphaned_workflows.py b/scripts/ci/inventory_orphaned_workflows.py index 85b8292d8b..8932ad70dd 100755 --- a/scripts/ci/inventory_orphaned_workflows.py +++ b/scripts/ci/inventory_orphaned_workflows.py @@ -84,31 +84,48 @@ def request(self, path: str, *, method: str = "GET", payload: Any = None) -> Any """Return the decoded JSON response for one bounded request.""" -def _live_get(client: GitHubTransport, path: str, receipts: list[dict[str, Any]]) -> Any: +def _live_get( + client: GitHubTransport, path: str, receipts: list[dict[str, Any]] +) -> Any: """Read one endpoint and append a content-bound API receipt.""" try: body = client.request(path) - except Exception as exc: - raise InventoryError(f"live GitHub visibility failed for {path}: {type(exc).__name__}") from exc + except Exception as first_exc: + if not re.search(r"\(HTTP 5\d\d\)", str(first_exc)): + raise InventoryError( + f"live GitHub visibility failed for {path}: {type(first_exc).__name__}" + ) from first_exc + try: + body = client.request(path) + except Exception as retry_exc: + raise InventoryError( + f"live GitHub visibility failed for {path}: {type(retry_exc).__name__}" + ) from retry_exc encoded = json.dumps(body, sort_keys=True, separators=(",", ":")).encode() - receipts.append({"method": "GET", "path": path, "sha256": hashlib.sha256(encoded).hexdigest()}) + receipts.append( + {"method": "GET", "path": path, "sha256": hashlib.sha256(encoded).hexdigest()} + ) return body def collect_live_organization( client: GitHubTransport, organization: str = "ContextualWisdomLab", + *, + receipts: list[dict[str, Any]] | None = None, ) -> tuple[dict[str, Any], list[dict[str, Any]]]: """Collect a complete live fleet payload with exact-head revalidation.""" if organization != "ContextualWisdomLab": raise InventoryError("organization must be ContextualWisdomLab") - receipts: list[dict[str, Any]] = [] + receipts = [] if receipts is None else receipts repositories: list[Mapping[str, Any]] = [] page = 1 while True: path = f"/orgs/{organization}/repos?type=all&sort=full_name&per_page=100&page={page}" batch = _live_get(client, path, receipts) - if not isinstance(batch, list) or any(not isinstance(item, Mapping) for item in batch): + if not isinstance(batch, list) or any( + not isinstance(item, Mapping) for item in batch + ): raise InventoryError("repository inventory response is malformed") repositories.extend(batch) if len(batch) < 100: @@ -116,6 +133,26 @@ def collect_live_organization( page += 1 if not repositories: raise InventoryError("live repository inventory is empty") + organization_body = _live_get(client, f"/orgs/{organization}", receipts) + if not isinstance(organization_body, Mapping): + raise InventoryError("organization visibility proof is malformed") + public_repos = organization_body.get("public_repos") + private_repos = organization_body.get("total_private_repos") + if ( + not isinstance(public_repos, int) + or public_repos < 0 + or not isinstance(private_repos, int) + or private_repos < 0 + ): + raise InventoryError("organization-wide visibility proof is unavailable") + visible_names = {item.get("full_name") for item in repositories} + if ( + len(visible_names) != len(repositories) + or len(repositories) != public_repos + private_repos + ): + raise InventoryError( + "organization-wide visibility proof does not match repository inventory" + ) records: list[dict[str, Any]] = [] for repository in repositories: name = repository.get("name") @@ -127,7 +164,11 @@ def collect_live_organization( if archived is True: records.append({"name": name, "archived": True}) continue - if archived is not False or not isinstance(default_branch, str) or not default_branch: + if ( + archived is not False + or not isinstance(default_branch, str) + or not default_branch + ): raise InventoryError(f"{name} repository metadata is incomplete") branch = quote(default_branch, safe="") commit_path = f"/repos/{full_name}/commits/{branch}" @@ -135,26 +176,50 @@ def collect_live_organization( start_sha = start.get("sha") if isinstance(start, Mapping) else None if not is_exact_sha(start_sha): raise InventoryError(f"{name} default-branch SHA is invalid") - tree_body = _live_get(client, f"/repos/{full_name}/git/trees/{start_sha}?recursive=1", receipts) - if not isinstance(tree_body, Mapping) or tree_body.get("truncated") is not False: - raise InventoryError(f"{name} default-branch tree is truncated or malformed") + tree_body = _live_get( + client, f"/repos/{full_name}/git/trees/{start_sha}?recursive=1", receipts + ) + if ( + not isinstance(tree_body, Mapping) + or tree_body.get("truncated") is not False + ): + raise InventoryError( + f"{name} default-branch tree is truncated or malformed" + ) tree = tree_body.get("tree") if not isinstance(tree, list): raise InventoryError(f"{name} default-branch tree is malformed") - tree_paths = [item.get("path") for item in tree if isinstance(item, Mapping) and item.get("type") == "blob"] - if any(not isinstance(path, str) for path in tree_paths): - raise InventoryError(f"{name} default-branch tree path is malformed") + tree_paths: list[str] = [] + for item in tree: + if not isinstance(item, Mapping): + raise InventoryError( + f"{name} default-branch tree entry is not an object" + ) + item_type = item.get("type") + path = item.get("path") + if ( + item_type not in {"blob", "tree", "commit"} + or not isinstance(path, str) + or not path + ): + raise InventoryError(f"{name} default-branch tree entry is malformed") + if item_type == "blob": + tree_paths.append(path) workflow_pages: list[dict[str, Any]] = [] workflow_page = 1 while True: workflow_path = f"/repos/{full_name}/actions/workflows?per_page=100&page={workflow_page}" body = _live_get(client, workflow_path, receipts) - if not isinstance(body, dict) or not isinstance(body.get("workflows"), list): + if not isinstance(body, dict) or not isinstance( + body.get("workflows"), list + ): raise InventoryError(f"{name} workflow inventory is malformed") total = body.get("total_count") if not isinstance(total, int) or total < 0: raise InventoryError(f"{name} workflow total_count is malformed") - consumed = sum(len(item["workflows"]) for item in workflow_pages) + len(body["workflows"]) + consumed = sum(len(item["workflows"]) for item in workflow_pages) + len( + body["workflows"] + ) has_next = consumed < total workflow_pages.append({**body, "_link_next": has_next}) if not has_next: @@ -165,8 +230,26 @@ def collect_live_organization( end = _live_get(client, commit_path, receipts) end_sha = end.get("sha") if isinstance(end, Mapping) else None assert_default_branch_bound(start_sha, end_sha) - records.append({"name": name, "archived": False, "default_branch": default_branch, "default_branch_sha": start_sha, "default_branch_sha_after": end_sha, "tree_paths": tree_paths, "workflow_pages": workflow_pages}) - return ({"organization": organization, "observed_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"), "repository_inventory_complete": True, "repositories": records}, receipts) + records.append( + { + "name": name, + "archived": False, + "default_branch": default_branch, + "default_branch_sha": start_sha, + "default_branch_sha_after": end_sha, + "tree_paths": tree_paths, + "workflow_pages": workflow_pages, + } + ) + return ( + { + "organization": organization, + "observed_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"), + "repository_inventory_complete": True, + "repositories": records, + }, + receipts, + ) def reject_forbidden_token(name: str) -> None: @@ -478,19 +561,17 @@ def inventory_organization(payload: Mapping[str, Any]) -> dict[str, Any]: repositories = payload.get("repositories") if not isinstance(repositories, list) or not repositories: raise InventoryError("repositories must be a non-empty list") + if payload.get("repository_inventory_complete") is not True: + raise InventoryError( + "repository inventory is incomplete; caller must prove full visibility" + ) inventories: list[dict[str, Any]] = [] for record in repositories: if not isinstance(record, Mapping): raise InventoryError("repository record is not an object") inventories.append(inventory_repository(record)) - if payload.get("repository_inventory_complete") is not True: - raise InventoryError( - "repository inventory is incomplete; caller must prove full visibility" - ) records = [ - item - for inventory in inventories - for item in inventory.get("records", []) + item for inventory in inventories for item in inventory.get("records", []) ] counts = {name: 0 for name in CLASSIFICATIONS} for item in records: @@ -524,71 +605,6 @@ def write_ledger(ledger: Mapping[str, Any], output: Path | None) -> str: return text -def disable_confirmed_orphan( - client: GitHubTransport, - record: Mapping[str, Any], - *, - confirmed_head_sha: str, -) -> None: - """Disable only one reviewed, ledger-bound orphan after a fresh head check.""" - if record.get("classification") != "orphan_active": - raise InventoryError("operator may disable only a ledger orphan_active record") - repository = record.get("repository") - workflow_id = record.get("workflow_id") - ledger_sha = record.get("default_branch_sha") - if not isinstance(repository, str) or not isinstance(workflow_id, int): - raise InventoryError("operator record identity is malformed") - assert_default_branch_bound(ledger_sha, confirmed_head_sha) - try: - client.request( - f"/repos/ContextualWisdomLab/{repository}/actions/workflows/{workflow_id}/disable", - method="PUT", - ) - except Exception as exc: - raise InventoryError(f"operator disable failed closed: {type(exc).__name__}") from exc - - -def publish_owner_issue( - client: GitHubTransport, - record: Mapping[str, Any], - *, - ledger_sha256: str, -) -> str: - """Create or update the bounded owner issue for one confirmed orphan.""" - if record.get("classification") != "orphan_active" or HEX_SHA256.fullmatch(ledger_sha256) is None: - raise InventoryError("issue publication requires an orphan_active and ledger digest") - repository = record.get("repository") - if not isinstance(repository, str) or REPO_SLUG.fullmatch(repository) is None: - raise InventoryError("issue publication repository is malformed") - issue = owner_issue_for(repository) - body = ( - "\n" - f"Exact workflow registry evidence: `{record.get('workflow_id')}` / " - f"`{record.get('path')}` at `{record.get('default_branch_sha')}`.\n" - f"Ledger SHA-256: `{ledger_sha256}`.\n" - ) - try: - if issue is not None: - number = issue.rsplit("#", 1)[1] - client.request( - f"/repos/ContextualWisdomLab/{repository}/issues/{number}/comments", - method="POST", - payload={"body": body}, - ) - return issue - created = client.request( - f"/repos/ContextualWisdomLab/{repository}/issues", - method="POST", - payload={"title": "Disable orphaned workflow registry identity", "body": body}, - ) - except Exception as exc: - raise InventoryError(f"owner issue publication failed closed: {type(exc).__name__}") from exc - number = created.get("number") if isinstance(created, Mapping) else None - if not isinstance(number, int) or number <= 0: - raise InventoryError("owner issue creation returned no issue number") - return f"ContextualWisdomLab/{repository}#{number}" - - def main(argv: Sequence[str] | None = None) -> int: """Load a fixture payload, emit a ledger, and optionally fail on orphans.""" parser = argparse.ArgumentParser( @@ -596,9 +612,16 @@ def main(argv: Sequence[str] | None = None) -> int: ) source = parser.add_mutually_exclusive_group(required=True) source.add_argument("--payload", help="JSON inventory fixture") - source.add_argument("--live", action="store_true", help="collect the live organization inventory") + source.add_argument( + "--live", action="store_true", help="collect the live organization inventory" + ) parser.add_argument("--output", help="optional ledger output path") - parser.add_argument("--receipt-output", help="required API receipt output for --live") + parser.add_argument( + "--receipt-output", help="required API receipt output for --live" + ) + parser.add_argument( + "--failure-output", help="structured failure evidence for --live" + ) parser.add_argument( "--fail-on-orphan-active", action="store_true", @@ -615,6 +638,7 @@ def main(argv: Sequence[str] | None = None) -> int: except InventoryError as exc: print(f"ERROR: {exc}", file=sys.stderr) return 2 + live_receipts: list[dict[str, Any]] = [] try: if args.live: if not args.receipt_output: @@ -632,7 +656,9 @@ def main(argv: Sequence[str] | None = None) -> int: raise InventoryError( f"live GitHub credential unavailable: {type(exc).__name__}" ) from exc - payload, receipts = collect_live_organization(client) + payload, receipts = collect_live_organization( + client, receipts=live_receipts + ) Path(args.receipt_output).write_text( json.dumps(receipts, indent=2, sort_keys=True) + "\n", encoding="utf-8" ) @@ -647,6 +673,26 @@ def main(argv: Sequence[str] | None = None) -> int: print(f"ERROR: unable to read payload: {exc}", file=sys.stderr) return 2 except InventoryError as exc: + if args.live: + if args.receipt_output: + Path(args.receipt_output).write_text( + json.dumps(live_receipts, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + if args.failure_output: + Path(args.failure_output).write_text( + json.dumps( + { + "capability": CAPABILITY, + "status": "failed", + "error_type": type(exc).__name__, + }, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) print(f"ERROR: {exc}", file=sys.stderr) return 2 try: @@ -658,7 +704,10 @@ def main(argv: Sequence[str] | None = None) -> int: sys.stdout.write(text) orphan_active = ledger["counts"]["orphan_active"] if args.fail_on_orphan_active and orphan_active: - print(f"FAIL: {orphan_active} orphan_active workflow identit(y/ies)", file=sys.stderr) + print( + f"FAIL: {orphan_active} orphan_active workflow identit(y/ies)", + file=sys.stderr, + ) return 1 print( f"PASS: inventoried {len(ledger['records'])} identities " diff --git a/scripts/ci/workflow_lifecycle_operator.py b/scripts/ci/workflow_lifecycle_operator.py new file mode 100644 index 0000000000..0e5dbabe19 --- /dev/null +++ b/scripts/ci/workflow_lifecycle_operator.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Reviewed write primitives for confirmed workflow-lifecycle findings.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from scripts.ci.inventory_orphaned_workflows import ( + HEX_SHA256, + REPO_SLUG, + GitHubTransport, + InventoryError, + assert_default_branch_bound, + owner_issue_for, +) + + +def disable_confirmed_orphan( + client: GitHubTransport, + record: Mapping[str, Any], + *, + confirmed_head_sha: str, +) -> None: + """Disable one reviewed ledger orphan after a fresh exact-head check.""" + if record.get("classification") != "orphan_active": + raise InventoryError("operator may disable only a ledger orphan_active record") + repository = record.get("repository") + workflow_id = record.get("workflow_id") + ledger_sha = record.get("default_branch_sha") + if not isinstance(repository, str) or not isinstance(workflow_id, int): + raise InventoryError("operator record identity is malformed") + assert_default_branch_bound(ledger_sha, confirmed_head_sha) + try: + client.request( + f"/repos/ContextualWisdomLab/{repository}/actions/workflows/{workflow_id}/disable", + method="PUT", + ) + except Exception as exc: + raise InventoryError( + f"operator disable failed closed: {type(exc).__name__}" + ) from exc + + +def publish_owner_issue( + client: GitHubTransport, + record: Mapping[str, Any], + *, + ledger_sha256: str, +) -> str: + """Create or update the bounded owner issue for one confirmed orphan.""" + if ( + record.get("classification") != "orphan_active" + or HEX_SHA256.fullmatch(ledger_sha256) is None + ): + raise InventoryError( + "issue publication requires an orphan_active and ledger digest" + ) + repository = record.get("repository") + if not isinstance(repository, str) or REPO_SLUG.fullmatch(repository) is None: + raise InventoryError("issue publication repository is malformed") + issue = owner_issue_for(repository) + body = ( + "\n" + f"Exact workflow registry evidence: `{record.get('workflow_id')}` / " + f"`{record.get('path')}` at `{record.get('default_branch_sha')}`.\n" + f"Ledger SHA-256: `{ledger_sha256}`.\n" + ) + try: + if issue is not None: + number = issue.rsplit("#", 1)[1] + client.request( + f"/repos/ContextualWisdomLab/{repository}/issues/{number}/comments", + method="POST", + payload={"body": body}, + ) + return issue + created = client.request( + f"/repos/ContextualWisdomLab/{repository}/issues", + method="POST", + payload={ + "title": "Disable orphaned workflow registry identity", + "body": body, + }, + ) + except Exception as exc: + raise InventoryError( + f"owner issue publication failed closed: {type(exc).__name__}" + ) from exc + number = created.get("number") if isinstance(created, Mapping) else None + if not isinstance(number, int) or number <= 0: + raise InventoryError("owner issue creation returned no issue number") + return f"ContextualWisdomLab/{repository}#{number}" diff --git a/tests/test_inventory_orphaned_workflows.py b/tests/test_inventory_orphaned_workflows.py index 2fbd5446cd..ad9c2d0837 100644 --- a/tests/test_inventory_orphaned_workflows.py +++ b/tests/test_inventory_orphaned_workflows.py @@ -9,6 +9,7 @@ import pytest from scripts.ci import inventory_orphaned_workflows as inventory +from scripts.ci import workflow_lifecycle_operator as operator SHA = "a" * 40 SHA_B = "b" * 40 @@ -50,7 +51,9 @@ def _repo( "tree_paths": tree_paths, "workflow_pages": pages if pages is not None - else [{"total_count": len(workflows), "workflows": workflows, "_link_next": False}], + else [ + {"total_count": len(workflows), "workflows": workflows, "_link_next": False} + ], } @@ -89,7 +92,9 @@ def test_parse_link_has_next() -> None: """Link pagination is boolean and fail-closed on malformed headers.""" assert inventory.parse_link_has_next(None) is False assert inventory.parse_link_has_next('; rel="next"') is True - assert inventory.parse_link_has_next('; rel="prev"') is False + assert ( + inventory.parse_link_has_next('; rel="prev"') is False + ) with pytest.raises(inventory.InventoryError, match="malformed"): inventory.parse_link_has_next("") with pytest.raises(inventory.InventoryError, match="malformed"): @@ -477,7 +482,9 @@ def test_inventory_repository_classifies_known_shapes() -> None: [".github/workflows/one-shot-cleanup.yml"], ) ) - classes = {item["workflow_id"]: item["classification"] for item in result["records"]} + classes = { + item["workflow_id"]: item["classification"] for item in result["records"] + } assert classes[1] == "present_active" assert classes[2] == "orphan_active" assert classes[3] == "dynamic_owned" @@ -614,6 +621,7 @@ def test_inventory_organization_and_unknown_classification( { "organization": "ContextualWisdomLab", "observed_at": "2026-08-16T12:00:00Z", + "repository_inventory_complete": True, "repositories": ["naruon"], } ) @@ -651,7 +659,9 @@ def test_inventory_requires_complete_repository_visibility() -> None: inventory.inventory_organization(payload) -def test_write_ledger_and_main(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: +def test_write_ledger_and_main( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: """The CLI writes a ledger, fails closed, and never mutates the registry.""" payload = _payload( [ @@ -669,10 +679,7 @@ def test_write_ledger_and_main(tmp_path: Path, capsys: pytest.CaptureFixture[str payload_path.write_text(json.dumps(payload), encoding="utf-8") output = tmp_path / "ledger.json" assert ( - inventory.main( - ["--payload", str(payload_path), "--output", str(output)] - ) - == 0 + inventory.main(["--payload", str(payload_path), "--output", str(output)]) == 0 ) ledger = json.loads(output.read_text(encoding="utf-8")) assert ledger["counts"]["orphan_active"] == 1 @@ -681,10 +688,7 @@ def test_write_ledger_and_main(tmp_path: Path, capsys: pytest.CaptureFixture[str assert "PASS:" in err assert ( - inventory.main( - ["--payload", str(payload_path), "--fail-on-orphan-active"] - ) - == 1 + inventory.main(["--payload", str(payload_path), "--fail-on-orphan-active"]) == 1 ) captured = capsys.readouterr() assert "FAIL:" in captured.err @@ -718,10 +722,7 @@ def test_main_reports_ledger_output_failures_separately( output = tmp_path / "missing" / "ledger.json" assert ( - inventory.main( - ["--payload", str(payload_path), "--output", str(output)] - ) - == 2 + inventory.main(["--payload", str(payload_path), "--output", str(output)]) == 2 ) error = capsys.readouterr().err assert "unable to write ledger" in error @@ -730,7 +731,9 @@ def test_main_reports_ledger_output_failures_separately( def test_example_fixture_classifies_without_masking_identities() -> None: """The committed example ledger fixture is executable and unredacted.""" - raw = Path("schemas/examples/cwl-workflow-lifecycle-ledger-v1.example.json").read_bytes() + raw = Path( + "schemas/examples/cwl-workflow-lifecycle-ledger-v1.example.json" + ).read_bytes() ledger = inventory.inventory_organization(inventory.load_payload_bytes(raw)) assert ledger["assurance_posture"]["operational_pii_mask"] is False classes = {item["path"]: item["classification"] for item in ledger["records"]} @@ -754,6 +757,14 @@ def request(self, path: str, *, method: str = "GET", payload: Any = None) -> Any assert method == "GET" assert payload is None self.calls.append(path) + if path == "/orgs/ContextualWisdomLab" and path not in self.responses: + visible = sum( + len(value) + for key, value in self.responses.items() + if key.startswith("/orgs/ContextualWisdomLab/repos?") + and isinstance(value, list) + ) + return {"public_repos": visible, "total_private_repos": 0} value = self.responses[path] if path.endswith("/commits/main") and isinstance(value, list): return value.pop(0) @@ -765,7 +776,12 @@ def test_collect_live_organization_paginates_and_rechecks_head() -> None: repo = "ContextualWisdomLab/appguardrail" responses = { "/orgs/ContextualWisdomLab/repos?type=all&sort=full_name&per_page=100&page=1": [ - {"name": "appguardrail", "full_name": repo, "archived": False, "default_branch": "main"} + { + "name": "appguardrail", + "full_name": repo, + "archived": False, + "default_branch": "main", + } ], f"/repos/{repo}/commits/main": [{"sha": SHA}, {"sha": SHA}], f"/repos/{repo}/git/trees/{SHA}?recursive=1": { @@ -789,16 +805,167 @@ def test_collect_live_organization_paginates_and_rechecks_head() -> None: assert client.calls.count(f"/repos/{repo}/commits/main") == 2 +def test_collect_live_organization_requires_org_wide_visibility_proof() -> None: + """A syntactically complete visible page cannot hide unselected repositories.""" + org_path = ( + "/orgs/ContextualWisdomLab/repos?type=all&sort=full_name&per_page=100&page=1" + ) + client = _LiveClient( + { + org_path: [ + { + "name": "public", + "full_name": "ContextualWisdomLab/public", + "archived": True, + } + ], + "/orgs/ContextualWisdomLab": {"public_repos": 1, "total_private_repos": 2}, + } + ) + with pytest.raises(inventory.InventoryError, match="visibility proof"): + inventory.collect_live_organization(client) + + +def test_live_get_retries_one_actual_http_5xx() -> None: + """The production exception transport retries only one explicit HTTP 5xx.""" + + class Once: + calls = 0 + + def request(self, _path: str, **_kwargs: Any) -> Any: + self.calls += 1 + if self.calls == 1: + raise RuntimeError("GitHub API failed: server unavailable (HTTP 503)") + return {"ok": True} + + receipts: list[dict[str, Any]] = [] + client = Once() + assert inventory._live_get(client, "/test", receipts) == {"ok": True} + assert client.calls == 2 + assert len(receipts) == 1 + + class Down: + def request(self, _path: str, **_kwargs: Any) -> Any: + raise RuntimeError("GitHub API failed (HTTP 502)") + + with pytest.raises(inventory.InventoryError, match="RuntimeError"): + inventory._live_get(Down(), "/test", []) + + +@pytest.mark.parametrize( + "proof", + ([], {}, {"public_repos": "1", "total_private_repos": 0}), +) +def test_live_collector_rejects_malformed_visibility_proof(proof: object) -> None: + """Only numeric organization-wide repository totals prove completeness.""" + org_path = ( + "/orgs/ContextualWisdomLab/repos?type=all&sort=full_name&per_page=100&page=1" + ) + with pytest.raises(inventory.InventoryError, match="visibility proof"): + inventory.collect_live_organization( + _LiveClient( + { + org_path: [ + { + "name": "old", + "full_name": "ContextualWisdomLab/old", + "archived": True, + } + ], + "/orgs/ContextualWisdomLab": proof, + } + ) + ) + + +@pytest.mark.parametrize( + "entry", + ( + "not-an-object", + {"path": "x"}, + {"type": "mystery", "path": "x"}, + {"type": "blob"}, + ), +) +def test_live_tree_rejects_every_malformed_entry(entry: object) -> None: + """Malformed tree members never become negative source evidence.""" + repo = "ContextualWisdomLab/naruon" + org_path = ( + "/orgs/ContextualWisdomLab/repos?type=all&sort=full_name&per_page=100&page=1" + ) + responses = { + org_path: [ + { + "name": "naruon", + "full_name": repo, + "archived": False, + "default_branch": "main", + } + ], + f"/repos/{repo}/commits/main": [{"sha": SHA}, {"sha": SHA}], + f"/repos/{repo}/git/trees/{SHA}?recursive=1": { + "truncated": False, + "tree": [entry], + }, + f"/repos/{repo}/actions/workflows?per_page=100&page=1": { + "total_count": 0, + "workflows": [], + }, + } + with pytest.raises(inventory.InventoryError, match="tree entry"): + inventory.collect_live_organization(_LiveClient(responses)) + + +def test_live_tree_accepts_non_blob_entries_without_source_paths() -> None: + """Valid tree and submodule entries are checked but never workflow sources.""" + repo = "ContextualWisdomLab/naruon" + org_path = ( + "/orgs/ContextualWisdomLab/repos?type=all&sort=full_name&per_page=100&page=1" + ) + responses = { + org_path: [ + { + "name": "naruon", + "full_name": repo, + "archived": False, + "default_branch": "main", + } + ], + f"/repos/{repo}/commits/main": [{"sha": SHA}, {"sha": SHA}], + f"/repos/{repo}/git/trees/{SHA}?recursive=1": { + "truncated": False, + "tree": [ + {"type": "tree", "path": ".github"}, + {"type": "commit", "path": "vendor"}, + ], + }, + f"/repos/{repo}/actions/workflows?per_page=100&page=1": { + "total_count": 0, + "workflows": [], + }, + } + payload, _ = inventory.collect_live_organization(_LiveClient(responses)) + assert payload["repositories"][0]["tree_paths"] == [] + + def test_collect_live_organization_fails_on_truncated_tree_or_head_move() -> None: """Incomplete trees and a moving default branch never produce a ledger.""" repo = "ContextualWisdomLab/naruon" base = { "/orgs/ContextualWisdomLab/repos?type=all&sort=full_name&per_page=100&page=1": [ - {"name": "naruon", "full_name": repo, "archived": False, "default_branch": "main"} + { + "name": "naruon", + "full_name": repo, + "archived": False, + "default_branch": "main", + } ], f"/repos/{repo}/commits/main": [{"sha": SHA}, {"sha": SHA_B}], f"/repos/{repo}/git/trees/{SHA}?recursive=1": {"truncated": False, "tree": []}, - f"/repos/{repo}/actions/workflows?per_page=100&page=1": {"total_count": 0, "workflows": []}, + f"/repos/{repo}/actions/workflows?per_page=100&page=1": { + "total_count": 0, + "workflows": [], + }, } with pytest.raises(inventory.InventoryError, match="moved"): inventory.collect_live_organization(_LiveClient(base)) @@ -810,33 +977,56 @@ def test_collect_live_organization_fails_on_truncated_tree_or_head_move() -> Non def test_operator_disable_is_ledger_and_head_bound() -> None: """The mutation path accepts only a reviewed orphan on its unchanged head.""" - record = {"repository": "appguardrail", "workflow_id": 9, "classification": "orphan_active", "default_branch_sha": SHA} + record = { + "repository": "appguardrail", + "workflow_id": 9, + "classification": "orphan_active", + "default_branch_sha": SHA, + } client = _LiveClient({}) - inventory.disable_confirmed_orphan(client, record, confirmed_head_sha=SHA) - assert client.calls == ["/repos/ContextualWisdomLab/appguardrail/actions/workflows/9/disable"] + operator.disable_confirmed_orphan(client, record, confirmed_head_sha=SHA) + assert client.calls == [ + "/repos/ContextualWisdomLab/appguardrail/actions/workflows/9/disable" + ] with pytest.raises(inventory.InventoryError, match="only"): - inventory.disable_confirmed_orphan(client, {**record, "classification": "present_active"}, confirmed_head_sha=SHA) + operator.disable_confirmed_orphan( + client, + {**record, "classification": "present_active"}, + confirmed_head_sha=SHA, + ) with pytest.raises(inventory.InventoryError, match="moved"): - inventory.disable_confirmed_orphan(client, record, confirmed_head_sha=SHA_B) + operator.disable_confirmed_orphan(client, record, confirmed_head_sha=SHA_B) def test_owner_issue_update_and_create_are_explicit() -> None: """Known owners receive an update; unknown owners receive one bounded issue.""" - known = {"repository": "appguardrail", "workflow_id": 9, "path": ".github/workflows/gone.yml", "classification": "orphan_active", "default_branch_sha": SHA} + known = { + "repository": "appguardrail", + "workflow_id": 9, + "path": ".github/workflows/gone.yml", + "classification": "orphan_active", + "default_branch_sha": SHA, + } client = _LiveClient({}) - assert inventory.publish_owner_issue(client, known, ledger_sha256="c" * 64).endswith("#929") + assert operator.publish_owner_issue(client, known, ledger_sha256="c" * 64).endswith( + "#929" + ) assert client.calls[-1].endswith("/issues/929/comments") unknown = {**known, "repository": "new-product"} create_path = "/repos/ContextualWisdomLab/new-product/issues" creator = _LiveClient({create_path: {"number": 41}}) - assert inventory.publish_owner_issue(creator, unknown, ledger_sha256="d" * 64).endswith("#41") + assert operator.publish_owner_issue( + creator, unknown, ledger_sha256="d" * 64 + ).endswith("#41") with pytest.raises(inventory.InventoryError, match="ledger digest"): - inventory.publish_owner_issue(client, known, ledger_sha256="bad") + operator.publish_owner_issue(client, known, ledger_sha256="bad") def test_live_collector_rejects_every_incomplete_api_shape() -> None: """All malformed live inventory boundaries fail closed.""" - org_path = "/orgs/ContextualWisdomLab/repos?type=all&sort=full_name&per_page=100&page=1" + org_path = ( + "/orgs/ContextualWisdomLab/repos?type=all&sort=full_name&per_page=100&page=1" + ) with pytest.raises(inventory.InventoryError, match="organization"): inventory.collect_live_organization(_LiveClient({}), "other") for response, message in [({}, "repository inventory"), ([], "empty")]: @@ -844,100 +1034,268 @@ def test_live_collector_rejects_every_incomplete_api_shape() -> None: inventory.collect_live_organization(_LiveClient({org_path: response})) repo = "ContextualWisdomLab/naruon" - valid_repo = {"name": "naruon", "full_name": repo, "archived": False, "default_branch": "main"} + valid_repo = { + "name": "naruon", + "full_name": repo, + "archived": False, + "default_branch": "main", + } def client_for(**updates: Any) -> _LiveClient: responses: dict[str, Any] = { org_path: [valid_repo], f"/repos/{repo}/commits/main": [{"sha": SHA}, {"sha": SHA}], - f"/repos/{repo}/git/trees/{SHA}?recursive=1": {"truncated": False, "tree": []}, - f"/repos/{repo}/actions/workflows?per_page=100&page=1": {"total_count": 0, "workflows": []}, + f"/repos/{repo}/git/trees/{SHA}?recursive=1": { + "truncated": False, + "tree": [], + }, + f"/repos/{repo}/actions/workflows?per_page=100&page=1": { + "total_count": 0, + "workflows": [], + }, } responses.update(updates) return _LiveClient(responses) malformed_repositories = [ - ({"name": 1, "full_name": repo, "archived": False, "default_branch": "main"}, "identity"), - ({"name": "naruon", "full_name": repo, "archived": "no", "default_branch": "main"}, "metadata"), + ( + {"name": 1, "full_name": repo, "archived": False, "default_branch": "main"}, + "identity", + ), + ( + { + "name": "naruon", + "full_name": repo, + "archived": "no", + "default_branch": "main", + }, + "metadata", + ), ] for malformed, message in malformed_repositories: with pytest.raises(inventory.InventoryError, match=message): inventory.collect_live_organization(_LiveClient({org_path: [malformed]})) - archived, _ = inventory.collect_live_organization(_LiveClient({org_path: [{"name": "old", "full_name": "ContextualWisdomLab/old", "archived": True}]})) + archived, _ = inventory.collect_live_organization( + _LiveClient( + { + org_path: [ + { + "name": "old", + "full_name": "ContextualWisdomLab/old", + "archived": True, + } + ] + } + ) + ) assert archived["repositories"][0]["archived"] is True with pytest.raises(inventory.InventoryError, match="SHA is invalid"): - inventory.collect_live_organization(client_for(**{f"/repos/{repo}/commits/main": [{"sha": "bad"}]})) + inventory.collect_live_organization( + client_for(**{f"/repos/{repo}/commits/main": [{"sha": "bad"}]}) + ) with pytest.raises(inventory.InventoryError, match="tree is malformed"): - inventory.collect_live_organization(client_for(**{f"/repos/{repo}/git/trees/{SHA}?recursive=1": {"truncated": False, "tree": {}}})) - with pytest.raises(inventory.InventoryError, match="tree path"): - inventory.collect_live_organization(client_for(**{f"/repos/{repo}/git/trees/{SHA}?recursive=1": {"truncated": False, "tree": [{"type": "blob", "path": 1}]}})) + inventory.collect_live_organization( + client_for( + **{ + f"/repos/{repo}/git/trees/{SHA}?recursive=1": { + "truncated": False, + "tree": {}, + } + } + ) + ) + with pytest.raises(inventory.InventoryError, match="tree entry"): + inventory.collect_live_organization( + client_for( + **{ + f"/repos/{repo}/git/trees/{SHA}?recursive=1": { + "truncated": False, + "tree": [{"type": "blob", "path": 1}], + } + } + ) + ) workflow_path = f"/repos/{repo}/actions/workflows?per_page=100&page=1" - for response, message in [([], "workflow inventory"), ({"workflows": [], "total_count": "0"}, "total_count"), ({"workflows": [], "total_count": 1}, "pagination")]: + for response, message in [ + ([], "workflow inventory"), + ({"workflows": [], "total_count": "0"}, "total_count"), + ({"workflows": [], "total_count": 1}, "pagination"), + ]: with pytest.raises(inventory.InventoryError, match=message): inventory.collect_live_organization(client_for(**{workflow_path: response})) def test_live_transport_and_mutation_failures_are_redacted_by_type() -> None: """Transport and write exceptions become bounded fail-closed errors.""" + class Broken: def request(self, *_args: Any, **_kwargs: Any) -> Any: raise RuntimeError("secret detail") with pytest.raises(inventory.InventoryError, match="RuntimeError"): inventory.collect_live_organization(Broken()) - record = {"repository": "appguardrail", "workflow_id": 9, "path": ".github/workflows/gone.yml", "classification": "orphan_active", "default_branch_sha": SHA} + record = { + "repository": "appguardrail", + "workflow_id": 9, + "path": ".github/workflows/gone.yml", + "classification": "orphan_active", + "default_branch_sha": SHA, + } with pytest.raises(inventory.InventoryError, match="disable failed"): - inventory.disable_confirmed_orphan(Broken(), record, confirmed_head_sha=SHA) + operator.disable_confirmed_orphan(Broken(), record, confirmed_head_sha=SHA) with pytest.raises(inventory.InventoryError, match="identity"): - inventory.disable_confirmed_orphan(Broken(), {**record, "workflow_id": "9"}, confirmed_head_sha=SHA) + operator.disable_confirmed_orphan( + Broken(), {**record, "workflow_id": "9"}, confirmed_head_sha=SHA + ) with pytest.raises(inventory.InventoryError, match="publication failed"): - inventory.publish_owner_issue(Broken(), record, ledger_sha256="e" * 64) + operator.publish_owner_issue(Broken(), record, ledger_sha256="e" * 64) with pytest.raises(inventory.InventoryError, match="repository"): - inventory.publish_owner_issue(Broken(), {**record, "repository": "bad name"}, ledger_sha256="e" * 64) + operator.publish_owner_issue( + Broken(), {**record, "repository": "bad name"}, ledger_sha256="e" * 64 + ) bad_create = _LiveClient({"/repos/ContextualWisdomLab/new-product/issues": {}}) with pytest.raises(inventory.InventoryError, match="no issue number"): - inventory.publish_owner_issue(bad_create, {**record, "repository": "new-product"}, ledger_sha256="e" * 64) + operator.publish_owner_issue( + bad_create, {**record, "repository": "new-product"}, ledger_sha256="e" * 64 + ) def test_live_collector_consumes_second_repository_and_workflow_pages() -> None: """Full 100-item pages force the next API page instead of truncating.""" - first_org = "/orgs/ContextualWisdomLab/repos?type=all&sort=full_name&per_page=100&page=1" - second_org = "/orgs/ContextualWisdomLab/repos?type=all&sort=full_name&per_page=100&page=2" - archived = [{"name": f"repo-{index}", "full_name": f"ContextualWisdomLab/repo-{index}", "archived": True} for index in range(100)] - payload, _ = inventory.collect_live_organization(_LiveClient({first_org: archived, second_org: []})) + first_org = ( + "/orgs/ContextualWisdomLab/repos?type=all&sort=full_name&per_page=100&page=1" + ) + second_org = ( + "/orgs/ContextualWisdomLab/repos?type=all&sort=full_name&per_page=100&page=2" + ) + archived = [ + { + "name": f"repo-{index}", + "full_name": f"ContextualWisdomLab/repo-{index}", + "archived": True, + } + for index in range(100) + ] + payload, _ = inventory.collect_live_organization( + _LiveClient({first_org: archived, second_org: []}) + ) assert len(payload["repositories"]) == 100 repo = "ContextualWisdomLab/naruon" - first = [_workflow(index + 1, f".github/workflows/{index}.yml") for index in range(100)] + first = [ + _workflow(index + 1, f".github/workflows/{index}.yml") for index in range(100) + ] responses = { - first_org: [{"name": "naruon", "full_name": repo, "archived": False, "default_branch": "main"}], + first_org: [ + { + "name": "naruon", + "full_name": repo, + "archived": False, + "default_branch": "main", + } + ], f"/repos/{repo}/commits/main": [{"sha": SHA}, {"sha": SHA}], f"/repos/{repo}/git/trees/{SHA}?recursive=1": {"truncated": False, "tree": []}, - f"/repos/{repo}/actions/workflows?per_page=100&page=1": {"total_count": 101, "workflows": first}, - f"/repos/{repo}/actions/workflows?per_page=100&page=2": {"total_count": 101, "workflows": [_workflow(101, ".github/workflows/last.yml")]}, + f"/repos/{repo}/actions/workflows?per_page=100&page=1": { + "total_count": 101, + "workflows": first, + }, + f"/repos/{repo}/actions/workflows?per_page=100&page=2": { + "total_count": 101, + "workflows": [_workflow(101, ".github/workflows/last.yml")], + }, } payload, _ = inventory.collect_live_organization(_LiveClient(responses)) assert len(payload["repositories"][0]["workflow_pages"]) == 2 -def test_live_main_requires_receipts_and_writes_outputs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: +def test_live_main_requires_receipts_and_writes_outputs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: """Live CLI fails without receipts and writes both evidence files on success.""" assert inventory.main(["--live"]) == 2 assert "receipt-output" in capsys.readouterr().err import scripts.ci.organization_commercial_readiness_loop as readiness - monkeypatch.setattr(readiness.GitHubClient, "from_environment", classmethod(lambda cls: object())) + monkeypatch.setattr( + readiness.GitHubClient, "from_environment", classmethod(lambda cls: object()) + ) payload = _payload([_repo("naruon", [], [])]) - monkeypatch.setattr(inventory, "collect_live_organization", lambda _client: (payload, [{"method": "GET"}])) + monkeypatch.setattr( + inventory, + "collect_live_organization", + lambda _client, **_kwargs: (payload, [{"method": "GET"}]), + ) output = tmp_path / "ledger.json" receipts = tmp_path / "receipts.json" - assert inventory.main(["--live", "--output", str(output), "--receipt-output", str(receipts)]) == 0 + assert ( + inventory.main( + ["--live", "--output", str(output), "--receipt-output", str(receipts)] + ) + == 0 + ) assert json.loads(receipts.read_text())[0]["method"] == "GET" - monkeypatch.setattr(readiness.GitHubClient, "from_environment", classmethod(lambda cls: (_ for _ in ()).throw(RuntimeError()))) + monkeypatch.setattr( + readiness.GitHubClient, + "from_environment", + classmethod(lambda cls: (_ for _ in ()).throw(RuntimeError())), + ) assert inventory.main(["--live", "--receipt-output", str(receipts)]) == 2 assert "credential unavailable" in capsys.readouterr().err +def test_live_main_preserves_partial_receipts_and_failure_evidence( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A failed sweep still leaves completed reads and a structured failure artifact.""" + import scripts.ci.organization_commercial_readiness_loop as readiness + + monkeypatch.setattr( + readiness.GitHubClient, "from_environment", classmethod(lambda cls: object()) + ) + + def fail( + _client: object, *, receipts: list[dict[str, Any]] + ) -> tuple[dict[str, Any], list[dict[str, Any]]]: + receipts.append({"method": "GET", "path": "/completed", "sha256": "a" * 64}) + raise inventory.InventoryError("visibility proof unavailable") + + monkeypatch.setattr(inventory, "collect_live_organization", fail) + receipts = tmp_path / "receipts.json" + failure = tmp_path / "failure.json" + assert ( + inventory.main( + [ + "--live", + "--receipt-output", + str(receipts), + "--failure-output", + str(failure), + ] + ) + == 2 + ) + assert json.loads(receipts.read_text())[0]["path"] == "/completed" + assert json.loads(failure.read_text())["status"] == "failed" + + +def test_incomplete_inventory_fails_before_repository_classification( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The fleet completeness gate precedes all repository processing.""" + payload = _payload([_repo("naruon", [], [])]) + payload["repository_inventory_complete"] = False + monkeypatch.setattr( + inventory, + "inventory_repository", + lambda _record: pytest.fail( + "incomplete fleet reached repository classification" + ), + ) + with pytest.raises(inventory.InventoryError, match="incomplete"): + inventory.inventory_organization(payload) + + def test_known_fleet_fixture_routes_owner_issues() -> None: """The three named fleet incidents remain routed, not heuristically deleted.""" payload = _payload( diff --git a/tests/test_workflow_lifecycle_inventory_workflow.py b/tests/test_workflow_lifecycle_inventory_workflow.py index 3b8eb72a85..bbf6cfb18b 100644 --- a/tests/test_workflow_lifecycle_inventory_workflow.py +++ b/tests/test_workflow_lifecycle_inventory_workflow.py @@ -12,8 +12,19 @@ def test_lifecycle_inventory_workflow_is_read_only_and_exact_head() -> None: assert 'test "$(git rev-parse HEAD)" = "$GITHUB_SHA"' in text assert "--live" in text assert "--receipt-output" in text + assert "--failure-output" in text assert "retention-days: 30" in text assert "secrets: inherit" not in text assert "COPILOT_GITHUB_TOKEN" not in text assert "/disable" not in text assert "--mutate" not in text + + +def test_read_only_scanner_exports_no_write_primitive() -> None: + """Disable and issue writes live only in the reviewed operator module.""" + scanner = Path("scripts/ci/inventory_orphaned_workflows.py").read_text() + operator = Path("scripts/ci/workflow_lifecycle_operator.py").read_text() + assert "def disable_confirmed_orphan" not in scanner + assert "def publish_owner_issue" not in scanner + assert "def disable_confirmed_orphan" in operator + assert "def publish_owner_issue" in operator From c2a243068da1764e5d98e57daafa127a637fc5ce Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 25 Sep 2026 00:09:33 +0900 Subject: [PATCH 15/27] fix(actions): bind lifecycle inventory to stable fleet evidence --- ...orkflow-lifecycle-inventory-quality-ci.yml | 1 + docs/doctoring/orphaned-workflow-lifecycle.md | 14 +- scripts/ci/inventory_orphaned_workflows.py | 82 +++++-- scripts/ci/workflow_lifecycle_operator.py | 83 ++++--- tests/test_inventory_orphaned_workflows.py | 215 ++++++++++++++---- ...t_workflow_lifecycle_inventory_workflow.py | 7 +- 6 files changed, 305 insertions(+), 97 deletions(-) diff --git a/.github/workflows/workflow-lifecycle-inventory-quality-ci.yml b/.github/workflows/workflow-lifecycle-inventory-quality-ci.yml index 366a3e9480..359d3ede3c 100644 --- a/.github/workflows/workflow-lifecycle-inventory-quality-ci.yml +++ b/.github/workflows/workflow-lifecycle-inventory-quality-ci.yml @@ -7,6 +7,7 @@ on: - ".github/workflows/workflow-lifecycle-inventory.yml" - ".github/workflows/workflow-lifecycle-inventory-quality-ci.yml" - "scripts/ci/inventory_orphaned_workflows.py" + - "scripts/ci/workflow_lifecycle_operator.py" - "tests/test_inventory_orphaned_workflows.py" - "tests/test_workflow_lifecycle_inventory_workflow.py" - "docs/doctoring/orphaned-workflow-lifecycle.md" diff --git a/docs/doctoring/orphaned-workflow-lifecycle.md b/docs/doctoring/orphaned-workflow-lifecycle.md index e5ae70d947..806b4ecf6b 100644 --- a/docs/doctoring/orphaned-workflow-lifecycle.md +++ b/docs/doctoring/orphaned-workflow-lifecycle.md @@ -98,13 +98,17 @@ python3 scripts/ci/inventory_orphaned_workflows.py \ --output /tmp/workflow-lifecycle-ledger.json ``` -The scanner never exports a write primitive. The separately reviewed -`scripts/ci/workflow_lifecycle_operator.py` module's -`disable_confirmed_orphan` primitive accepts only an immutable `orphan_active` record and an identical -fresh head SHA, then addresses its exact numeric workflow ID. After a reviewed -operator pass, rerun the organization sweep and retain both receipt sets. +The scanner and owner-issue publisher expose no workflow-state mutation. +Disablement needs a separate reviewed operator path that rechecks the live +default-branch SHA, exact workflow ID, source absence, owner intent, active +runs, and reusable-workflow callers immediately before the API write. After a +reviewed operator pass, rerun the organization sweep and retain both receipt sets. Known AppGuardrail, Clearfolio, and DiskSage owner routes bind the same live evidence to their governance issues without heuristic issue creation. +The separately invoked owner-issue publisher verifies that each finding belongs +to the supplied ledger, computes that ledger's digest, and scans existing owner +issues before creating one for a repository without a known route. It does not +run in the read-only inventory job. ## Rollback diff --git a/scripts/ci/inventory_orphaned_workflows.py b/scripts/ci/inventory_orphaned_workflows.py index 8932ad70dd..9119ad2be5 100755 --- a/scripts/ci/inventory_orphaned_workflows.py +++ b/scripts/ci/inventory_orphaned_workflows.py @@ -26,9 +26,10 @@ CAPABILITY = "workflow_lifecycle_inventory" MAX_PAYLOAD_BYTES = 1_048_576 PER_PAGE_DEFAULT = 100 +MAX_PAGES = 1_000 HEX_SHA = re.compile(r"^[0-9a-f]{40}$") HEX_SHA256 = re.compile(r"^[0-9a-f]{64}$") -REPO_SLUG = re.compile(r"^[A-Za-z0-9._-]+$") +REPO_SLUG = re.compile(r"^(?!\.{1,2}$)(?!.*\.\.)[A-Za-z0-9._-]+$") REPO_WORKFLOW_NAME = re.compile(r"^[^/]+\.(yml|yaml)$") DYNAMIC_PREFIXES = ("dynamic/",) DISABLED_STATES = frozenset( @@ -118,19 +119,25 @@ def collect_live_organization( if organization != "ContextualWisdomLab": raise InventoryError("organization must be ContextualWisdomLab") receipts = [] if receipts is None else receipts - repositories: list[Mapping[str, Any]] = [] - page = 1 - while True: - path = f"/orgs/{organization}/repos?type=all&sort=full_name&per_page=100&page={page}" - batch = _live_get(client, path, receipts) - if not isinstance(batch, list) or any( - not isinstance(item, Mapping) for item in batch - ): - raise InventoryError("repository inventory response is malformed") - repositories.extend(batch) - if len(batch) < 100: - break - page += 1 + + def list_repositories() -> list[Mapping[str, Any]]: + found: list[Mapping[str, Any]] = [] + page = 1 + while True: + path = f"/orgs/{organization}/repos?type=all&sort=full_name&per_page=100&page={page}" + batch = _live_get(client, path, receipts) + if not isinstance(batch, list) or any( + not isinstance(item, Mapping) for item in batch + ): + raise InventoryError("repository inventory response is malformed") + found.extend(batch) + if len(batch) < 100: + return found + page += 1 + if page > MAX_PAGES: + raise InventoryError("repository pagination exceeded limit") + + repositories = list_repositories() if not repositories: raise InventoryError("live repository inventory is empty") organization_body = _live_get(client, f"/orgs/{organization}", receipts) @@ -153,6 +160,29 @@ def collect_live_organization( raise InventoryError( "organization-wide visibility proof does not match repository inventory" ) + + def identities(items: list[Mapping[str, Any]]) -> set[tuple[object, ...]]: + try: + return { + ( + item.get("id"), + item.get("full_name"), + item.get("default_branch"), + item.get("archived"), + ) + for item in items + } + except TypeError as exc: + raise InventoryError("repository identity is malformed") from exc + + baseline = identities(repositories) + second = list_repositories() + if ( + len(baseline) != len(repositories) + or len(second) != len(repositories) + or identities(second) != baseline + ): + raise InventoryError("repository inventory changed during pagination") records: list[dict[str, Any]] = [] for repository in repositories: name = repository.get("name") @@ -241,6 +271,9 @@ def collect_live_organization( "workflow_pages": workflow_pages, } ) + final = list_repositories() + if len(final) != len(repositories) or identities(final) != baseline: + raise InventoryError("repository inventory changed during scan") return ( { "organization": organization, @@ -648,7 +681,9 @@ def main(argv: Sequence[str] | None = None) -> int: GitHubClient, ) except ModuleNotFoundError: # pragma: no cover - direct-script smoke tested - from organization_commercial_readiness_loop import GitHubClient # type: ignore[no-redef] # pylint: disable=import-outside-toplevel # pragma: no cover + from organization_commercial_readiness_loop import ( + GitHubClient, # type: ignore[no-redef] # pylint: disable=import-outside-toplevel # pragma: no cover + ) try: client = GitHubClient.from_environment() @@ -698,6 +733,23 @@ def main(argv: Sequence[str] | None = None) -> int: try: text = write_ledger(ledger, Path(args.output) if args.output else None) except (FileNotFoundError, OSError) as exc: + if args.live and args.failure_output: + try: + Path(args.failure_output).write_text( + json.dumps( + { + "capability": CAPABILITY, + "status": "failed", + "error_type": type(exc).__name__, + }, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + except OSError: + pass print(f"ERROR: unable to write ledger: {exc}", file=sys.stderr) return 2 if args.output is None: diff --git a/scripts/ci/workflow_lifecycle_operator.py b/scripts/ci/workflow_lifecycle_operator.py index 0e5dbabe19..fca25e8efb 100644 --- a/scripts/ci/workflow_lifecycle_operator.py +++ b/scripts/ci/workflow_lifecycle_operator.py @@ -1,61 +1,36 @@ -#!/usr/bin/env python3 -"""Reviewed write primitives for confirmed workflow-lifecycle findings.""" +"""Reviewed owner-issue publication for workflow-lifecycle findings.""" from __future__ import annotations +import hashlib from collections.abc import Mapping from typing import Any from scripts.ci.inventory_orphaned_workflows import ( - HEX_SHA256, + MAX_PAGES, REPO_SLUG, GitHubTransport, InventoryError, - assert_default_branch_bound, owner_issue_for, + write_ledger, ) -def disable_confirmed_orphan( - client: GitHubTransport, - record: Mapping[str, Any], - *, - confirmed_head_sha: str, -) -> None: - """Disable one reviewed ledger orphan after a fresh exact-head check.""" - if record.get("classification") != "orphan_active": - raise InventoryError("operator may disable only a ledger orphan_active record") - repository = record.get("repository") - workflow_id = record.get("workflow_id") - ledger_sha = record.get("default_branch_sha") - if not isinstance(repository, str) or not isinstance(workflow_id, int): - raise InventoryError("operator record identity is malformed") - assert_default_branch_bound(ledger_sha, confirmed_head_sha) - try: - client.request( - f"/repos/ContextualWisdomLab/{repository}/actions/workflows/{workflow_id}/disable", - method="PUT", - ) - except Exception as exc: - raise InventoryError( - f"operator disable failed closed: {type(exc).__name__}" - ) from exc - - def publish_owner_issue( client: GitHubTransport, record: Mapping[str, Any], *, - ledger_sha256: str, + ledger: Mapping[str, Any], ) -> str: """Create or update the bounded owner issue for one confirmed orphan.""" + records = ledger.get("records") if ( record.get("classification") != "orphan_active" - or HEX_SHA256.fullmatch(ledger_sha256) is None + or not isinstance(records, list) + or record not in records ): - raise InventoryError( - "issue publication requires an orphan_active and ledger digest" - ) + raise InventoryError("issue publication requires a ledger-bound orphan_active") + ledger_sha256 = hashlib.sha256(write_ledger(ledger, None).encode()).hexdigest() repository = record.get("repository") if not isinstance(repository, str) or REPO_SLUG.fullmatch(repository) is None: raise InventoryError("issue publication repository is malformed") @@ -75,6 +50,44 @@ def publish_owner_issue( payload={"body": body}, ) return issue + marker = "" + page = 1 + matches: list[Mapping[str, Any]] = [] + while True: + existing = client.request( + f"/repos/ContextualWisdomLab/{repository}/issues?state=all&per_page=100&page={page}" + ) + if not isinstance(existing, list): + raise InventoryError("owner issue inventory is incomplete") + matches.extend( + item + for item in existing + if isinstance(item, Mapping) + and marker in str(item.get("body") or "") + and "pull_request" not in item + ) + if len(existing) < 100: + break + page += 1 + if page > MAX_PAGES: + raise InventoryError("owner issue pagination exceeded limit") + if matches: + number = matches[0].get("number") + if not isinstance(number, int) or number <= 0 or len(matches) != 1: + raise InventoryError("owner issue identity is ambiguous") + if matches[0].get("state") != "open": + raise InventoryError( + "owner issue is closed; operator review is required" + ) + if f"Ledger SHA-256: `{ledger_sha256}`" not in str( + matches[0].get("body") or "" + ): + client.request( + f"/repos/ContextualWisdomLab/{repository}/issues/{number}/comments", + method="POST", + payload={"body": body}, + ) + return f"ContextualWisdomLab/{repository}#{number}" created = client.request( f"/repos/ContextualWisdomLab/{repository}/issues", method="POST", diff --git a/tests/test_inventory_orphaned_workflows.py b/tests/test_inventory_orphaned_workflows.py index ad9c2d0837..c24ff0bf0d 100644 --- a/tests/test_inventory_orphaned_workflows.py +++ b/tests/test_inventory_orphaned_workflows.py @@ -826,6 +826,85 @@ def test_collect_live_organization_requires_org_wide_visibility_proof() -> None: inventory.collect_live_organization(client) +def test_repository_page_shift_with_same_total_fails_closed() -> None: + """A rename across page boundaries cannot silently omit a repository.""" + first = [ + { + "id": n, + "name": f"repo-{n:03}", + "full_name": f"ContextualWisdomLab/repo-{n:03}", + "archived": True, + } + for n in range(101) + ] + + class Moving: + page = 0 + + def request(self, path: str, **_kwargs: Any) -> Any: + if path == "/orgs/ContextualWisdomLab": + return {"public_repos": 101, "total_private_repos": 0} + self.page += 1 + if self.page == 1: + return first[:100] + if self.page == 2: + return first[100:] + if self.page == 3: + return first[:99] + first[100:] + return [ + { + "id": 102, + "name": "repo-102", + "full_name": "ContextualWisdomLab/repo-102", + "archived": True, + } + ] + + with pytest.raises(inventory.InventoryError, match="changed during pagination"): + inventory.collect_live_organization(Moving()) + + +def test_repository_inventory_fails_on_page_limit_and_malformed_identity( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Unbounded page streams and unusable repository IDs are incomplete evidence.""" + path = "/orgs/ContextualWisdomLab/repos?type=all&sort=full_name&per_page=100&page=1" + entry = {"name": "repo", "full_name": "ContextualWisdomLab/repo", "archived": True} + monkeypatch.setattr(inventory, "MAX_PAGES", 1) + with pytest.raises(inventory.InventoryError, match="pagination exceeded limit"): + inventory.collect_live_organization(_LiveClient({path: [entry] * 100})) + with pytest.raises(inventory.InventoryError, match="identity is malformed"): + inventory.collect_live_organization(_LiveClient({path: [{**entry, "id": []}]})) + + +def test_repository_change_during_scan_fails_closed() -> None: + """A final organization recheck rejects changes after repository reads.""" + path = "/orgs/ContextualWisdomLab/repos?type=all&sort=full_name&per_page=100&page=1" + entry = { + "id": 1, + "name": "repo", + "full_name": "ContextualWisdomLab/repo", + "archived": True, + } + + class Changed: + reads = 0 + + def request(self, request_path: str, **_kwargs: Any) -> Any: + if request_path == "/orgs/ContextualWisdomLab": + return {"public_repos": 1, "total_private_repos": 0} + assert request_path == path + self.reads += 1 + return [ + entry + if self.reads < 3 + else {**entry, "full_name": "ContextualWisdomLab/renamed"} + ] + + with pytest.raises(inventory.InventoryError, match="changed during scan"): + inventory.collect_live_organization(Changed()) + + def test_live_get_retries_one_actual_http_5xx() -> None: """The production exception transport retries only one explicit HTTP 5xx.""" @@ -975,29 +1054,6 @@ def test_collect_live_organization_fails_on_truncated_tree_or_head_move() -> Non inventory.collect_live_organization(_LiveClient(base)) -def test_operator_disable_is_ledger_and_head_bound() -> None: - """The mutation path accepts only a reviewed orphan on its unchanged head.""" - record = { - "repository": "appguardrail", - "workflow_id": 9, - "classification": "orphan_active", - "default_branch_sha": SHA, - } - client = _LiveClient({}) - operator.disable_confirmed_orphan(client, record, confirmed_head_sha=SHA) - assert client.calls == [ - "/repos/ContextualWisdomLab/appguardrail/actions/workflows/9/disable" - ] - with pytest.raises(inventory.InventoryError, match="only"): - operator.disable_confirmed_orphan( - client, - {**record, "classification": "present_active"}, - confirmed_head_sha=SHA, - ) - with pytest.raises(inventory.InventoryError, match="moved"): - operator.disable_confirmed_orphan(client, record, confirmed_head_sha=SHA_B) - - def test_owner_issue_update_and_create_are_explicit() -> None: """Known owners receive an update; unknown owners receive one bounded issue.""" known = { @@ -1008,18 +1064,32 @@ def test_owner_issue_update_and_create_are_explicit() -> None: "default_branch_sha": SHA, } client = _LiveClient({}) - assert operator.publish_owner_issue(client, known, ledger_sha256="c" * 64).endswith( - "#929" - ) + assert operator.publish_owner_issue( + client, known, ledger={"records": [known]} + ).endswith("#929") assert client.calls[-1].endswith("/issues/929/comments") unknown = {**known, "repository": "new-product"} create_path = "/repos/ContextualWisdomLab/new-product/issues" - creator = _LiveClient({create_path: {"number": 41}}) + list_path = ( + "/repos/ContextualWisdomLab/new-product/issues?state=all&per_page=100&page=1" + ) + creator = _LiveClient({list_path: [], create_path: {"number": 41}}) assert operator.publish_owner_issue( - creator, unknown, ledger_sha256="d" * 64 + creator, unknown, ledger={"records": [unknown]} ).endswith("#41") - with pytest.raises(inventory.InventoryError, match="ledger digest"): - operator.publish_owner_issue(client, known, ledger_sha256="bad") + with pytest.raises(inventory.InventoryError, match="ledger-bound"): + operator.publish_owner_issue(client, known, ledger={"records": []}) + prior = {"number": 41, "state": "open", "body": ""} + repeated = _LiveClient( + { + list_path: [prior], + "/repos/ContextualWisdomLab/new-product/issues/41/comments": {}, + } + ) + assert operator.publish_owner_issue( + repeated, unknown, ledger={"records": [unknown]} + ).endswith("#41") + assert all(not path.endswith("/issues") for path in repeated.calls) def test_live_collector_rejects_every_incomplete_api_shape() -> None: @@ -1141,22 +1211,25 @@ def request(self, *_args: Any, **_kwargs: Any) -> Any: "classification": "orphan_active", "default_branch_sha": SHA, } - with pytest.raises(inventory.InventoryError, match="disable failed"): - operator.disable_confirmed_orphan(Broken(), record, confirmed_head_sha=SHA) - with pytest.raises(inventory.InventoryError, match="identity"): - operator.disable_confirmed_orphan( - Broken(), {**record, "workflow_id": "9"}, confirmed_head_sha=SHA - ) with pytest.raises(inventory.InventoryError, match="publication failed"): - operator.publish_owner_issue(Broken(), record, ledger_sha256="e" * 64) + operator.publish_owner_issue(Broken(), record, ledger={"records": [record]}) with pytest.raises(inventory.InventoryError, match="repository"): operator.publish_owner_issue( - Broken(), {**record, "repository": "bad name"}, ledger_sha256="e" * 64 + Broken(), + {**record, "repository": "bad name"}, + ledger={"records": [{**record, "repository": "bad name"}]}, ) - bad_create = _LiveClient({"/repos/ContextualWisdomLab/new-product/issues": {}}) + bad_create = _LiveClient( + { + "/repos/ContextualWisdomLab/new-product/issues?state=all&per_page=100&page=1": [], + "/repos/ContextualWisdomLab/new-product/issues": {}, + } + ) with pytest.raises(inventory.InventoryError, match="no issue number"): operator.publish_owner_issue( - bad_create, {**record, "repository": "new-product"}, ledger_sha256="e" * 64 + bad_create, + {**record, "repository": "new-product"}, + ledger={"records": [{**record, "repository": "new-product"}]}, ) @@ -1279,6 +1352,68 @@ def fail( assert json.loads(failure.read_text())["status"] == "failed" +def test_live_main_records_ledger_write_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """An unwritable ledger target still leaves structured failure evidence.""" + import scripts.ci.organization_commercial_readiness_loop as readiness + + monkeypatch.setattr( + readiness.GitHubClient, "from_environment", classmethod(lambda cls: object()) + ) + monkeypatch.setattr( + inventory, + "collect_live_organization", + lambda _client, **_kwargs: (_payload([_repo("naruon", [], [])]), []), + ) + failure = tmp_path / "failure.json" + assert ( + inventory.main( + [ + "--live", + "--output", + str(tmp_path), + "--receipt-output", + str(tmp_path / "receipts.json"), + "--failure-output", + str(failure), + ] + ) + == 2 + ) + assert json.loads(failure.read_text())["error_type"] == "IsADirectoryError" + + +def test_live_main_ledger_and_failure_targets_unwritable( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """An unwritable failure target leaves a controlled CLI error.""" + import scripts.ci.organization_commercial_readiness_loop as readiness + + monkeypatch.setattr( + readiness.GitHubClient, "from_environment", classmethod(lambda cls: object()) + ) + monkeypatch.setattr( + inventory, + "collect_live_organization", + lambda _client, **_kwargs: (_payload([_repo("naruon", [], [])]), []), + ) + assert ( + inventory.main( + [ + "--live", + "--output", + str(tmp_path), + "--receipt-output", + str(tmp_path / "receipts.json"), + "--failure-output", + str(tmp_path), + ] + ) + == 2 + ) + + def test_incomplete_inventory_fails_before_repository_classification( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_workflow_lifecycle_inventory_workflow.py b/tests/test_workflow_lifecycle_inventory_workflow.py index bbf6cfb18b..0a617c1939 100644 --- a/tests/test_workflow_lifecycle_inventory_workflow.py +++ b/tests/test_workflow_lifecycle_inventory_workflow.py @@ -21,10 +21,13 @@ def test_lifecycle_inventory_workflow_is_read_only_and_exact_head() -> None: def test_read_only_scanner_exports_no_write_primitive() -> None: - """Disable and issue writes live only in the reviewed operator module.""" + """The scanner cannot write and the publisher cannot disable workflows.""" scanner = Path("scripts/ci/inventory_orphaned_workflows.py").read_text() operator = Path("scripts/ci/workflow_lifecycle_operator.py").read_text() assert "def disable_confirmed_orphan" not in scanner assert "def publish_owner_issue" not in scanner - assert "def disable_confirmed_orphan" in operator + assert "def disable_confirmed_orphan" not in operator assert "def publish_owner_issue" in operator + assert "scripts/ci/workflow_lifecycle_operator.py" in Path( + ".github/workflows/workflow-lifecycle-inventory-quality-ci.yml" + ).read_text() From 26961658ef20a050309bccff0f4679669d45c1a4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 25 Sep 2026 00:24:42 +0900 Subject: [PATCH 16/27] experiment: revalidate owner issue evidence before publication --- scripts/ci/workflow_lifecycle_operator.py | 77 ++++++++++++++++++++-- tests/test_inventory_orphaned_workflows.py | 68 ++++++++++++++++++- 2 files changed, 137 insertions(+), 8 deletions(-) diff --git a/scripts/ci/workflow_lifecycle_operator.py b/scripts/ci/workflow_lifecycle_operator.py index fca25e8efb..0b9a820e30 100644 --- a/scripts/ci/workflow_lifecycle_operator.py +++ b/scripts/ci/workflow_lifecycle_operator.py @@ -5,12 +5,17 @@ import hashlib from collections.abc import Mapping from typing import Any +from urllib.parse import quote from scripts.ci.inventory_orphaned_workflows import ( MAX_PAGES, REPO_SLUG, GitHubTransport, InventoryError, + assert_default_branch_bound, + classify_workflow, + is_exact_sha, + is_repository_workflow_path, owner_issue_for, write_ledger, ) @@ -34,14 +39,74 @@ def publish_owner_issue( repository = record.get("repository") if not isinstance(repository, str) or REPO_SLUG.fullmatch(repository) is None: raise InventoryError("issue publication repository is malformed") + workflow_id = record.get("workflow_id") + path = record.get("path") + sha = record.get("default_branch_sha") + if ( + not isinstance(workflow_id, int) + or isinstance(workflow_id, bool) + or workflow_id <= 0 + or not isinstance(path, str) + or not is_repository_workflow_path(path) + or not is_exact_sha(sha) + ): + raise InventoryError("issue publication workflow evidence is malformed") issue = owner_issue_for(repository) - body = ( - "\n" - f"Exact workflow registry evidence: `{record.get('workflow_id')}` / " - f"`{record.get('path')}` at `{record.get('default_branch_sha')}`.\n" - f"Ledger SHA-256: `{ledger_sha256}`.\n" - ) try: + repo_path = f"/repos/ContextualWisdomLab/{repository}" + repo = client.request(repo_path) + if ( + not isinstance(repo, Mapping) + or repo.get("full_name") != f"ContextualWisdomLab/{repository}" + or repo.get("archived") is not False + ): + raise InventoryError("owner repository identity changed") + branch = repo.get("default_branch") + if not isinstance(branch, str) or not branch: + raise InventoryError("owner repository default branch is missing") + commit_path = f"{repo_path}/commits/{quote(branch, safe='')}" + start = client.request(commit_path) + if not isinstance(start, Mapping) or start.get("sha") != sha: + raise InventoryError("owner repository default branch moved") + workflow = client.request(f"{repo_path}/actions/workflows/{workflow_id}") + if ( + not isinstance(workflow, Mapping) + or workflow.get("id") != workflow_id + or workflow.get("path") != path + or classify_workflow( + path=path, state=workflow.get("state"), source_present=False + ) + != "orphan_active" + ): + raise InventoryError("owner workflow identity changed") + tree = client.request(f"{repo_path}/git/trees/{sha}?recursive=1") + entries = tree.get("tree") if isinstance(tree, Mapping) else None + if ( + not isinstance(tree, Mapping) + or tree.get("truncated") is not False + or not isinstance(entries, list) + or any( + not isinstance(item, Mapping) + or not isinstance(item.get("path"), str) + or item.get("type") not in {"blob", "tree", "commit"} + for item in entries + ) + or any( + item.get("type") == "blob" and item.get("path") == path + for item in entries + ) + ): + raise InventoryError("owner workflow source absence is unverified") + end = client.request(commit_path) + assert_default_branch_bound( + sha, end.get("sha") if isinstance(end, Mapping) else None + ) + body = ( + "\n" + f"Exact workflow registry evidence: `{workflow_id}` / " + f"`{path}` at `{sha}`.\n" + f"Ledger SHA-256: `{ledger_sha256}`.\n" + ) if issue is not None: number = issue.rsplit("#", 1)[1] client.request( diff --git a/tests/test_inventory_orphaned_workflows.py b/tests/test_inventory_orphaned_workflows.py index c24ff0bf0d..b39cfcb176 100644 --- a/tests/test_inventory_orphaned_workflows.py +++ b/tests/test_inventory_orphaned_workflows.py @@ -771,6 +771,30 @@ def request(self, path: str, *, method: str = "GET", payload: Any = None) -> Any return value +def _owner_live_responses(record: dict[str, Any]) -> dict[str, Any]: + repo = f"/repos/ContextualWisdomLab/{record['repository']}" + return { + repo: { + "full_name": f"ContextualWisdomLab/{record['repository']}", + "archived": False, + "default_branch": "main", + }, + f"{repo}/commits/main": [ + {"sha": record["default_branch_sha"]}, + {"sha": record["default_branch_sha"]}, + ], + f"{repo}/actions/workflows/{record['workflow_id']}": { + "id": record["workflow_id"], + "path": record["path"], + "state": "active", + }, + f"{repo}/git/trees/{record['default_branch_sha']}?recursive=1": { + "truncated": False, + "tree": [], + }, + } + + def test_collect_live_organization_paginates_and_rechecks_head() -> None: """The live collector consumes every page and binds both head reads.""" repo = "ContextualWisdomLab/appguardrail" @@ -1063,7 +1087,7 @@ def test_owner_issue_update_and_create_are_explicit() -> None: "classification": "orphan_active", "default_branch_sha": SHA, } - client = _LiveClient({}) + client = _LiveClient(_owner_live_responses(known)) assert operator.publish_owner_issue( client, known, ledger={"records": [known]} ).endswith("#929") @@ -1073,7 +1097,11 @@ def test_owner_issue_update_and_create_are_explicit() -> None: list_path = ( "/repos/ContextualWisdomLab/new-product/issues?state=all&per_page=100&page=1" ) - creator = _LiveClient({list_path: [], create_path: {"number": 41}}) + creator = _LiveClient({ + **_owner_live_responses(unknown), + list_path: [], + create_path: {"number": 41}, + }) assert operator.publish_owner_issue( creator, unknown, ledger={"records": [unknown]} ).endswith("#41") @@ -1082,6 +1110,7 @@ def test_owner_issue_update_and_create_are_explicit() -> None: prior = {"number": 41, "state": "open", "body": ""} repeated = _LiveClient( { + **_owner_live_responses(unknown), list_path: [prior], "/repos/ContextualWisdomLab/new-product/issues/41/comments": {}, } @@ -1092,6 +1121,40 @@ def test_owner_issue_update_and_create_are_explicit() -> None: assert all(not path.endswith("/issues") for path in repeated.calls) +def test_owner_issue_rejects_restored_source_before_post() -> None: + record = { + "repository": "appguardrail", + "workflow_id": 9, + "path": ".github/workflows/gone.yml", + "classification": "orphan_active", + "default_branch_sha": SHA, + } + responses = _owner_live_responses(record) + tree_path = f"/repos/ContextualWisdomLab/appguardrail/git/trees/{SHA}?recursive=1" + responses[tree_path]["tree"] = [{"type": "blob", "path": record["path"]}] + client = _LiveClient(responses) + with pytest.raises(inventory.InventoryError, match="publication failed"): + operator.publish_owner_issue(client, record, ledger={"records": [record]}) + assert all("/issues/" not in path for path in client.calls) + + +def test_owner_issue_rejects_changed_workflow_identity_before_post() -> None: + record = { + "repository": "appguardrail", + "workflow_id": 9, + "path": ".github/workflows/gone.yml", + "classification": "orphan_active", + "default_branch_sha": SHA, + } + responses = _owner_live_responses(record) + workflow_path = "/repos/ContextualWisdomLab/appguardrail/actions/workflows/9" + responses[workflow_path]["path"] = ".github/workflows/replacement.yml" + client = _LiveClient(responses) + with pytest.raises(inventory.InventoryError, match="publication failed"): + operator.publish_owner_issue(client, record, ledger={"records": [record]}) + assert all("/issues/" not in path for path in client.calls) + + def test_live_collector_rejects_every_incomplete_api_shape() -> None: """All malformed live inventory boundaries fail closed.""" org_path = ( @@ -1221,6 +1284,7 @@ def request(self, *_args: Any, **_kwargs: Any) -> Any: ) bad_create = _LiveClient( { + **_owner_live_responses({**record, "repository": "new-product"}), "/repos/ContextualWisdomLab/new-product/issues?state=all&per_page=100&page=1": [], "/repos/ContextualWisdomLab/new-product/issues": {}, } From 48abe0e1cd257e18e635b26fa5375de7a20380de Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 25 Sep 2026 00:29:52 +0900 Subject: [PATCH 17/27] docs: require live proof before owner issue publication --- docs/doctoring/orphaned-workflow-lifecycle.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/doctoring/orphaned-workflow-lifecycle.md b/docs/doctoring/orphaned-workflow-lifecycle.md index 806b4ecf6b..d95e99973f 100644 --- a/docs/doctoring/orphaned-workflow-lifecycle.md +++ b/docs/doctoring/orphaned-workflow-lifecycle.md @@ -106,9 +106,12 @@ reviewed operator pass, rerun the organization sweep and retain both receipt set Known AppGuardrail, Clearfolio, and DiskSage owner routes bind the same live evidence to their governance issues without heuristic issue creation. The separately invoked owner-issue publisher verifies that each finding belongs -to the supplied ledger, computes that ledger's digest, and scans existing owner -issues before creating one for a repository without a known route. It does not -run in the read-only inventory job. +to the supplied ledger, then rechecks the live repository, default-branch SHA, +workflow ID/path/state, and complete source tree before posting. A changed +identity or restored source file stops publication. The publisher computes the +ledger's digest and scans existing owner issues before creating one for a +repository without a known route. It does not run in the read-only inventory +job. ## Rollback From 19ff8c87bec113007e8fe0b3708aabc838281179 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 25 Sep 2026 00:41:45 +0900 Subject: [PATCH 18/27] docs: define temporary workflow lifecycle contract --- docs/doctoring/orphaned-workflow-lifecycle.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/doctoring/orphaned-workflow-lifecycle.md b/docs/doctoring/orphaned-workflow-lifecycle.md index d95e99973f..fab716f75d 100644 --- a/docs/doctoring/orphaned-workflow-lifecycle.md +++ b/docs/doctoring/orphaned-workflow-lifecycle.md @@ -63,6 +63,23 @@ CWE-862 describes missing authorization when a registry mutation is performed without a reviewed operator path. This increment closes the visibility gap and refuses the mutation. +## Prevention contract for repository workflows + +A bounded repair workflow must take one of two paths before its PR is merged: + +1. Keep the workflow on a short-lived branch, complete its work through a normal + PR, and remove the YAML before it reaches the protected default branch. It + never acquires a default-branch workflow registry identity. +2. If the workflow must reach the protected default branch, record its lifecycle + owner and expiry condition in the PR. The owner must arrange a separately + reviewed, exact-ID registry-disable action after use, then retain the API + receipt and a fresh inventory showing the identity disabled. + +Deleting the YAML alone does not satisfy the second path. A later inventory +finding remains open until the owner either completes that disablement or +documents an explicit reviewed exception. Neither a workflow name nor a +missing source file authorizes disablement by the read-only scanner. + ## Operator contract For a live read-only sweep, run: From e0c860c802e128c14b088598437847066ea4ec8e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 25 Sep 2026 01:00:11 +0900 Subject: [PATCH 19/27] feat: bind owner issue publication to reviewed ledger --- docs/doctoring/orphaned-workflow-lifecycle.md | 16 ++++ scripts/ci/workflow_lifecycle_operator.py | 87 ++++++++++++++++++- tests/test_inventory_orphaned_workflows.py | 47 ++++++++++ 3 files changed, 149 insertions(+), 1 deletion(-) diff --git a/docs/doctoring/orphaned-workflow-lifecycle.md b/docs/doctoring/orphaned-workflow-lifecycle.md index fab716f75d..608aaab230 100644 --- a/docs/doctoring/orphaned-workflow-lifecycle.md +++ b/docs/doctoring/orphaned-workflow-lifecycle.md @@ -130,6 +130,22 @@ ledger's digest and scans existing owner issues before creating one for a repository without a known route. It does not run in the read-only inventory job. +For one separately reviewed finding, the operator supplies the exact ledger +digest, repository, and workflow ID: + +```bash +python3 -m scripts.ci.workflow_lifecycle_operator \ + --ledger /tmp/workflow-lifecycle-ledger.json \ + --expected-ledger-sha256 "$REVIEWED_LEDGER_SHA256" \ + --repository appguardrail --workflow-id "$REVIEWED_WORKFLOW_ID" +``` + +The digest binds the reviewed file; it is not approval or fleet-completeness +proof by itself. Review the ledger and its API receipts first. The command +accepts one identity, requires the canonical ledger and its completeness +marker, and reads live GitHub evidence again before its issue write. It never +disables a workflow. + ## Rollback Rollback removes the inventory script, focused tests, schema example, diff --git a/scripts/ci/workflow_lifecycle_operator.py b/scripts/ci/workflow_lifecycle_operator.py index 0b9a820e30..43fdf0e5f7 100644 --- a/scripts/ci/workflow_lifecycle_operator.py +++ b/scripts/ci/workflow_lifecycle_operator.py @@ -2,12 +2,18 @@ from __future__ import annotations +import argparse import hashlib -from collections.abc import Mapping +import json +import sys +from collections.abc import Mapping, Sequence +from pathlib import Path from typing import Any from urllib.parse import quote from scripts.ci.inventory_orphaned_workflows import ( + CAPABILITY, + HEX_SHA256, MAX_PAGES, REPO_SLUG, GitHubTransport, @@ -17,9 +23,17 @@ is_exact_sha, is_repository_workflow_path, owner_issue_for, + reject_duplicate_keys, write_ledger, ) +MAX_LEDGER_BYTES = 16_777_216 + + +def reject_non_json_constant(value: str) -> None: + """Refuse non-standard JSON numbers in reviewed evidence.""" + raise InventoryError(f"non-JSON ledger constant {value}") + def publish_owner_issue( client: GitHubTransport, @@ -169,3 +183,74 @@ def publish_owner_issue( if not isinstance(number, int) or number <= 0: raise InventoryError("owner issue creation returned no issue number") return f"ContextualWisdomLab/{repository}#{number}" + + +def main(argv: Sequence[str] | None = None) -> int: + """Publish one reviewed ledger finding after fresh live revalidation.""" + parser = argparse.ArgumentParser(description="Publish one workflow owner issue.") + parser.add_argument("--ledger", required=True) + parser.add_argument("--expected-ledger-sha256", required=True) + parser.add_argument("--repository", required=True) + parser.add_argument("--workflow-id", type=int, required=True) + args = parser.parse_args(argv) + try: + if HEX_SHA256.fullmatch(args.expected_ledger_sha256) is None: + raise InventoryError("reviewed ledger SHA-256 is malformed") + if REPO_SLUG.fullmatch(args.repository) is None or args.workflow_id <= 0: + raise InventoryError("operator target is malformed") + # ponytail: 16 MiB bounds parsing; raise only after measured fleet growth. + with Path(args.ledger).open("rb") as source: + raw = source.read(MAX_LEDGER_BYTES + 1) + if not raw or len(raw) > MAX_LEDGER_BYTES: + raise InventoryError("reviewed ledger is empty or exceeds 16 MiB") + if hashlib.sha256(raw).hexdigest() != args.expected_ledger_sha256: + raise InventoryError("reviewed ledger SHA-256 does not match") + ledger = json.loads( + raw.decode("utf-8"), + object_pairs_hook=reject_duplicate_keys, + parse_constant=reject_non_json_constant, + ) + if ( + not isinstance(ledger, dict) + or ledger.get("schema_version") != "1" + or ledger.get("capability") != CAPABILITY + or ledger.get("organization") != "ContextualWisdomLab" + or ledger.get("repository_inventory_complete") is not True + or write_ledger(ledger, None).encode() != raw + ): + raise InventoryError( + "reviewed ledger is not a canonical complete inventory" + ) + records = ledger.get("records") + if not isinstance(records, list): + raise InventoryError("reviewed ledger has no records") + matches = [ + item + for item in records + if isinstance(item, Mapping) + and item.get("repository") == args.repository + and item.get("workflow_id") == args.workflow_id + ] + if len(matches) != 1: + raise InventoryError("reviewed ledger target is missing or ambiguous") + from scripts.ci.organization_commercial_readiness_loop import GitHubClient + + try: + client = GitHubClient.from_environment() + except Exception as exc: + raise InventoryError( + f"operator credential unavailable: {type(exc).__name__}" + ) from exc + issue = publish_owner_issue(client, matches[0], ledger=ledger) + except (InventoryError, OSError, UnicodeError, json.JSONDecodeError) as exc: + print( + f"ERROR: owner issue publication refused: {type(exc).__name__}", + file=sys.stderr, + ) + return 2 + print(issue) + return 0 + + +if __name__ == "__main__": # pragma: no cover - exercised through main() + raise SystemExit(main()) diff --git a/tests/test_inventory_orphaned_workflows.py b/tests/test_inventory_orphaned_workflows.py index b39cfcb176..f4d9c914ce 100644 --- a/tests/test_inventory_orphaned_workflows.py +++ b/tests/test_inventory_orphaned_workflows.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import json from pathlib import Path from typing import Any @@ -1155,6 +1156,52 @@ def test_owner_issue_rejects_changed_workflow_identity_before_post() -> None: assert all("/issues/" not in path for path in client.calls) +def test_owner_issue_cli_requires_one_reviewed_ledger_identity( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + from scripts.ci.organization_commercial_readiness_loop import GitHubClient + + record = { + "repository": "appguardrail", + "workflow_id": 9, + "path": ".github/workflows/gone.yml", + "classification": "orphan_active", + "default_branch_sha": SHA, + } + ledger = { + "schema_version": "1", + "capability": inventory.CAPABILITY, + "organization": "ContextualWisdomLab", + "repository_inventory_complete": True, + "records": [record], + } + ledger_path = tmp_path / "ledger.json" + raw = inventory.write_ledger(ledger, None).encode() + ledger_path.write_bytes(raw) + client = _LiveClient(_owner_live_responses(record)) + monkeypatch.setattr(GitHubClient, "from_environment", lambda: client) + args = [ + "--ledger", str(ledger_path), + "--expected-ledger-sha256", "0" * 64, + "--repository", "appguardrail", + "--workflow-id", "9", + ] + assert operator.main(args) == 2 + assert client.calls == [] + args[3] = hashlib.sha256(raw).hexdigest() + assert operator.main(args) == 0 + assert "ContextualWisdomLab/appguardrail#929" in capsys.readouterr().out + assert client.calls[-1].endswith("/issues/929/comments") + + ledger["records"].append(dict(record)) + raw = inventory.write_ledger(ledger, None).encode() + ledger_path.write_bytes(raw) + args[3] = hashlib.sha256(raw).hexdigest() + call_count = len(client.calls) + assert operator.main(args) == 2 + assert len(client.calls) == call_count + + def test_live_collector_rejects_every_incomplete_api_shape() -> None: """All malformed live inventory boundaries fail closed.""" org_path = ( From bf01048b9ea72f7f9d81a67400c3ba320aa76533 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 25 Sep 2026 02:31:36 +0900 Subject: [PATCH 20/27] fix: preserve lifecycle evidence and isolate owner updates --- .../workflows/pr-review-merge-scheduler.yml | 2 +- docs/doctoring/orphaned-workflow-lifecycle.md | 4 + scripts/ci/inventory_orphaned_workflows.py | 79 ++++++++++--------- scripts/ci/test_strix_quick_gate.sh | 2 +- scripts/ci/workflow_lifecycle_operator.py | 48 +++++++---- tests/test_inventory_orphaned_workflows.py | 72 ++++++++++++++++- .../test_required_workflow_queue_contract.py | 5 +- 7 files changed, 154 insertions(+), 58 deletions(-) diff --git a/.github/workflows/pr-review-merge-scheduler.yml b/.github/workflows/pr-review-merge-scheduler.yml index d98a72e605..6a1307c28d 100644 --- a/.github/workflows/pr-review-merge-scheduler.yml +++ b/.github/workflows/pr-review-merge-scheduler.yml @@ -86,7 +86,7 @@ concurrency: github.event_name == 'schedule' && format('schedule-{0}', github.event.schedule) || github.event_name == 'repository_dispatch' && github.event.client_payload.target_repository != '' && github.event.client_payload.pr_number != '' && format('target-{0}-pr-{1}', github.event.client_payload.target_repository, github.event.client_payload.pr_number) || github.event_name == 'repository_dispatch' && github.event.client_payload.pr_number != '' && format('pr-{0}', github.event.client_payload.pr_number) || - github.event_name == 'repository_dispatch' && format('repo-dispatch-{0}', github.repository) || + github.event_name == 'repository_dispatch' && format('repo-dispatch-{0}', github.event.client_payload.target_repository || github.repository) || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'repository_dispatch' }} diff --git a/docs/doctoring/orphaned-workflow-lifecycle.md b/docs/doctoring/orphaned-workflow-lifecycle.md index 608aaab230..8550235fd6 100644 --- a/docs/doctoring/orphaned-workflow-lifecycle.md +++ b/docs/doctoring/orphaned-workflow-lifecycle.md @@ -129,6 +129,10 @@ identity or restored source file stops publication. The publisher computes the ledger's digest and scans existing owner issues before creating one for a repository without a known route. It does not run in the read-only inventory job. +Each owner issue is matched to the exact workflow ID and path. Before posting, +the publisher checks the open issue and every comment for the exact evidence +body, so retrying the same ledger does not duplicate a comment or hide another +workflow from that ledger. For one separately reviewed finding, the operator supplies the exact ledger digest, repository, and workflow ID: diff --git a/scripts/ci/inventory_orphaned_workflows.py b/scripts/ci/inventory_orphaned_workflows.py index 9119ad2be5..1cd2270484 100755 --- a/scripts/ci/inventory_orphaned_workflows.py +++ b/scripts/ci/inventory_orphaned_workflows.py @@ -638,6 +638,31 @@ def write_ledger(ledger: Mapping[str, Any], output: Path | None) -> str: return text +def write_failure_evidence( + receipt_output: str | None, + failure_output: str | None, + receipts: list[dict[str, Any]], + error: Exception, +) -> None: + """Best-effort bounded evidence after a live collection or write failure.""" + for path, content in ( + (receipt_output, receipts), + (failure_output, { + "capability": CAPABILITY, + "status": "failed", + "error_type": type(error).__name__, + }), + ): + if path: + try: + Path(path).write_text( + json.dumps(content, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + except OSError: + pass + + def main(argv: Sequence[str] | None = None) -> int: """Load a fixture payload, emit a ledger, and optionally fail on orphans.""" parser = argparse.ArgumentParser( @@ -694,9 +719,6 @@ def main(argv: Sequence[str] | None = None) -> int: payload, receipts = collect_live_organization( client, receipts=live_receipts ) - Path(args.receipt_output).write_text( - json.dumps(receipts, indent=2, sort_keys=True) + "\n", encoding="utf-8" - ) else: raw = Path(args.payload).read_bytes() payload = load_payload_bytes(raw) @@ -709,47 +731,26 @@ def main(argv: Sequence[str] | None = None) -> int: return 2 except InventoryError as exc: if args.live: - if args.receipt_output: - Path(args.receipt_output).write_text( - json.dumps(live_receipts, indent=2, sort_keys=True) + "\n", - encoding="utf-8", - ) - if args.failure_output: - Path(args.failure_output).write_text( - json.dumps( - { - "capability": CAPABILITY, - "status": "failed", - "error_type": type(exc).__name__, - }, - indent=2, - sort_keys=True, - ) - + "\n", - encoding="utf-8", - ) + write_failure_evidence( + args.receipt_output, args.failure_output, live_receipts, exc + ) print(f"ERROR: {exc}", file=sys.stderr) return 2 + if args.live: + try: + Path(args.receipt_output).write_text( + json.dumps(receipts, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + except OSError as exc: + write_failure_evidence(None, args.failure_output, live_receipts, exc) + print(f"ERROR: unable to write receipts: {exc}", file=sys.stderr) + return 2 try: text = write_ledger(ledger, Path(args.output) if args.output else None) except (FileNotFoundError, OSError) as exc: - if args.live and args.failure_output: - try: - Path(args.failure_output).write_text( - json.dumps( - { - "capability": CAPABILITY, - "status": "failed", - "error_type": type(exc).__name__, - }, - indent=2, - sort_keys=True, - ) - + "\n", - encoding="utf-8", - ) - except OSError: - pass + if args.live: + write_failure_evidence(None, args.failure_output, live_receipts, exc) print(f"ERROR: unable to write ledger: {exc}", file=sys.stderr) return 2 if args.output is None: diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index aa39ddd498..7de05b49d0 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1578,7 +1578,7 @@ assert_pr_review_merge_scheduler_uses_github_actions_bot_token() { assert_file_not_contains "$workflow_file" "github.event.pull_request.number == 240" "scheduler must not hard-code repository-specific PR bypasses" assert_file_contains "$workflow_file" "github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number)" "scheduler scopes pull_request_target concurrency to the active PR" assert_file_contains "$workflow_file" "github.event_name == 'schedule' && format('schedule-{0}', github.event.schedule)" "scheduler isolates repository-local recovery from PR runs" - assert_file_contains "$workflow_file" "github.event_name == 'repository_dispatch' && format('repo-dispatch-{0}', github.repository)" "scheduler keeps manual queue scans isolated per repository dispatch target" + assert_file_contains "$workflow_file" "github.event_name == 'repository_dispatch' && format('repo-dispatch-{0}', github.event.client_payload.target_repository || github.repository)" "scheduler keeps manual queue scans isolated per repository dispatch target" assert_file_contains "$workflow_file" "github.event_name == 'repository_dispatch' && github.event.client_payload.target_repository != '' && github.event.client_payload.pr_number != ''" "scheduler scopes targeted manual queue scans to the requested PR" assert_file_contains "$workflow_file" "cancel-in-progress: \${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'repository_dispatch' }}" "scheduler cancels stale PR/review/manual queue scans instead of accumulating merge/update attempts" assert_file_not_contains "$workflow_file" 'github.event.workflow_run' "scheduler does not poll required-check completion through follow-up workflow runs" diff --git a/scripts/ci/workflow_lifecycle_operator.py b/scripts/ci/workflow_lifecycle_operator.py index 43fdf0e5f7..52ed21b89c 100644 --- a/scripts/ci/workflow_lifecycle_operator.py +++ b/scripts/ci/workflow_lifecycle_operator.py @@ -35,6 +35,29 @@ def reject_non_json_constant(value: str) -> None: raise InventoryError(f"non-JSON ledger constant {value}") +def issue_already_has_evidence( + client: GitHubTransport, repository: str, number: int, body: str +) -> bool: + """Require an open owner issue and find exact evidence across all comments.""" + issue_path = f"/repos/ContextualWisdomLab/{repository}/issues/{number}" + existing = client.request(issue_path) + if not isinstance(existing, Mapping) or existing.get("state") != "open": + raise InventoryError("owner issue is closed or unavailable") + if existing.get("body") == body: + return True + for page in range(1, MAX_PAGES + 1): + comments = client.request(f"{issue_path}/comments?per_page=100&page={page}") + if not isinstance(comments, list) or any( + not isinstance(comment, Mapping) for comment in comments + ): + raise InventoryError("owner issue comment inventory is incomplete") + if any(comment.get("body") == body for comment in comments): + return True + if len(comments) < 100: + return False + raise InventoryError("owner issue comment pagination exceeded limit") + + def publish_owner_issue( client: GitHubTransport, record: Mapping[str, Any], @@ -115,21 +138,24 @@ def publish_owner_issue( assert_default_branch_bound( sha, end.get("sha") if isinstance(end, Mapping) else None ) + marker = ( + f"" + ) body = ( - "\n" + f"{marker}\n" f"Exact workflow registry evidence: `{workflow_id}` / " f"`{path}` at `{sha}`.\n" f"Ledger SHA-256: `{ledger_sha256}`.\n" ) if issue is not None: number = issue.rsplit("#", 1)[1] - client.request( - f"/repos/ContextualWisdomLab/{repository}/issues/{number}/comments", - method="POST", - payload={"body": body}, - ) + if not issue_already_has_evidence(client, repository, int(number), body): + client.request( + f"/repos/ContextualWisdomLab/{repository}/issues/{number}/comments", + method="POST", + payload={"body": body}, + ) return issue - marker = "" page = 1 matches: list[Mapping[str, Any]] = [] while True: @@ -154,13 +180,7 @@ def publish_owner_issue( number = matches[0].get("number") if not isinstance(number, int) or number <= 0 or len(matches) != 1: raise InventoryError("owner issue identity is ambiguous") - if matches[0].get("state") != "open": - raise InventoryError( - "owner issue is closed; operator review is required" - ) - if f"Ledger SHA-256: `{ledger_sha256}`" not in str( - matches[0].get("body") or "" - ): + if not issue_already_has_evidence(client, repository, number, body): client.request( f"/repos/ContextualWisdomLab/{repository}/issues/{number}/comments", method="POST", diff --git a/tests/test_inventory_orphaned_workflows.py b/tests/test_inventory_orphaned_workflows.py index f4d9c914ce..8178a67eb0 100644 --- a/tests/test_inventory_orphaned_workflows.py +++ b/tests/test_inventory_orphaned_workflows.py @@ -749,11 +749,13 @@ class _LiveClient: def __init__(self, responses: dict[str, Any]) -> None: self.responses = responses self.calls: list[str] = [] + self.writes: list[tuple[str, Any]] = [] def request(self, path: str, *, method: str = "GET", payload: Any = None) -> Any: """Return one canned response and retain its exact request path.""" if method in {"PUT", "POST"}: self.calls.append(path) + self.writes.append((path, payload)) return self.responses.get(path) assert method == "GET" assert payload is None @@ -774,7 +776,7 @@ def request(self, path: str, *, method: str = "GET", payload: Any = None) -> Any def _owner_live_responses(record: dict[str, Any]) -> dict[str, Any]: repo = f"/repos/ContextualWisdomLab/{record['repository']}" - return { + responses = { repo: { "full_name": f"ContextualWisdomLab/{record['repository']}", "archived": False, @@ -794,6 +796,10 @@ def _owner_live_responses(record: dict[str, Any]) -> dict[str, Any]: "tree": [], }, } + if record["repository"] == "appguardrail": + responses[f"{repo}/issues/929"] = {"state": "open", "body": "owner issue"} + responses[f"{repo}/issues/929/comments?per_page=100&page=1"] = [] + return responses def test_collect_live_organization_paginates_and_rechecks_head() -> None: @@ -1108,11 +1114,17 @@ def test_owner_issue_update_and_create_are_explicit() -> None: ).endswith("#41") with pytest.raises(inventory.InventoryError, match="ledger-bound"): operator.publish_owner_issue(client, known, ledger={"records": []}) - prior = {"number": 41, "state": "open", "body": ""} + prior = { + "number": 41, + "state": "open", + "body": "", + } repeated = _LiveClient( { **_owner_live_responses(unknown), list_path: [prior], + "/repos/ContextualWisdomLab/new-product/issues/41": prior, + "/repos/ContextualWisdomLab/new-product/issues/41/comments?per_page=100&page=1": [], "/repos/ContextualWisdomLab/new-product/issues/41/comments": {}, } ) @@ -1122,6 +1134,39 @@ def test_owner_issue_update_and_create_are_explicit() -> None: assert all(not path.endswith("/issues") for path in repeated.calls) +def test_owner_issue_dedupes_exact_comment_and_keeps_workflows_distinct() -> None: + """One ledger can carry several IDs; retries must not publish twice.""" + record = { + "repository": "appguardrail", + "workflow_id": 9, + "path": ".github/workflows/gone.yml", + "classification": "orphan_active", + "default_branch_sha": SHA, + } + other = {**record, "workflow_id": 10, "path": ".github/workflows/other.yml"} + ledger = {"records": [record, other]} + responses = _owner_live_responses(record) + repo = "/repos/ContextualWisdomLab/appguardrail" + responses[f"{repo}/actions/workflows/10"] = { + "id": 10, "path": other["path"], "state": "active" + } + first = _LiveClient(responses) + operator.publish_owner_issue(first, record, ledger=ledger) + evidence = first.writes[-1][1]["body"] + responses[f"{repo}/issues/929/comments?per_page=100&page=1"] = [ + {"body": evidence} + ] + responses[f"{repo}/commits/main"] = [{"sha": SHA}, {"sha": SHA}] + repeated = _LiveClient(responses) + operator.publish_owner_issue(repeated, record, ledger=ledger) + assert repeated.writes == [] + responses[f"{repo}/commits/main"] = [{"sha": SHA}, {"sha": SHA}] + distinct = _LiveClient(responses) + operator.publish_owner_issue(distinct, other, ledger=ledger) + assert len(distinct.writes) == 1 + assert "workflow_id=10" in distinct.writes[0][1]["body"] + + def test_owner_issue_rejects_restored_source_before_post() -> None: record = { "repository": "appguardrail", @@ -1525,6 +1570,29 @@ def test_live_main_ledger_and_failure_targets_unwritable( ) +def test_live_main_receipt_write_failure_is_controlled( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """A bad receipt target reports the write error and preserves failure proof.""" + import scripts.ci.organization_commercial_readiness_loop as readiness + + monkeypatch.setattr( + readiness.GitHubClient, "from_environment", classmethod(lambda cls: object()) + ) + monkeypatch.setattr( + inventory, + "collect_live_organization", + lambda _client, **_kwargs: (_payload([_repo("naruon", [], [])]), []), + ) + failure = tmp_path / "failure.json" + assert inventory.main([ + "--live", "--receipt-output", str(tmp_path), + "--failure-output", str(failure), + ]) == 2 + assert "unable to write receipts" in capsys.readouterr().err + assert json.loads(failure.read_text())["error_type"] == "IsADirectoryError" + + def test_incomplete_inventory_fails_before_repository_classification( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index 87277d45f5..51826b9712 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -220,7 +220,10 @@ def test_merge_scheduler_uses_native_auto_merge_after_required_checks() -> None: )[0] assert "org-sweep" not in concurrency_contract - assert "format('repo-dispatch-{0}', github.repository)" in concurrency_contract + assert ( + "format('repo-dispatch-{0}', github.event.client_payload.target_repository || github.repository)" + in concurrency_contract + ) assert "workflow_run:" not in workflow.split("workflow_call:", 1)[0] assert "github.event.workflow_run" not in concurrency_contract assert "github.event_name == 'repository_dispatch' && github.run_id" not in ( From 1c5230523cd89748e3b629aad9740007dccf3372 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 25 Sep 2026 02:55:27 +0900 Subject: [PATCH 21/27] test: repair Strix gate baseline fixtures --- scripts/ci/test_strix_quick_gate.sh | 32 ++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 150b9102b3..b18d9a1603 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -640,7 +640,7 @@ assert_opencode_review_uses_codegraph_and_contextual_orchestrator() { assert_file_not_contains "$workflow_file" 'ref: ${{ github.workflow_sha }}' "opencode trusted checkout never bypasses the validated ref output" assert_file_contains "$workflow_file" "target_repository:" "opencode repository_dispatch can target a repository whose PR does not inherit required workflows" assert_file_contains "$workflow_file" "Materialize pull request merge tree for coverage measurement" "opencode coverage measures the PR merge tree instead of exposing secrets to untrusted checkout actions" - assert_file_contains "$workflow_file" 'TARGET_REPOSITORY: ${{ needs.validate-pr-metadata.outputs.target_repository }}' "opencode coverage fetches exact validated base/head commits from the target repository" + assert_file_contains "$workflow_file" 'TARGET_REPOSITORY: ${{ steps.validate.outputs.target_repository }}' "opencode coverage fetches exact validated base/head commits from the target repository" assert_file_contains "$workflow_file" "Exchange OpenCode app token for target repository review reads" "opencode review can read private target repositories through the OpenCode app token before materializing review data" assert_file_contains "$workflow_file" 'GH_TOKEN: ${{ steps.review_read_app_token.outputs.token || secrets.OPENCODE_APPROVE_TOKEN || github.token }}' "opencode materialization prefers the OpenCode app token for private target repository reads" assert_file_contains "$workflow_file" '[ "${GH_REPOSITORY:-}" != "${GITHUB_REPOSITORY:-}" ]' "opencode approval uses the app token for target-repository check lookup" @@ -968,13 +968,14 @@ assert_opencode_review_uses_codegraph_and_contextual_orchestrator() { assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" "exponential backoff" "opencode model retry paths use exponential backoff instead of fixed sleeps" assert_file_contains "$workflow_file" '"enabled_providers": ["contextual-orchestrator"]' "opencode review keeps the generated provider set gateway-only" assert_file_contains "$workflow_file" '"model": "contextual-orchestrator/orchestrator/free"' "opencode review keeps the generated model on orchestrator/free" - assert_file_contains "$workflow_file" "coverage-source-tree:" "opencode workflow materializes coverage source before running PR-head tests" + assert_file_contains "$workflow_file" "- name: Materialize pull request merge tree for coverage measurement" "opencode workflow materializes coverage source before running PR-head tests" assert_file_contains "$workflow_file" "coverage-evidence:" "opencode workflow measures coverage before review" assert_file_contains "$workflow_file" "Materialize pull request merge tree for coverage measurement" "required OpenCode reviews measure coverage instead of approving skipped coverage evidence" assert_file_contains "$workflow_file" "Exchange OpenCode app token for target repository coverage reads" "coverage source materialization can read private target repositories during central manual dispatch" assert_file_contains "$workflow_file" "Upload materialized pull request merge tree" "coverage source materialization passes only a prepared merge tree artifact to the PR-head coverage job" assert_file_contains "$workflow_file" "Download materialized pull request merge tree" "coverage evidence consumes the prepared merge tree artifact without target-repository credentials" - assert_file_contains "$workflow_file" "Report coverage source materialization failure" "coverage evidence logs source materialization failures as the coverage blocker" + assert_file_contains "$workflow_file" "Coverage fetch could not authenticate to" "coverage source materialization failures remain explicit blockers" + assert_file_contains "$workflow_file" "Coverage merge tree could not be materialized" "coverage merge conflicts remain explicit blockers" local coverage_merge_tree_step coverage_merge_tree_step="$( awk ' @@ -3296,6 +3297,7 @@ run_gate_case() { local gate_under_test="$repo_root_dir/scripts/ci/strix_quick_gate.sh" cp "$GATE_SCRIPT" "$gate_under_test" cp "$REPO_ROOT/scripts/ci/strix_model_utils.sh" "$repo_root_dir/scripts/ci/strix_model_utils.sh" + cp "$REPO_ROOT/scripts/ci/strix_evidence_binding.py" "$repo_root_dir/scripts/ci/strix_evidence_binding.py" chmod +x "$gate_under_test" local fake_strix="$bin_dir/strix" local path_hijack_log="$tmp_dir/path-hijack.log" @@ -7026,6 +7028,7 @@ run_pull_request_target_head_scope_case() { mkdir -p "$bin_dir" "$repo_root_dir/scripts/ci" cp "$GATE_SCRIPT" "$repo_root_dir/scripts/ci/strix_quick_gate.sh" cp "$REPO_ROOT/scripts/ci/strix_model_utils.sh" "$repo_root_dir/scripts/ci/strix_model_utils.sh" + cp "$REPO_ROOT/scripts/ci/strix_evidence_binding.py" "$repo_root_dir/scripts/ci/strix_evidence_binding.py" chmod +x "$repo_root_dir/scripts/ci/strix_quick_gate.sh" local fake_strix="$bin_dir/strix" @@ -7174,6 +7177,7 @@ run_pull_request_target_plaintext_runner_token_fails_closed_case() { mkdir -p "$bin_dir" "$repo_root_dir/scripts/ci" cp "$GATE_SCRIPT" "$repo_root_dir/scripts/ci/strix_quick_gate.sh" cp "$REPO_ROOT/scripts/ci/strix_model_utils.sh" "$repo_root_dir/scripts/ci/strix_model_utils.sh" + cp "$REPO_ROOT/scripts/ci/strix_evidence_binding.py" "$repo_root_dir/scripts/ci/strix_evidence_binding.py" chmod +x "$repo_root_dir/scripts/ci/strix_quick_gate.sh" local fake_strix="$bin_dir/strix" @@ -7296,6 +7300,7 @@ run_pull_request_target_bounded_head_context_scope_case() { mkdir -p "$bin_dir" "$repo_root_dir/scripts/ci" cp "$GATE_SCRIPT" "$repo_root_dir/scripts/ci/strix_quick_gate.sh" cp "$REPO_ROOT/scripts/ci/strix_model_utils.sh" "$repo_root_dir/scripts/ci/strix_model_utils.sh" + cp "$REPO_ROOT/scripts/ci/strix_evidence_binding.py" "$repo_root_dir/scripts/ci/strix_evidence_binding.py" chmod +x "$repo_root_dir/scripts/ci/strix_quick_gate.sh" local fake_strix="$bin_dir/strix" @@ -7401,6 +7406,7 @@ run_pull_request_target_changed_context_scope_uses_pr_head_case() { mkdir -p "$bin_dir" "$repo_root_dir/scripts/ci" cp "$GATE_SCRIPT" "$repo_root_dir/scripts/ci/strix_quick_gate.sh" cp "$REPO_ROOT/scripts/ci/strix_model_utils.sh" "$repo_root_dir/scripts/ci/strix_model_utils.sh" + cp "$REPO_ROOT/scripts/ci/strix_evidence_binding.py" "$repo_root_dir/scripts/ci/strix_evidence_binding.py" chmod +x "$repo_root_dir/scripts/ci/strix_quick_gate.sh" local fake_strix="$bin_dir/strix" @@ -7580,6 +7586,7 @@ run_pull_request_target_changed_backend_context_scope_case() { mkdir -p "$bin_dir" "$repo_root_dir/scripts/ci" cp "$GATE_SCRIPT" "$repo_root_dir/scripts/ci/strix_quick_gate.sh" cp "$REPO_ROOT/scripts/ci/strix_model_utils.sh" "$repo_root_dir/scripts/ci/strix_model_utils.sh" + cp "$REPO_ROOT/scripts/ci/strix_evidence_binding.py" "$repo_root_dir/scripts/ci/strix_evidence_binding.py" chmod +x "$repo_root_dir/scripts/ci/strix_quick_gate.sh" local fake_strix="$bin_dir/strix" @@ -7839,6 +7846,7 @@ run_pull_request_target_frontend_email_context_scope_case() { mkdir -p "$bin_dir" "$repo_root_dir/scripts/ci" cp "$GATE_SCRIPT" "$repo_root_dir/scripts/ci/strix_quick_gate.sh" cp "$REPO_ROOT/scripts/ci/strix_model_utils.sh" "$repo_root_dir/scripts/ci/strix_model_utils.sh" + cp "$REPO_ROOT/scripts/ci/strix_evidence_binding.py" "$repo_root_dir/scripts/ci/strix_evidence_binding.py" chmod +x "$repo_root_dir/scripts/ci/strix_quick_gate.sh" local fake_strix="$bin_dir/strix" @@ -8029,6 +8037,7 @@ run_pull_request_target_shallow_head_merge_base_fallback_case() { cp "$GATE_SCRIPT" "$repo_root_dir/scripts/ci/strix_quick_gate.sh" cp "$REPO_ROOT/scripts/ci/strix_model_utils.sh" "$repo_root_dir/scripts/ci/strix_model_utils.sh" + cp "$REPO_ROOT/scripts/ci/strix_evidence_binding.py" "$repo_root_dir/scripts/ci/strix_evidence_binding.py" chmod +x "$repo_root_dir/scripts/ci/strix_quick_gate.sh" local fake_strix="$bin_dir/strix" @@ -8144,6 +8153,7 @@ run_pull_request_target_aborts_on_pr_head_blob_failure_case() { mkdir -p "$bin_dir" "$repo_root_dir/scripts/ci" cp "$GATE_SCRIPT" "$repo_root_dir/scripts/ci/strix_quick_gate.sh" cp "$REPO_ROOT/scripts/ci/strix_model_utils.sh" "$repo_root_dir/scripts/ci/strix_model_utils.sh" + cp "$REPO_ROOT/scripts/ci/strix_evidence_binding.py" "$repo_root_dir/scripts/ci/strix_evidence_binding.py" chmod +x "$repo_root_dir/scripts/ci/strix_quick_gate.sh" local real_git @@ -8268,6 +8278,7 @@ run_pull_request_target_rejects_invalid_sha_case() { mkdir -p "$bin_dir" "$repo_root_dir/scripts/ci" cp "$GATE_SCRIPT" "$repo_root_dir/scripts/ci/strix_quick_gate.sh" cp "$REPO_ROOT/scripts/ci/strix_model_utils.sh" "$repo_root_dir/scripts/ci/strix_model_utils.sh" + cp "$REPO_ROOT/scripts/ci/strix_evidence_binding.py" "$repo_root_dir/scripts/ci/strix_evidence_binding.py" chmod +x "$repo_root_dir/scripts/ci/strix_quick_gate.sh" local fake_strix="$bin_dir/strix" @@ -8361,6 +8372,7 @@ run_pull_request_target_irregular_head_entry_fails_closed_case() { mkdir -p "$bin_dir" "$repo_root_dir/scripts/ci" cp "$GATE_SCRIPT" "$repo_root_dir/scripts/ci/strix_quick_gate.sh" cp "$REPO_ROOT/scripts/ci/strix_model_utils.sh" "$repo_root_dir/scripts/ci/strix_model_utils.sh" + cp "$REPO_ROOT/scripts/ci/strix_evidence_binding.py" "$repo_root_dir/scripts/ci/strix_evidence_binding.py" chmod +x "$repo_root_dir/scripts/ci/strix_quick_gate.sh" local fake_strix="$bin_dir/strix" @@ -8444,6 +8456,7 @@ run_pull_request_target_gitlink_is_explicitly_skipped_case() { mkdir -p "$bin_dir" "$repo_root_dir/scripts/ci" cp "$GATE_SCRIPT" "$repo_root_dir/scripts/ci/strix_quick_gate.sh" cp "$REPO_ROOT/scripts/ci/strix_model_utils.sh" "$repo_root_dir/scripts/ci/strix_model_utils.sh" + cp "$REPO_ROOT/scripts/ci/strix_evidence_binding.py" "$repo_root_dir/scripts/ci/strix_evidence_binding.py" chmod +x "$repo_root_dir/scripts/ci/strix_quick_gate.sh" local fake_strix="$bin_dir/strix" @@ -8526,6 +8539,7 @@ run_full_head_scope_skips_gitlink_case() { mkdir -p "$bin_dir" "$repo_root_dir/scripts/ci" cp "$GATE_SCRIPT" "$repo_root_dir/scripts/ci/strix_quick_gate.sh" cp "$REPO_ROOT/scripts/ci/strix_model_utils.sh" "$repo_root_dir/scripts/ci/strix_model_utils.sh" + cp "$REPO_ROOT/scripts/ci/strix_evidence_binding.py" "$repo_root_dir/scripts/ci/strix_evidence_binding.py" chmod +x "$repo_root_dir/scripts/ci/strix_quick_gate.sh" local fake_strix="$bin_dir/strix" @@ -8640,6 +8654,7 @@ run_pull_request_target_rejects_unsafe_changed_path_case() { mkdir -p "$bin_dir" "$repo_root_dir/scripts/ci" cp "$GATE_SCRIPT" "$repo_root_dir/scripts/ci/strix_quick_gate.sh" cp "$REPO_ROOT/scripts/ci/strix_model_utils.sh" "$repo_root_dir/scripts/ci/strix_model_utils.sh" + cp "$REPO_ROOT/scripts/ci/strix_evidence_binding.py" "$repo_root_dir/scripts/ci/strix_evidence_binding.py" chmod +x "$repo_root_dir/scripts/ci/strix_quick_gate.sh" local fake_strix="$bin_dir/strix" @@ -8732,6 +8747,7 @@ run_timeout_cleanup_case() { mkdir -p "$bin_dir" "$repo_root_dir/scripts/ci" cp "$GATE_SCRIPT" "$repo_root_dir/scripts/ci/strix_quick_gate.sh" cp "$REPO_ROOT/scripts/ci/strix_model_utils.sh" "$repo_root_dir/scripts/ci/strix_model_utils.sh" + cp "$REPO_ROOT/scripts/ci/strix_evidence_binding.py" "$repo_root_dir/scripts/ci/strix_evidence_binding.py" chmod +x "$repo_root_dir/scripts/ci/strix_quick_gate.sh" local fake_strix="$bin_dir/strix" local child_pid_file="$tmp_dir/child.pid" @@ -8814,6 +8830,7 @@ run_vertex_model_ignores_untrusted_llm_api_base_file_case() { mkdir -p "$repo_root_dir/scripts/ci" "$allowed_input_dir" "$outside_dir" cp "$GATE_SCRIPT" "$repo_root_dir/scripts/ci/strix_quick_gate.sh" cp "$REPO_ROOT/scripts/ci/strix_model_utils.sh" "$repo_root_dir/scripts/ci/strix_model_utils.sh" + cp "$REPO_ROOT/scripts/ci/strix_evidence_binding.py" "$repo_root_dir/scripts/ci/strix_evidence_binding.py" chmod +x "$repo_root_dir/scripts/ci/strix_quick_gate.sh" cat >"$fake_strix" <<'EOF' @@ -8866,6 +8883,7 @@ run_total_timeout_case() { mkdir -p "$bin_dir" "$repo_root_dir/scripts/ci" cp "$GATE_SCRIPT" "$repo_root_dir/scripts/ci/strix_quick_gate.sh" cp "$REPO_ROOT/scripts/ci/strix_model_utils.sh" "$repo_root_dir/scripts/ci/strix_model_utils.sh" + cp "$REPO_ROOT/scripts/ci/strix_evidence_binding.py" "$repo_root_dir/scripts/ci/strix_evidence_binding.py" chmod +x "$repo_root_dir/scripts/ci/strix_quick_gate.sh" local fake_strix="$bin_dir/strix" local output_log="$tmp_dir/output.log" @@ -9193,6 +9211,7 @@ run_llm_api_base_file_outside_input_root_fails_closed_case() { mkdir -p "$repo_root_dir/scripts/ci" "$allowed_input_dir" "$outside_dir" cp "$GATE_SCRIPT" "$repo_root_dir/scripts/ci/strix_quick_gate.sh" cp "$REPO_ROOT/scripts/ci/strix_model_utils.sh" "$repo_root_dir/scripts/ci/strix_model_utils.sh" + cp "$REPO_ROOT/scripts/ci/strix_evidence_binding.py" "$repo_root_dir/scripts/ci/strix_evidence_binding.py" chmod +x "$repo_root_dir/scripts/ci/strix_quick_gate.sh" cat >"$fake_strix" <<'EOF' @@ -9248,6 +9267,7 @@ run_pr_scoped_llm_api_base_file_config_failure_exits_2_case() { mkdir -p "$repo_root_dir/scripts/ci" "$repo_root_dir/src" "$allowed_input_dir" "$outside_dir" cp "$GATE_SCRIPT" "$repo_root_dir/scripts/ci/strix_quick_gate.sh" cp "$REPO_ROOT/scripts/ci/strix_model_utils.sh" "$repo_root_dir/scripts/ci/strix_model_utils.sh" + cp "$REPO_ROOT/scripts/ci/strix_evidence_binding.py" "$repo_root_dir/scripts/ci/strix_evidence_binding.py" chmod +x "$repo_root_dir/scripts/ci/strix_quick_gate.sh" printf '%s\n' 'print("one")' >"$repo_root_dir/src/one.py" printf '%s\n' 'print("two")' >"$repo_root_dir/src/two.py" @@ -9309,6 +9329,7 @@ run_required_input_file_outside_input_root_fails_closed_case() { mkdir -p "$repo_root_dir/scripts/ci" "$allowed_input_dir" "$outside_dir" cp "$GATE_SCRIPT" "$repo_root_dir/scripts/ci/strix_quick_gate.sh" cp "$REPO_ROOT/scripts/ci/strix_model_utils.sh" "$repo_root_dir/scripts/ci/strix_model_utils.sh" + cp "$REPO_ROOT/scripts/ci/strix_evidence_binding.py" "$repo_root_dir/scripts/ci/strix_evidence_binding.py" chmod +x "$repo_root_dir/scripts/ci/strix_quick_gate.sh" cat >"$fake_strix" <<'EOF' @@ -9379,6 +9400,7 @@ run_input_file_root_override_takes_precedence_over_runner_temp_case() { mkdir -p "$repo_root_dir/scripts/ci" "$explicit_input_root" "$inherited_runner_temp" cp "$GATE_SCRIPT" "$repo_root_dir/scripts/ci/strix_quick_gate.sh" cp "$REPO_ROOT/scripts/ci/strix_model_utils.sh" "$repo_root_dir/scripts/ci/strix_model_utils.sh" + cp "$REPO_ROOT/scripts/ci/strix_evidence_binding.py" "$repo_root_dir/scripts/ci/strix_evidence_binding.py" chmod +x "$repo_root_dir/scripts/ci/strix_quick_gate.sh" cat >"$fake_strix" <<'EOF' @@ -9433,6 +9455,7 @@ run_stale_report_case() { mkdir -p "$repo_root_dir/scripts/ci" cp "$GATE_SCRIPT" "$repo_root_dir/scripts/ci/strix_quick_gate.sh" cp "$REPO_ROOT/scripts/ci/strix_model_utils.sh" "$repo_root_dir/scripts/ci/strix_model_utils.sh" + cp "$REPO_ROOT/scripts/ci/strix_evidence_binding.py" "$repo_root_dir/scripts/ci/strix_evidence_binding.py" chmod +x "$repo_root_dir/scripts/ci/strix_quick_gate.sh" mkdir -p "$stale_report_dir" @@ -9488,6 +9511,7 @@ run_symlink_report_case() { mkdir -p "$repo_root_dir/scripts/ci" cp "$GATE_SCRIPT" "$repo_root_dir/scripts/ci/strix_quick_gate.sh" cp "$REPO_ROOT/scripts/ci/strix_model_utils.sh" "$repo_root_dir/scripts/ci/strix_model_utils.sh" + cp "$REPO_ROOT/scripts/ci/strix_evidence_binding.py" "$repo_root_dir/scripts/ci/strix_evidence_binding.py" chmod +x "$repo_root_dir/scripts/ci/strix_quick_gate.sh" mkdir -p "$external_report_dir" "$repo_root_dir/strix_runs" @@ -9544,6 +9568,7 @@ run_unsafe_target_path_case() { mkdir -p "$repo_root_dir/scripts/ci" cp "$GATE_SCRIPT" "$repo_root_dir/scripts/ci/strix_quick_gate.sh" cp "$REPO_ROOT/scripts/ci/strix_model_utils.sh" "$repo_root_dir/scripts/ci/strix_model_utils.sh" + cp "$REPO_ROOT/scripts/ci/strix_evidence_binding.py" "$repo_root_dir/scripts/ci/strix_evidence_binding.py" chmod +x "$repo_root_dir/scripts/ci/strix_quick_gate.sh" cat >"$fake_strix" <<'EOF' @@ -9592,6 +9617,7 @@ run_absolute_outside_target_path_case() { mkdir -p "$bin_dir" "$repo_root_dir/src" "$repo_root_dir/scripts/ci" cp "$GATE_SCRIPT" "$repo_root_dir/scripts/ci/strix_quick_gate.sh" cp "$REPO_ROOT/scripts/ci/strix_model_utils.sh" "$repo_root_dir/scripts/ci/strix_model_utils.sh" + cp "$REPO_ROOT/scripts/ci/strix_evidence_binding.py" "$repo_root_dir/scripts/ci/strix_evidence_binding.py" chmod +x "$repo_root_dir/scripts/ci/strix_quick_gate.sh" local fake_strix="$bin_dir/strix" local call_log="$tmp_dir/calls.log" From 54033b90dcea72423847afa3ddc64963392ec8c7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 27 Sep 2026 11:34:06 +0900 Subject: [PATCH 22/27] test: assert REST workflow pagination requests --- tests/test_pr_review_fix_scheduler_rest_workflow_identity.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_pr_review_fix_scheduler_rest_workflow_identity.py b/tests/test_pr_review_fix_scheduler_rest_workflow_identity.py index bfbc0d787e..f0324ed5fa 100644 --- a/tests/test_pr_review_fix_scheduler_rest_workflow_identity.py +++ b/tests/test_pr_review_fix_scheduler_rest_workflow_identity.py @@ -14,8 +14,9 @@ def test_workflow_name_rest_paginates_and_propagates_real_errors(monkeypatch: An """REST workflow identity consumes full pages and fails on non-permission errors.""" calls = {"count": 0} - def pages(_path: str) -> Any: + def pages(path: str) -> Any: calls["count"] += 1 + assert path.endswith(f"&page={calls['count']}") if calls["count"] == 1: return {"workflow_runs": [{"check_suite_id": index, "name": f"workflow-{index}"} for index in range(100)]} return {"workflow_runs": [{"check_suite_id": 101, "name": "last"}, {"check_suite_id": None, "name": "ignored"}, {"check_suite_id": 102, "name": ""}]} From 2e6694d6d8593c4987fba3db7f8e4fc65e0b90d7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 27 Sep 2026 21:23:41 +0900 Subject: [PATCH 23/27] fix(lifecycle): bind owner issue reuse to trusted authors --- CHANGELOG.md | 12 ++--- docs/doctoring/orphaned-workflow-lifecycle.md | 11 +++-- scripts/ci/workflow_lifecycle_operator.py | 9 ++++ tests/test_inventory_orphaned_workflows.py | 47 +++++++++++++++++++ 4 files changed, 70 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ebe6c9134..e4382bca3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1339,6 +1339,11 @@ Semantic Versioning where the repository publishes a release. - Added a dedicated fast-mlsirm hourly caller that preserves Rust-owned psychometric arithmetic while dispatching at most one exact-head, root-cause-driven repair with a two-hour same-head retry floor. - Added a dedicated Orgmetra hourly caller at minute 58 that targets protected `develop`, dispatches at most one exact-head repair, preserves a two-hour same-head retry floor and non-cancelling single-flight execution, and maps only the established scheduler credentials. - Added read-only orphan-workflow lifecycle classification with exact default-branch binding, fail-closed visibility and pagination checks, explicit case-insensitive owner-issue routing for active and disabled orphans, and no registry mutation authority. +- Add a live, paginated organization workflow inventory with content-bound API + receipts, exact default-head revalidation, and a separately reviewed, + ledger-bound owner-issue publisher that never disables workflows. +- Schedule the central read-only workflow-lifecycle sweep on the protected + default branch and retain its exact ledger and API receipts for 30 days. ### Changed @@ -1371,12 +1376,7 @@ Semantic Versioning where the repository publishes a release. authenticated organization-wide repository totals, retries one explicit HTTP 5xx once, rejects every malformed Git tree member, preserves partial read receipts and structured failure evidence, gates completeness before - classification, and isolates disable/issue writes in the operator module. -- Add a live, paginated organization workflow inventory with content-bound API - receipts, exact default-head revalidation, and a separately reviewed, - ledger-bound operator disable primitive. -- Schedule the central read-only workflow-lifecycle sweep on the protected - default branch and retain its exact ledger and API receipts for 30 days. + classification, and isolates owner-issue writes in the operator module. - Prefer the job-scoped `github.token` when the central OpenCode dispatch publishes a commit status back to the same `.github` repository. The job's declared `statuses: write` permission now reaches the endpoint instead of an diff --git a/docs/doctoring/orphaned-workflow-lifecycle.md b/docs/doctoring/orphaned-workflow-lifecycle.md index 8550235fd6..c07237c8c0 100644 --- a/docs/doctoring/orphaned-workflow-lifecycle.md +++ b/docs/doctoring/orphaned-workflow-lifecycle.md @@ -148,12 +148,17 @@ The digest binds the reviewed file; it is not approval or fleet-completeness proof by itself. Review the ledger and its API receipts first. The command accepts one identity, requires the canonical ledger and its completeness marker, and reads live GitHub evidence again before its issue write. It never -disables a workflow. +disables a workflow. Existing issue markers are reused only for repository +owners, members, collaborators, or the established github-actions and +opencode-agent bot publishers. Other public issue authors cannot redirect +evidence or poison the duplicate-issue check. ## Rollback -Rollback removes the inventory script, focused tests, schema example, -architecture entry, changelog entry, and this doctoring record together. No +Rollback removes the inventory and owner-issue operator scripts, focused tests, +schema example, architecture entry, changelog entry, this doctoring record, +and both `.github/workflows/workflow-lifecycle-inventory.yml` and +`.github/workflows/workflow-lifecycle-inventory-quality-ci.yml` together. No registry state is mutated, so rollback does not re-enable or disable workflows. ## References diff --git a/scripts/ci/workflow_lifecycle_operator.py b/scripts/ci/workflow_lifecycle_operator.py index 52ed21b89c..74571b1069 100644 --- a/scripts/ci/workflow_lifecycle_operator.py +++ b/scripts/ci/workflow_lifecycle_operator.py @@ -170,6 +170,15 @@ def publish_owner_issue( if isinstance(item, Mapping) and marker in str(item.get("body") or "") and "pull_request" not in item + and ( + item.get("author_association") in {"OWNER", "MEMBER", "COLLABORATOR"} + or ( + isinstance(item.get("user"), Mapping) + and item["user"].get("type") == "Bot" + and item["user"].get("login") + in {"github-actions[bot]", "opencode-agent[bot]"} + ) + ) ) if len(existing) < 100: break diff --git a/tests/test_inventory_orphaned_workflows.py b/tests/test_inventory_orphaned_workflows.py index 8178a67eb0..a0ee34da4e 100644 --- a/tests/test_inventory_orphaned_workflows.py +++ b/tests/test_inventory_orphaned_workflows.py @@ -1117,6 +1117,7 @@ def test_owner_issue_update_and_create_are_explicit() -> None: prior = { "number": 41, "state": "open", + "author_association": "OWNER", "body": "", } repeated = _LiveClient( @@ -1641,3 +1642,49 @@ def test_known_fleet_fixture_routes_owner_issues() -> None: assert "owner_issue" not in by_repo["clearfolio"] assert by_repo["disksage"]["classification"] == "orphan_active" assert by_repo["disksage"]["owner_issue"] == "ContextualWisdomLab/disksage#191" + + +@pytest.mark.parametrize( + "association,login,kind,trusted", + [ + ("OWNER", "owner", "User", True), + ("MEMBER", "member", "User", True), + ("COLLABORATOR", "collaborator", "User", True), + ("NONE", "outsider", "User", False), + ("CONTRIBUTOR", "outsider", "User", False), + (None, "outsider", "User", False), + ("NONE", "external-app[bot]", "Bot", False), + ("NONE", "github-actions[bot]", "Bot", True), + ("NONE", "opencode-agent[bot]", "Bot", True), + ("NONE", "opencode-agent[bot]", "User", False), + ], +) +def test_owner_issue_marker_requires_trusted_author( + association: str | None, login: str, kind: str, trusted: bool +) -> None: + """Public markers cannot redirect evidence or poison duplicate detection.""" + record = { + "repository": "new-product", "workflow_id": 9, + "path": ".github/workflows/gone.yml", "classification": "orphan_active", + "default_branch_sha": SHA, + } + root = "/repos/ContextualWisdomLab/new-product/issues" + prior = { + "number": 41, "state": "open", "author_association": association, + "user": {"login": login, "type": kind}, + "body": "", + } + # Two forged matches must not turn the ambiguity guard into a denial of service. + candidates = [prior] if trusted else [prior, {**prior, "number": 42}] + client = _LiveClient({ + **_owner_live_responses(record), + f"{root}?state=all&per_page=100&page=1": candidates, + root: {"number": 77}, + f"{root}/41": prior, + f"{root}/41/comments?per_page=100&page=1": [], + f"{root}/41/comments": {}, + }) + result = operator.publish_owner_issue(client, record, ledger={"records": [record]}) + assert result.endswith("#41" if trusted else "#77") + assert len(client.writes) == 1 + assert client.writes[0][0] == (f"{root}/41/comments" if trusted else root) From 15a62d028c3c208dec1f907dffba337c2709fe5b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 28 Sep 2026 15:18:40 +0900 Subject: [PATCH 24/27] fix(lifecycle): group orphan owner issues by repository --- docs/doctoring/orphaned-workflow-lifecycle.md | 9 ++-- scripts/ci/workflow_lifecycle_operator.py | 19 ++++++-- tests/test_inventory_orphaned_workflows.py | 43 +++++++++++++++++-- 3 files changed, 60 insertions(+), 11 deletions(-) diff --git a/docs/doctoring/orphaned-workflow-lifecycle.md b/docs/doctoring/orphaned-workflow-lifecycle.md index c07237c8c0..702d4d43e1 100644 --- a/docs/doctoring/orphaned-workflow-lifecycle.md +++ b/docs/doctoring/orphaned-workflow-lifecycle.md @@ -129,10 +129,11 @@ identity or restored source file stops publication. The publisher computes the ledger's digest and scans existing owner issues before creating one for a repository without a known route. It does not run in the read-only inventory job. -Each owner issue is matched to the exact workflow ID and path. Before posting, -the publisher checks the open issue and every comment for the exact evidence -body, so retrying the same ledger does not duplicate a comment or hide another -workflow from that ledger. +For a repository without a known route, one trusted owner issue groups its +findings. Each revalidated workflow ID and path gets its own evidence comment. +Before posting, the publisher checks the open issue and every comment for the +exact evidence body, so retrying the same ledger does not duplicate a comment +or hide another workflow from that ledger. For one separately reviewed finding, the operator supplies the exact ledger digest, repository, and workflow ID: diff --git a/scripts/ci/workflow_lifecycle_operator.py b/scripts/ci/workflow_lifecycle_operator.py index 74571b1069..0bfdfb048a 100644 --- a/scripts/ci/workflow_lifecycle_operator.py +++ b/scripts/ci/workflow_lifecycle_operator.py @@ -141,6 +141,9 @@ def publish_owner_issue( marker = ( f"" ) + owner_marker = ( + f"" + ) body = ( f"{marker}\n" f"Exact workflow registry evidence: `{workflow_id}` / " @@ -168,7 +171,7 @@ def publish_owner_issue( item for item in existing if isinstance(item, Mapping) - and marker in str(item.get("body") or "") + and owner_marker in str(item.get("body") or "") and "pull_request" not in item and ( item.get("author_association") in {"OWNER", "MEMBER", "COLLABORATOR"} @@ -200,8 +203,8 @@ def publish_owner_issue( f"/repos/ContextualWisdomLab/{repository}/issues", method="POST", payload={ - "title": "Disable orphaned workflow registry identity", - "body": body, + "title": "Review orphaned GitHub Actions workflows", + "body": f"{owner_marker}\nReview each revalidated workflow ID below before disabling it.", }, ) except Exception as exc: @@ -211,6 +214,16 @@ def publish_owner_issue( number = created.get("number") if isinstance(created, Mapping) else None if not isinstance(number, int) or number <= 0: raise InventoryError("owner issue creation returned no issue number") + try: + client.request( + f"{repo_path}/issues/{number}/comments", + method="POST", + payload={"body": body}, + ) + except Exception as exc: + raise InventoryError( + f"owner issue publication failed closed: {type(exc).__name__}" + ) from exc return f"ContextualWisdomLab/{repository}#{number}" diff --git a/tests/test_inventory_orphaned_workflows.py b/tests/test_inventory_orphaned_workflows.py index a0ee34da4e..e6e3567baf 100644 --- a/tests/test_inventory_orphaned_workflows.py +++ b/tests/test_inventory_orphaned_workflows.py @@ -1108,6 +1108,7 @@ def test_owner_issue_update_and_create_are_explicit() -> None: **_owner_live_responses(unknown), list_path: [], create_path: {"number": 41}, + f"{create_path}/41/comments": {}, }) assert operator.publish_owner_issue( creator, unknown, ledger={"records": [unknown]} @@ -1118,7 +1119,7 @@ def test_owner_issue_update_and_create_are_explicit() -> None: "number": 41, "state": "open", "author_association": "OWNER", - "body": "", + "body": "", } repeated = _LiveClient( { @@ -1133,6 +1134,21 @@ def test_owner_issue_update_and_create_are_explicit() -> None: repeated, unknown, ledger={"records": [unknown]} ).endswith("#41") assert all(not path.endswith("/issues") for path in repeated.calls) + other = {**unknown, "workflow_id": 10, "path": ".github/workflows/other.yml"} + distinct = _LiveClient({ + **_owner_live_responses(other), + list_path: [prior], + "/repos/ContextualWisdomLab/new-product/issues/41": prior, + "/repos/ContextualWisdomLab/new-product/issues/41/comments?per_page=100&page=1": [], + "/repos/ContextualWisdomLab/new-product/issues/41/comments": {}, + }) + assert operator.publish_owner_issue( + distinct, other, ledger={"records": [unknown, other]} + ).endswith("#41") + assert [path for path, _ in distinct.writes] == [ + "/repos/ContextualWisdomLab/new-product/issues/41/comments" + ] + assert "workflow_id=10" in distinct.writes[0][1]["body"] def test_owner_issue_dedupes_exact_comment_and_keeps_workflows_distinct() -> None: @@ -1388,6 +1404,23 @@ def request(self, *_args: Any, **_kwargs: Any) -> Any: {**record, "repository": "new-product"}, ledger={"records": [{**record, "repository": "new-product"}]}, ) + class BadComment(_LiveClient): + def request(self, path: str, *, method: str = "GET", payload: Any = None) -> Any: + if method == "POST" and path.endswith("/comments"): + raise RuntimeError("secret detail") + return super().request(path, method=method, payload=payload) + + bad_comment = BadComment({ + **_owner_live_responses({**record, "repository": "new-product"}), + "/repos/ContextualWisdomLab/new-product/issues?state=all&per_page=100&page=1": [], + "/repos/ContextualWisdomLab/new-product/issues": {"number": 41}, + }) + with pytest.raises(inventory.InventoryError, match="publication failed closed"): + operator.publish_owner_issue( + bad_comment, + {**record, "repository": "new-product"}, + ledger={"records": [{**record, "repository": "new-product"}]}, + ) def test_live_collector_consumes_second_repository_and_workflow_pages() -> None: @@ -1672,7 +1705,7 @@ def test_owner_issue_marker_requires_trusted_author( prior = { "number": 41, "state": "open", "author_association": association, "user": {"login": login, "type": kind}, - "body": "", + "body": "", } # Two forged matches must not turn the ambiguity guard into a denial of service. candidates = [prior] if trusted else [prior, {**prior, "number": 42}] @@ -1683,8 +1716,10 @@ def test_owner_issue_marker_requires_trusted_author( f"{root}/41": prior, f"{root}/41/comments?per_page=100&page=1": [], f"{root}/41/comments": {}, + f"{root}/77/comments": {}, }) result = operator.publish_owner_issue(client, record, ledger={"records": [record]}) assert result.endswith("#41" if trusted else "#77") - assert len(client.writes) == 1 - assert client.writes[0][0] == (f"{root}/41/comments" if trusted else root) + assert [path for path, _ in client.writes] == ( + [f"{root}/41/comments"] if trusted else [root, f"{root}/77/comments"] + ) From 117a5b359d808dc2fe7b35bc335287a720529bc7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 28 Sep 2026 15:20:57 +0900 Subject: [PATCH 25/27] fix(lifecycle): reuse verified fleet owner issues --- scripts/ci/inventory_orphaned_workflows.py | 4 ++++ tests/test_inventory_orphaned_workflows.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/scripts/ci/inventory_orphaned_workflows.py b/scripts/ci/inventory_orphaned_workflows.py index 1cd2270484..a8c59ace4c 100755 --- a/scripts/ci/inventory_orphaned_workflows.py +++ b/scripts/ci/inventory_orphaned_workflows.py @@ -50,6 +50,7 @@ "unresolved", ) KNOWN_OWNER_ISSUES = { + ".github": "ContextualWisdomLab/.github#945", "appguardrail": "ContextualWisdomLab/appguardrail#929", "bandscope": "ContextualWisdomLab/bandscope#847", "clearfolio": "ContextualWisdomLab/clearfolio#423", @@ -62,13 +63,16 @@ "four-pillars": "ContextualWisdomLab/four-pillars#33", "inkspan": "ContextualWisdomLab/inkspan#278", "keyverse": "ContextualWisdomLab/keyverse#99", + "life-os": "ContextualWisdomLab/life-os#202", "naruon": "ContextualWisdomLab/naruon#1324", "newsdom-api": "ContextualWisdomLab/newsdom-api#604", "noema": "ContextualWisdomLab/noema#226", "OriginWeave": "ContextualWisdomLab/OriginWeave#123", "pg-erd-cloud": "ContextualWisdomLab/pg-erd-cloud#865", + "pg-llm-batch": "ContextualWisdomLab/pg-llm-batch#158", "RankWeave": "ContextualWisdomLab/RankWeave#38", "saju-caldav": "ContextualWisdomLab/saju-caldav#33", + "scopeweave": "ContextualWisdomLab/scopeweave#478", "ThreadWeave": "ContextualWisdomLab/ThreadWeave#31", } FORBIDDEN_TOKENS = frozenset({"COPILOT_GITHUB_TOKEN"}) diff --git a/tests/test_inventory_orphaned_workflows.py b/tests/test_inventory_orphaned_workflows.py index e6e3567baf..4774887cc6 100644 --- a/tests/test_inventory_orphaned_workflows.py +++ b/tests/test_inventory_orphaned_workflows.py @@ -438,6 +438,7 @@ def test_disabled_orphan_routes_to_owner_issue() -> None: def test_owner_issue_registry_covers_confirmed_fleet() -> None: """Every confirmed fleet owner has an explicit, linkable issue route.""" assert inventory.KNOWN_OWNER_ISSUES == { + ".github": "ContextualWisdomLab/.github#945", "appguardrail": "ContextualWisdomLab/appguardrail#929", "bandscope": "ContextualWisdomLab/bandscope#847", "clearfolio": "ContextualWisdomLab/clearfolio#423", @@ -450,13 +451,16 @@ def test_owner_issue_registry_covers_confirmed_fleet() -> None: "four-pillars": "ContextualWisdomLab/four-pillars#33", "inkspan": "ContextualWisdomLab/inkspan#278", "keyverse": "ContextualWisdomLab/keyverse#99", + "life-os": "ContextualWisdomLab/life-os#202", "naruon": "ContextualWisdomLab/naruon#1324", "newsdom-api": "ContextualWisdomLab/newsdom-api#604", "noema": "ContextualWisdomLab/noema#226", "OriginWeave": "ContextualWisdomLab/OriginWeave#123", "pg-erd-cloud": "ContextualWisdomLab/pg-erd-cloud#865", + "pg-llm-batch": "ContextualWisdomLab/pg-llm-batch#158", "RankWeave": "ContextualWisdomLab/RankWeave#38", "saju-caldav": "ContextualWisdomLab/saju-caldav#33", + "scopeweave": "ContextualWisdomLab/scopeweave#478", "ThreadWeave": "ContextualWisdomLab/ThreadWeave#31", } From 7e8106e49a9c45004c7e5bd73f8f15701d89f315 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 28 Sep 2026 18:03:12 +0900 Subject: [PATCH 26/27] fix(actions): guard branch-owned workflow identities before retirement Clarify that default-branch absence is inventory evidence only. Owner issues now call out live PR heads and bases, and the operator record requires that check before an exact-ID disablement. Refs: #945 --- docs/doctoring/orphaned-workflow-lifecycle.md | 9 ++++++--- scripts/ci/workflow_lifecycle_operator.py | 6 +++++- tests/test_inventory_orphaned_workflows.py | 1 + 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/doctoring/orphaned-workflow-lifecycle.md b/docs/doctoring/orphaned-workflow-lifecycle.md index 702d4d43e1..425ed81892 100644 --- a/docs/doctoring/orphaned-workflow-lifecycle.md +++ b/docs/doctoring/orphaned-workflow-lifecycle.md @@ -27,7 +27,9 @@ reviewer cannot treat "the YAML is gone" as "no writer remains enabled." 2. Classification is evidence-based: `present_active`, `present_disabled`, `orphan_active`, `orphan_disabled`, `dynamic_owned`, or `unresolved`. A file named `once` is not alone proof of invalidity. A benign name - does not hide a missing source file. + does not hide a missing source file. `orphan_active` means absent from the + bound default-branch tree; an open pull request can still use that path on + its head or base. It is not a safe-disable verdict. 3. Incomplete visibility (401/403/404), a 5xx after one retry, pagination truncation, `total_count` drift, reused workflow IDs, percent-encoded paths, and default-branch movement fail closed. @@ -117,8 +119,9 @@ python3 scripts/ci/inventory_orphaned_workflows.py \ The scanner and owner-issue publisher expose no workflow-state mutation. Disablement needs a separate reviewed operator path that rechecks the live -default-branch SHA, exact workflow ID, source absence, owner intent, active -runs, and reusable-workflow callers immediately before the API write. After a +default-branch SHA, exact workflow ID, source absence, open pull request heads +and bases, owner intent, active runs, and reusable-workflow callers immediately +before the API write. After a reviewed operator pass, rerun the organization sweep and retain both receipt sets. Known AppGuardrail, Clearfolio, and DiskSage owner routes bind the same live evidence to their governance issues without heuristic issue creation. diff --git a/scripts/ci/workflow_lifecycle_operator.py b/scripts/ci/workflow_lifecycle_operator.py index 0bfdfb048a..18fd7ba1bd 100644 --- a/scripts/ci/workflow_lifecycle_operator.py +++ b/scripts/ci/workflow_lifecycle_operator.py @@ -204,7 +204,11 @@ def publish_owner_issue( method="POST", payload={ "title": "Review orphaned GitHub Actions workflows", - "body": f"{owner_marker}\nReview each revalidated workflow ID below before disabling it.", + "body": ( + f"{owner_marker}\nReview each revalidated workflow ID below before disabling it. " + "Default-branch absence does not prove retirement: check open pull request heads " + "and bases, active runs, reusable callers, and owner intent." + ), }, ) except Exception as exc: diff --git a/tests/test_inventory_orphaned_workflows.py b/tests/test_inventory_orphaned_workflows.py index 4774887cc6..7a579c4b55 100644 --- a/tests/test_inventory_orphaned_workflows.py +++ b/tests/test_inventory_orphaned_workflows.py @@ -1117,6 +1117,7 @@ def test_owner_issue_update_and_create_are_explicit() -> None: assert operator.publish_owner_issue( creator, unknown, ledger={"records": [unknown]} ).endswith("#41") + assert "open pull request heads" in creator.writes[0][1]["body"] with pytest.raises(inventory.InventoryError, match="ledger-bound"): operator.publish_owner_issue(client, known, ledger={"records": []}) prior = { From 6f645a73502e159d5a229805afa34868ad9bb851 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 29 Sep 2026 07:58:49 +0900 Subject: [PATCH 27/27] test(lifecycle): cover owner-issue operator fail-closed paths The operator that publishes reviewed owner issues sat at 80% branch coverage and was outside the PR's exact-head coverage gate, so its refusal paths (closed issue, torn comment pages, repository drift, ambiguous owner issue, malformed CLI input) were unproven. Add focused tests for each, include the operator in the gate, and document the two nested inventory helpers that failed the docstring gate. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QG4G3MVMtn6EHuTA7jAPNa --- ...orkflow-lifecycle-inventory-quality-ci.yml | 4 +- scripts/ci/inventory_orphaned_workflows.py | 2 + tests/test_inventory_orphaned_workflows.py | 179 ++++++++++++++++++ 3 files changed, 183 insertions(+), 2 deletions(-) diff --git a/.github/workflows/workflow-lifecycle-inventory-quality-ci.yml b/.github/workflows/workflow-lifecycle-inventory-quality-ci.yml index 359d3ede3c..d915715d03 100644 --- a/.github/workflows/workflow-lifecycle-inventory-quality-ci.yml +++ b/.github/workflows/workflow-lifecycle-inventory-quality-ci.yml @@ -53,6 +53,6 @@ jobs: coverage run --branch -m pytest --import-mode=importlib \ tests/test_inventory_orphaned_workflows.py \ tests/test_workflow_lifecycle_inventory_workflow.py -q - coverage report --include='scripts/ci/inventory_orphaned_workflows.py' --show-missing --fail-under=100 - python -m compileall -q scripts/ci/inventory_orphaned_workflows.py tests/test_inventory_orphaned_workflows.py + coverage report --include='scripts/ci/inventory_orphaned_workflows.py,scripts/ci/workflow_lifecycle_operator.py' --show-missing --fail-under=100 + python -m compileall -q scripts/ci/inventory_orphaned_workflows.py scripts/ci/workflow_lifecycle_operator.py tests/test_inventory_orphaned_workflows.py git diff --exit-code diff --git a/scripts/ci/inventory_orphaned_workflows.py b/scripts/ci/inventory_orphaned_workflows.py index a8c59ace4c..191c14e5cb 100755 --- a/scripts/ci/inventory_orphaned_workflows.py +++ b/scripts/ci/inventory_orphaned_workflows.py @@ -125,6 +125,7 @@ def collect_live_organization( receipts = [] if receipts is None else receipts def list_repositories() -> list[Mapping[str, Any]]: + """Read every organization repository page within the page bound.""" found: list[Mapping[str, Any]] = [] page = 1 while True: @@ -166,6 +167,7 @@ def list_repositories() -> list[Mapping[str, Any]]: ) def identities(items: list[Mapping[str, Any]]) -> set[tuple[object, ...]]: + """Project repositories to identity tuples for scan-drift comparison.""" try: return { ( diff --git a/tests/test_inventory_orphaned_workflows.py b/tests/test_inventory_orphaned_workflows.py index 7a579c4b55..8deb4b9a8a 100644 --- a/tests/test_inventory_orphaned_workflows.py +++ b/tests/test_inventory_orphaned_workflows.py @@ -1728,3 +1728,182 @@ def test_owner_issue_marker_requires_trusted_author( assert [path for path, _ in client.writes] == ( [f"{root}/41/comments"] if trusted else [root, f"{root}/77/comments"] ) + + +def _orphan(repository: str = "new-product") -> dict[str, Any]: + return { + "repository": repository, + "workflow_id": 9, + "path": ".github/workflows/gone.yml", + "classification": "orphan_active", + "default_branch_sha": SHA, + } + + +def test_existing_evidence_lookup_fails_closed_and_pages( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Evidence lookup needs an open issue and complete, bounded comment pages.""" + issue = "/repos/ContextualWisdomLab/app/issues/7" + comments = f"{issue}/comments?per_page=100&page=" + closed = _LiveClient({issue: {"state": "closed"}}) + with pytest.raises(inventory.InventoryError, match="closed"): + operator.issue_already_has_evidence(closed, "app", 7, "b") + same_body = _LiveClient({issue: {"state": "open", "body": "b"}}) + assert operator.issue_already_has_evidence(same_body, "app", 7, "b") + torn = _LiveClient({issue: {"state": "open"}, f"{comments}1": [1]}) + with pytest.raises(inventory.InventoryError, match="incomplete"): + operator.issue_already_has_evidence(torn, "app", 7, "b") + full = [{"body": "x"}] * 100 + second = _LiveClient( + {issue: {"state": "open"}, f"{comments}1": full, f"{comments}2": [{"body": "b"}]} + ) + assert operator.issue_already_has_evidence(second, "app", 7, "b") + monkeypatch.setattr(operator, "MAX_PAGES", 1) + endless = _LiveClient({issue: {"state": "open"}, f"{comments}1": full}) + with pytest.raises(inventory.InventoryError, match="pagination"): + operator.issue_already_has_evidence(endless, "app", 7, "b") + + +@pytest.mark.parametrize( + ("change", "reason"), + [ + ({"workflow_id": True}, "workflow evidence is malformed"), + ({"path": "gone.yml"}, "workflow evidence is malformed"), + ], +) +def test_owner_issue_rejects_malformed_workflow_evidence( + change: dict[str, Any], reason: str +) -> None: + """Malformed ledger evidence stops before any live request.""" + record = {**_orphan(), **change} + client = _LiveClient({}) + with pytest.raises(inventory.InventoryError, match=reason): + operator.publish_owner_issue(client, record, ledger={"records": [record]}) + assert client.calls == [] + + +@pytest.mark.parametrize( + "repo_change", + [{"archived": True}, {"default_branch": ""}, {"_moved": True}], +) +def test_owner_issue_rejects_repository_drift_before_post( + repo_change: dict[str, Any], +) -> None: + """Archive, missing default branch, or a moved head all refuse publication.""" + record = _orphan() + responses = _owner_live_responses(record) + repo = "/repos/ContextualWisdomLab/new-product" + if repo_change.pop("_moved", False): + responses[f"{repo}/commits/main"] = [{"sha": SHA_B}] + responses[repo] = {**responses[repo], **repo_change} + client = _LiveClient(responses) + with pytest.raises(inventory.InventoryError, match="failed closed"): + operator.publish_owner_issue(client, record, ledger={"records": [record]}) + assert client.writes == [] + + +def test_owner_issue_inventory_shape_pagination_and_ambiguity( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Issue discovery refuses torn pages, runaway paging, and duplicate owners.""" + record = _orphan() + repo = "/repos/ContextualWisdomLab/new-product" + listing = f"{repo}/issues?state=all&per_page=100&page=1" + owner = { + "number": 41, + "state": "open", + "author_association": "MEMBER", + "body": "", + } + ledger = {"records": [record]} + torn = _LiveClient({**_owner_live_responses(record), listing: {}}) + with pytest.raises(inventory.InventoryError, match="failed closed"): + operator.publish_owner_issue(torn, record, ledger=ledger) + twice = _LiveClient( + {**_owner_live_responses(record), listing: [owner, {**owner, "number": 42}]} + ) + with pytest.raises(inventory.InventoryError, match="failed closed"): + operator.publish_owner_issue(twice, record, ledger=ledger) + assert twice.writes == [] + ledger_sha256 = hashlib.sha256(inventory.write_ledger(ledger, None).encode()).hexdigest() + evidence = ( + "\n" + f"Exact workflow registry evidence: `9` / `.github/workflows/gone.yml` at `{SHA}`.\n" + f"Ledger SHA-256: `{ledger_sha256}`.\n" + ) + retried = _LiveClient( + { + **_owner_live_responses(record), + listing: [owner], + f"{repo}/issues/41": owner, + f"{repo}/issues/41/comments?per_page=100&page=1": [{"body": evidence}], + } + ) + assert operator.publish_owner_issue(retried, record, ledger=ledger).endswith("#41") + assert retried.writes == [] + monkeypatch.setattr(operator, "MAX_PAGES", 1) + endless = _LiveClient( + {**_owner_live_responses(record), listing: [{"number": 1}] * 100} + ) + with pytest.raises(inventory.InventoryError, match="failed closed"): + operator.publish_owner_issue(endless, record, ledger=ledger) + + +def test_owner_issue_discovery_consumes_second_page() -> None: + """A full first issue page continues to the next page before creating.""" + record = _orphan() + repo = "/repos/ContextualWisdomLab/new-product" + client = _LiveClient( + { + **_owner_live_responses(record), + f"{repo}/issues?state=all&per_page=100&page=1": [{"number": 1}] * 100, + f"{repo}/issues?state=all&per_page=100&page=2": [], + f"{repo}/issues": {"number": 43}, + f"{repo}/issues/43/comments": {}, + } + ) + assert operator.publish_owner_issue( + client, record, ledger={"records": [record]} + ).endswith("#43") + + +def test_owner_issue_cli_refuses_malformed_inputs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Every malformed operator input exits 2 before any credential is read.""" + from scripts.ci.organization_commercial_readiness_loop import GitHubClient + + ledger_path = tmp_path / "ledger.json" + + def run(raw: bytes, *, digest: str | None = None, repo: str = "app") -> int: + ledger_path.write_bytes(raw) + return operator.main( + [ + "--ledger", str(ledger_path), + "--expected-ledger-sha256", + digest or hashlib.sha256(raw).hexdigest(), + "--repository", repo, + "--workflow-id", "9", + ] + ) + + def unavailable() -> Any: + raise RuntimeError("no token") + + monkeypatch.setattr(GitHubClient, "from_environment", unavailable) + complete = { + "schema_version": "1", + "capability": inventory.CAPABILITY, + "organization": "ContextualWisdomLab", + "repository_inventory_complete": True, + } + assert run(b"{}", digest="NOT-HEX") == 2 + assert run(b"{}", repo="bad name") == 2 + assert run(b"") == 2 + assert run(b'{"n": NaN}') == 2 + assert run(b'{"records": []}') == 2 + assert run(inventory.write_ledger({**complete, "records": {}}, None).encode()) == 2 + record = {**_orphan("app"), "workflow_id": 9} + ledger = inventory.write_ledger({**complete, "records": [record]}, None).encode() + assert run(ledger) == 2