diff --git a/.github/workflows/current-head-run-coalescer.yml b/.github/workflows/current-head-run-coalescer.yml new file mode 100644 index 0000000000..a3c985a532 --- /dev/null +++ b/.github/workflows/current-head-run-coalescer.yml @@ -0,0 +1,44 @@ +name: Current Head Run Coalescer + +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft] + +concurrency: + group: current-head-run-coalescer-${{ github.repository }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + actions: write + contents: read + pull-requests: read + +jobs: + coalesce: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout trusted control-plane source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: ContextualWisdomLab/.github + ref: ${{ github.workflow_sha }} + persist-credentials: false + + - name: Retire redundant queued exact-head runs + env: + GH_TOKEN: ${{ github.token }} + COALESCE_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + EXPECTED_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + EXPECTED_HEAD_REF: ${{ github.event.pull_request.head.ref }} + EXPECTED_HEAD: ${{ github.event.pull_request.head.sha }} + shell: bash + run: | + set -euo pipefail + python3 scripts/ci/current_head_run_coalescer.py \ + --repo "$COALESCE_REPO" \ + --pr-number "$PR_NUMBER" \ + --expected-head-repo "$EXPECTED_HEAD_REPO" \ + --expected-head-ref "$EXPECTED_HEAD_REF" \ + --expected-head "$EXPECTED_HEAD" diff --git a/docs/doctoring/current-head-run-coalescing.md b/docs/doctoring/current-head-run-coalescing.md new file mode 100644 index 0000000000..45eb7f1bca --- /dev/null +++ b/docs/doctoring/current-head-run-coalescing.md @@ -0,0 +1,58 @@ +# Current-head workflow-run coalescing + +## Incident + +On 2026-09-02 KST (2026-09-01 UTC), exact head `09908aaf56e568420105b81434c6cdd147856657` was reused when Draft pull request #1050 was closed and ready successor #1643 was opened. GitHub exposed two simultaneously queued runs for several expensive workflows on that unchanged branch/head, including Security Scan (`33561053485`, `33561076062`), CodeQL PR (`33561053137`, `33561076168`), Python Security (`33561053333`, `33561076150`), and SAST Semgrep (`33561053180`, `33561076360`). Equivalent duplicate pairs existed for Secret Scan, SBOM Generation, Scorecard PR, and OSV-Scanner PR. + +The live-ref queue-hygiene repair from #1348 correctly prevents stale pull-request payloads from cancelling a newly pushed authoritative head. Its destructive revalidation intentionally preserves any run whose `head_sha` still equals the live branch ref. That safety invariant does not distinguish the sole authoritative current-head run from redundant queued siblings belonging to the same GitHub `workflow_id`. PR recreation therefore exposed a second, orthogonal capacity leak: safe stale-head preservation could retain several same-workflow runs for one current head. + +## Trust boundary + +`.github/workflows/current-head-run-coalescer.yml` executes on trusted `pull_request_target` events for `opened`, `synchronize`, `reopened`, `ready_for_review`, and `converted_to_draft`. It checks out `ContextualWisdomLab/.github` at immutable `github.workflow_sha` with persisted credentials disabled. The job has only `actions: write`, `contents: read`, and `pull-requests: read`; it never checks out or executes pull-request-head code. Event-derived repository/ref/SHA values are first placed in environment variables and are referenced from the shell only as quoted variables, so PR-controlled branch names are never interpolated directly into executable shell text. + +The script re-fetches the live PR before classification. It lists all queued and in-progress repository runs rather than filtering only by workflow-run `head_sha`, because `pull_request_target` runs execute on the trusted base and their workflow head is not the PR head. Those runs are instead bound to the associated pull request's head identity. GitHub exposes repository identity in two different trusted REST shapes: the pull-request endpoint supplies a full repository object with `full_name`, while workflow-run `pull_requests[*].head.repo` and `base.repo` associations can contain only `id`, `name`, and canonical `https://api.github.com/repos/{owner}/{repo}` URL. `_repository_full_name()` therefore normalizes a valid full name directly or derives `owner/name` only from an exact HTTPS `api.github.com/repos/...` URL; malformed, query-bearing, foreign-host, non-HTTPS, or path-sentinel identities fail closed. This prevents a missing `full_name` field from turning every real workflow-run association into an empty repository identity while retaining a narrow authenticated GitHub boundary. + +Before every cancellation the script re-fetches active same-head state, exact non-current PR associations, each possible same-workflow authoritative sibling, the current PR, and finally the candidate itself. Missing, malformed, moved, closed, completed, timed-out, or ambiguous evidence preserves the candidate or fails closed. + +## Pull-request isolation + +A workflow run may authorize cancellation only inside the current PR's evidence boundary. Runs associated with the current PR are eligible only when both their associated head and base match the current live PR exactly. A run associated with a different **open** PR never authorizes or receives cancellation, even when both PRs share the same branch and commit; those PRs retain independent required-check evidence. A run left behind by a **closed** predecessor may be coalesced into a successor only when both the run association and the predecessor's live record match the successor's exact head repository/ref/SHA **and exact base repository/ref/SHA**. A predecessor from an older base commit is therefore not interchangeable with the successor even when the base branch name is unchanged. This preserves the #1050-to-#1643 recreation repair only when the required-workflow evidence really represents the same merge boundary. + +## Cancellation invariant + +Runs are eligible only when all of the following are true: + +1. the run was triggered by `pull_request` or `pull_request_target` and is bound to the current live PR head through the correct event-specific identity; +2. its PR association belongs either to the current PR or to a proven closed predecessor with the same exact head and exact base repository/ref/SHA identity; +3. its stable numeric `workflow_id` matches another run inside the same PR evidence boundary; +4. each candidate authoritative sibling identified from the bulk Actions snapshot is re-fetched by exact run ID and must still be queued or in progress with the same workflow/head/PR scope; +5. the current PR is re-fetched after sibling refresh and still exposes the same exact head/base boundary; and +6. the candidate is still `queued` on the final exact-run fetch immediately before mutation, while at least one refreshed distinct authoritative sibling remains active: either an `in_progress` sibling or a newer queued sibling. + +The coalescer never selects an observed `in_progress` run. If a workflow already has an in-progress run, only queued siblings are redundant. If every matching run is queued, the greatest run ID is retained and older queued siblings are candidates. A candidate for which the authoritative sibling disappears, completes, changes identity, or becomes otherwise non-authoritative during refresh is preserved. Cancellation uses GitHub's ordinary `/cancel` endpoint rather than `force-cancel` and shares the same explicit `GH_TOKEN` and per-request timeout contract as every other API call. + +GitHub's REST cancellation endpoint has no conditional `If-Status-Is-Queued` precondition and acknowledges cancellation asynchronously. Therefore no client can make the final GET and POST literally atomic. The implementation closes the controllable races by re-fetching the specific authoritative sibling(s), then the current PR, then performing the candidate GET last and requiring `queued` immediately before the ordinary cancellation POST. The regression suite covers both a candidate that changes from queued to in-progress and an authoritative sibling that becomes completed after the bulk snapshot; in both cases the candidate is preserved. The residual sub-request race after the final GETs is an upstream API limitation; the coalescer never uses force-cancel and does not claim stronger atomicity than the platform exposes. + +This invariant is deliberately separate from old-head cancellation. #1348 remains authoritative for resolving live Git refs before retiring superseded heads; the coalescer handles only redundant active evidence for one live PR head. + +## Executable evidence + +`tests/test_current_head_run_coalescer.py` and `tests/test_current_head_run_coalescer_review_regressions.py` pin the source and workflow contract. Coverage includes one-run retention, in-progress preservation, `pull_request_target` base/head separation, real minimal Actions repository-association normalization for both PR event families, fail-closed repository URL normalization, isolation between concurrently open PRs, exact-base isolation across closed predecessor succession, same-workflow sibling re-fetch, completed-sibling preservation, workflow/head/branch/repository/event isolation, moved-head/status fail-closed behavior, per-call timeouts, explicit cancellation authentication, complete pagination, final candidate re-fetch, ready/draft transition triggers, trusted-source checkout, shell-injection resistance, PR-stable concurrency, and minimum workflow permissions. + +The minimal-repository-shape regression was committed before the production normalization repair. On the pre-fix source `_head_tuple()` read only `repo.full_name`, so the real Actions fixture deterministically normalized to an empty repository string. Production now accepts the fuller pull-request representation and the minimal workflow-run representation through the same bounded owner/name normalization contract. + +A one-use read-only branch workflow was attempted solely to capture hosted RED/GREEN evidence; GitHub did not schedule newly introduced branch-only push workflows in this repository, so no hosted result is claimed from that mechanism and it was deleted from the publishable tree. Ordinary protected PR checks and independent review on the exact production head remain authoritative. + +## Recovery and rollback + +If the coalescer reports unexpected preservation, inspect the live PR/run/sibling identities before changing policy. Do not weaken repository normalization, exact-head, exact-base, PR-association, final-status, refreshed-sibling, or authoritative-sibling checks to improve cancellation volume. If a false cancellation is ever observed, disable the `current-head-run-coalescer.yml` trigger first while retaining #1348 stale-head queue hygiene, then reproduce the identity race with a deterministic regression before repair. + +The feature is operability-only: it does not convert cancelled, queued, missing, stale, or predecessor evidence into passing merge evidence, and it does not change required-check, security, review, or branch-protection policy. + +## References + +GitHub. (2026). *REST API endpoints for workflow runs*. GitHub Docs. https://docs.github.com/en/rest/actions/workflow-runs + +GitHub. (2026). *Workflow syntax for GitHub Actions: concurrency*. GitHub Docs. https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#concurrency + +National Institute of Standards and Technology. (2020). *Security and privacy controls for information systems and organizations* (NIST Special Publication 800-53 Rev. 5). https://doi.org/10.6028/NIST.SP.800-53r5 diff --git a/scripts/ci/current_head_run_coalescer.py b/scripts/ci/current_head_run_coalescer.py new file mode 100644 index 0000000000..cb0fadeea6 --- /dev/null +++ b/scripts/ci/current_head_run_coalescer.py @@ -0,0 +1,513 @@ +#!/usr/bin/env python3 +"""Retire redundant queued GitHub Actions runs for one exact open PR head. + +The coalescer is intentionally narrower than ordinary stale-head cleanup. It +never intentionally cancels an in-progress run and never cancels the only +queued run for a workflow. A queued candidate is eligible only when a distinct +same-workflow run is still authoritative after live PR, association, sibling, +and candidate state are re-fetched immediately before cancellation. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +from typing import Any, Iterable, Mapping, Sequence +from urllib.parse import urlsplit + + +GIT_SHA_RE = re.compile(r"^[0-9a-f]{40}$") +REPOSITORY_RE = re.compile( + r"^(?!\.{1,2}/)[A-Za-z0-9_.-]+/(?!\.{1,2}$)[A-Za-z0-9_.-]+$" +) +PR_EVENTS = frozenset({"pull_request", "pull_request_target"}) +ACTIVE_STATUSES = ("queued", "in_progress") +API_TIMEOUT_SECONDS = 30 + + +class CoalescingRefused(RuntimeError): + """Signal that live evidence is insufficient for a destructive cancellation.""" + + +def _positive_int(value: object) -> int | None: + """Return a positive integer without accepting booleans or numeric strings.""" + return value if type(value) is int and value > 0 else None + + +def _pull_request_associations(run_data: Mapping[str, Any]) -> list[dict[str, Any]]: + """Return only well-shaped pull-request associations from an Actions run.""" + value = run_data.get("pull_requests") + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, dict)] + + +def _association_number(association: Mapping[str, Any]) -> int | None: + """Return one associated PR number when GitHub supplied a positive integer.""" + return _positive_int(association.get("number")) + + +def _repository_full_name(value: object) -> str: + """Normalize full and Actions-embedded repository objects to ``owner/name``.""" + if not isinstance(value, Mapping): + return "" + full_name = value.get("full_name") + if full_name is not None: + return ( + full_name + if isinstance(full_name, str) and REPOSITORY_RE.fullmatch(full_name) + else "" + ) + api_url = value.get("url") + if not isinstance(api_url, str): + return "" + parsed = urlsplit(api_url) + if ( + parsed.scheme != "https" + or parsed.netloc != "api.github.com" + or parsed.query + or parsed.fragment + ): + return "" + parts = parsed.path.split("/") + if len(parts) != 4 or parts[0] != "" or parts[1] != "repos": + return "" + candidate = f"{parts[2]}/{parts[3]}" + return candidate if REPOSITORY_RE.fullmatch(candidate) else "" + + +def _head_tuple(value: Mapping[str, Any]) -> tuple[str, str, str]: + """Normalize a PR-style head object to repository, ref, and lowercase SHA.""" + repository = _repository_full_name(value.get("repo")) + ref = str(value.get("ref") or "") + sha = str(value.get("sha") or "").lower() + return repository, ref, sha + + +def _base_tuple(value: Mapping[str, Any]) -> tuple[str, str, str]: + """Normalize a PR-style base object to repository, ref, and lowercase SHA.""" + repository = _repository_full_name(value.get("repo")) + ref = str(value.get("ref") or "") + sha = str(value.get("sha") or "").lower() + return repository, ref, sha + + +def _run_matches_head_identity( + run_data: Mapping[str, Any], *, repository: str, branch: str, head_sha: str +) -> bool: + """Match a run to the live PR head, including pull_request_target semantics.""" + event = run_data.get("event") + if event not in PR_EVENTS: + return False + if event == "pull_request": + if ( + str(run_data.get("head_sha") or "").lower() == head_sha + and run_data.get("head_branch") == branch + and _repository_full_name(run_data.get("head_repository")) == repository + ): + return True + for association in _pull_request_associations(run_data): + if _head_tuple(association.get("head") or {}) == (repository, branch, head_sha): + return True + return False + + +def _run_identity_matches( + run_data: dict[str, Any], + *, + repository: str, + branch: str, + head_sha: str, +) -> bool: + """Return whether one run belongs to the exact PR-head cancellation boundary.""" + return ( + _run_matches_head_identity( + run_data, repository=repository, branch=branch, head_sha=head_sha + ) + and _positive_int(run_data.get("workflow_id")) is not None + and _positive_int(run_data.get("id")) is not None + and run_data.get("status") in ACTIVE_STATUSES + ) + + +def select_duplicate_queued_run_ids( + runs: Iterable[dict[str, Any]], + *, + repository: str, + branch: str, + head_sha: str, +) -> list[int]: + """Select redundant queued runs while retaining one authoritative sibling.""" + groups: dict[int, list[dict[str, Any]]] = {} + for run_data in runs: + if not _run_identity_matches( + run_data, repository=repository, branch=branch, head_sha=head_sha + ): + continue + workflow_id = _positive_int(run_data.get("workflow_id")) + if workflow_id is not None: + groups.setdefault(workflow_id, []).append(run_data) + + redundant: list[int] = [] + for group in groups.values(): + queued = [item for item in group if item.get("status") == "queued"] + if not queued: + continue + if any(item.get("status") == "in_progress" for item in group): + redundant.extend( + run_id + for item in queued + if (run_id := _positive_int(item.get("id"))) is not None + ) + continue + queued_ids = sorted( + run_id + for item in queued + if (run_id := _positive_int(item.get("id"))) is not None + ) + if len(queued_ids) > 1: + redundant.extend(queued_ids[:-1]) + return sorted(redundant) + + +def _run_pr_scope_is_safe( + run_data: Mapping[str, Any], + *, + live_pr: Mapping[str, Any], + current_pr_number: int, + associated_prs: Mapping[int, Mapping[str, Any]], +) -> bool: + """Keep evidence isolated across live PRs while allowing exact closed predecessors.""" + associations = _pull_request_associations(run_data) + if not associations: + return False + live_head = _head_tuple(live_pr.get("head") or {}) + live_base = _base_tuple(live_pr.get("base") or {}) + if not all(live_head) or not all(live_base) or not GIT_SHA_RE.fullmatch(live_base[2]): + return False + saw_current = False + saw_closed_predecessor = False + for association in associations: + number = _association_number(association) + if number is None: + return False + if _head_tuple(association.get("head") or {}) != live_head: + return False + if _base_tuple(association.get("base") or {}) != live_base: + return False + if number == current_pr_number: + saw_current = True + continue + other = associated_prs.get(number) + if not isinstance(other, Mapping): + return False + if other.get("state") == "open": + return False + if _head_tuple(other.get("head") or {}) != live_head: + return False + if _base_tuple(other.get("base") or {}) != live_base: + return False + saw_closed_predecessor = True + return saw_current or saw_closed_predecessor + + +def validate_candidate_against_live_state( + candidate: dict[str, Any], + *, + live_pr: dict[str, Any], + active_same_head_runs: Sequence[dict[str, Any]], + current_pr_number: int | None = None, + associated_prs: Mapping[int, Mapping[str, Any]] | None = None, +) -> None: + """Fail closed unless a queued candidate still has an authoritative sibling.""" + if candidate.get("status") != "queued": + raise CoalescingRefused("candidate is no longer queued") + if live_pr.get("state") != "open": + raise CoalescingRefused("pull request is no longer open") + + live_repo, live_ref, live_sha = _head_tuple(live_pr.get("head") or {}) + if ( + not GIT_SHA_RE.fullmatch(live_sha) + or not _run_matches_head_identity( + candidate, repository=live_repo, branch=live_ref, head_sha=live_sha + ) + ): + raise CoalescingRefused("pull request head moved after duplicate classification") + + candidate_id = _positive_int(candidate.get("id")) + workflow_id = _positive_int(candidate.get("workflow_id")) + if candidate_id is None or workflow_id is None: + raise CoalescingRefused("candidate identity is malformed") + if candidate.get("event") not in PR_EVENTS: + raise CoalescingRefused("candidate is not a pull-request workflow run") + + association_map = associated_prs or {} + if current_pr_number is not None and not _run_pr_scope_is_safe( + candidate, + live_pr=live_pr, + current_pr_number=current_pr_number, + associated_prs=association_map, + ): + raise CoalescingRefused("candidate belongs to an independent pull request") + + authoritative_sibling = False + for sibling in active_same_head_runs: + sibling_id = _positive_int(sibling.get("id")) + if sibling_id is None or sibling_id == candidate_id: + continue + if _positive_int(sibling.get("workflow_id")) != workflow_id: + continue + if not _run_identity_matches( + sibling, repository=live_repo, branch=live_ref, head_sha=live_sha + ): + continue + if current_pr_number is not None and not _run_pr_scope_is_safe( + sibling, + live_pr=live_pr, + current_pr_number=current_pr_number, + associated_prs=association_map, + ): + continue + if sibling.get("status") == "in_progress" or sibling_id > candidate_id: + authoritative_sibling = True + break + if not authoritative_sibling: + raise CoalescingRefused("no distinct authoritative sibling remains active") + + +def _run_json(args: Sequence[str]) -> Any: + """Run one token-bound GitHub CLI call with an individual request timeout.""" + if not os.environ.get("GH_TOKEN"): + raise RuntimeError("GH_TOKEN is required for current-head run coalescing") + try: + completed = subprocess.run( + list(args), + capture_output=True, + text=True, + check=False, + shell=False, + env=os.environ.copy(), + timeout=API_TIMEOUT_SECONDS, + ) + except subprocess.TimeoutExpired as exc: + raise RuntimeError("GitHub API request timed out") from exc + if completed.returncode != 0: + diagnostic = (completed.stderr or completed.stdout or "GitHub API request failed").strip() + raise RuntimeError(diagnostic[:600]) + return json.loads(completed.stdout or "null") + + +def _fetch_pr(repo: str, number: int) -> dict[str, Any]: + """Fetch one live pull request through GitHub REST.""" + payload = _run_json( + ["gh", "api", "-H", "Accept: application/vnd.github+json", f"repos/{repo}/pulls/{number}"] + ) + if not isinstance(payload, dict): + raise RuntimeError("GitHub returned malformed pull-request evidence") + return payload + + +def _active_runs(repo: str, _head_sha: str) -> list[dict[str, Any]]: + """Fetch all queued/in-progress runs so pull_request_target runs are visible.""" + runs: list[dict[str, Any]] = [] + for status in ACTIVE_STATUSES: + page = 1 + while True: + payload = _run_json( + [ + "gh", + "api", + "--method", + "GET", + f"repos/{repo}/actions/runs", + "-f", + f"status={status}", + "-F", + "per_page=100", + "-F", + f"page={page}", + ] + ) + if not isinstance(payload, dict) or not isinstance(payload.get("workflow_runs"), list): + raise RuntimeError("GitHub returned malformed Actions run evidence") + batch = payload["workflow_runs"] + runs.extend(item for item in batch if isinstance(item, dict)) + if len(batch) < 100: + break + page += 1 + return runs + + +def _fetch_run(repo: str, run_id: int) -> dict[str, Any]: + """Fetch one exact Actions run immediately before possible cancellation.""" + payload = _run_json( + [ + "gh", + "api", + "-H", + "Accept: application/vnd.github+json", + f"repos/{repo}/actions/runs/{run_id}", + ] + ) + if not isinstance(payload, dict): + raise RuntimeError("GitHub returned malformed Actions run identity evidence") + return payload + + +def _cancel_run(repo: str, run_id: int) -> None: + """Request ordinary cancellation using the same explicit token/timeout contract.""" + _run_json(["gh", "api", "-X", "POST", f"repos/{repo}/actions/runs/{run_id}/cancel"]) + + +def _associated_prs( + repo: str, + runs: Sequence[Mapping[str, Any]], + current_pr_number: int, + *, + repository: str, + branch: str, + head_sha: str, +) -> dict[int, dict[str, Any]]: + """Fetch only same-head non-current PR associations needed for predecessor proof.""" + numbers = { + number + for run_data in runs + if _run_matches_head_identity( + run_data, repository=repository, branch=branch, head_sha=head_sha + ) + for association in _pull_request_associations(run_data) + if (number := _association_number(association)) is not None + and number != current_pr_number + } + return {number: _fetch_pr(repo, number) for number in sorted(numbers)} + + +def _refresh_siblings( + repo: str, + runs: Sequence[Mapping[str, Any]], + candidate_run_id: int, + *, + repository: str, + branch: str, + head_sha: str, +) -> list[dict[str, Any]]: + """Re-fetch candidate peers so stale bulk state cannot authorize cancellation.""" + candidate_snapshot = next( + ( + run_data + for run_data in runs + if _positive_int(run_data.get("id")) == candidate_run_id + ), + None, + ) + if candidate_snapshot is None: + return [] + workflow_id = _positive_int(candidate_snapshot.get("workflow_id")) + if workflow_id is None: + return [] + sibling_ids = sorted( + sibling_run_id + for run_data in runs + if _positive_int(run_data.get("workflow_id")) == workflow_id + and _run_identity_matches( + dict(run_data), repository=repository, branch=branch, head_sha=head_sha + ) + and (sibling_run_id := _positive_int(run_data.get("id"))) is not None + and sibling_run_id != candidate_run_id + ) + return [_fetch_run(repo, sibling_run_id) for sibling_run_id in sibling_ids] + + +def coalesce(repo: str, number: int, expected_repo: str, expected_ref: str, expected_head: str) -> list[int]: + """Cancel redundant queued runs after exact live PR/run/sibling revalidation.""" + if not REPOSITORY_RE.fullmatch(repo) or not REPOSITORY_RE.fullmatch(expected_repo): + raise RuntimeError("repository identity is malformed") + if not GIT_SHA_RE.fullmatch(expected_head): + raise RuntimeError("expected head must be a lowercase 40-character Git SHA") + if number <= 0 or not expected_ref or any(char.isspace() for char in expected_ref): + raise RuntimeError("pull-request identity is malformed") + + live_pr = _fetch_pr(repo, number) + live_repo, live_ref, live_sha = _head_tuple(live_pr.get("head") or {}) + if ( + live_pr.get("state") != "open" + or live_sha != expected_head + or live_ref != expected_ref + or live_repo != expected_repo + ): + raise CoalescingRefused("pull request head moved before duplicate classification") + + snapshot = _active_runs(repo, expected_head) + candidates = select_duplicate_queued_run_ids( + snapshot, + repository=expected_repo, + branch=expected_ref, + head_sha=expected_head, + ) + cancelled: list[int] = [] + for run_id in candidates: + try: + active = _active_runs(repo, expected_head) + association_map = _associated_prs( + repo, + active, + number, + repository=expected_repo, + branch=expected_ref, + head_sha=expected_head, + ) + refreshed_siblings = _refresh_siblings( + repo, + active, + run_id, + repository=expected_repo, + branch=expected_ref, + head_sha=expected_head, + ) + current_pr = _fetch_pr(repo, number) + candidate = _fetch_run(repo, run_id) + validate_candidate_against_live_state( + candidate, + live_pr=current_pr, + active_same_head_runs=refreshed_siblings, + current_pr_number=number, + associated_prs=association_map, + ) + _cancel_run(repo, run_id) + except CoalescingRefused as exc: + print(f"Preserving run {run_id}: {exc}") + continue + cancelled.append(run_id) + print(f"Cancelled redundant queued current-head run {run_id} for {repo}#{number}.") + return cancelled + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + """Parse the exact pull-request identity supplied by the trusted workflow.""" + parser = argparse.ArgumentParser() + parser.add_argument("--repo", required=True) + parser.add_argument("--pr-number", required=True, type=int) + parser.add_argument("--expected-head-repo", required=True) + parser.add_argument("--expected-head-ref", required=True) + parser.add_argument("--expected-head", required=True) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the coalescer and fail closed on malformed or unavailable evidence.""" + args = parse_args(argv) + coalesce( + args.repo, + args.pr_number, + args.expected_head_repo, + args.expected_head_ref, + args.expected_head, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_current_head_run_coalescer.py b/tests/test_current_head_run_coalescer.py new file mode 100644 index 0000000000..33eee98d6c --- /dev/null +++ b/tests/test_current_head_run_coalescer.py @@ -0,0 +1,480 @@ +"""Regression tests for exact-current-head GitHub Actions run coalescing.""" + +from __future__ import annotations + +import importlib.util +import runpy +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPT = REPO_ROOT / "scripts" / "ci" / "current_head_run_coalescer.py" +WORKFLOW = REPO_ROOT / ".github" / "workflows" / "current-head-run-coalescer.yml" + + +def load_module(): + """Load the production coalescer only after proving the file exists.""" + assert SCRIPT.is_file(), "current-head duplicate coalescer is not implemented" + spec = importlib.util.spec_from_file_location("current_head_run_coalescer", SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def pr_association( + number: int = 1, + *, + head_sha: str = "a" * 40, + branch: str = "feature/current", + repository: str = "ContextualWisdomLab/.github", + base_ref: str = "main", +) -> dict[str, object]: + """Return one Actions run pull-request association fixture.""" + return { + "number": number, + "head": {"sha": head_sha, "ref": branch, "repo": {"full_name": repository}}, + "base": {"ref": base_ref, "sha": "c" * 40, "repo": {"full_name": repository}}, + } + + +def run_record( + run_id: int, + workflow_id: int, + *, + status: str = "queued", + head_sha: str = "a" * 40, + branch: str = "feature/current", + repository: str = "ContextualWisdomLab/.github", + event: str = "pull_request", + pr_number: int = 1, + execution_head_sha: str | None = None, + associations: list[dict[str, object]] | None = None, +) -> dict[str, object]: + """Return one bounded Actions run fixture with authoritative PR association.""" + return { + "id": run_id, + "workflow_id": workflow_id, + "status": status, + "head_sha": execution_head_sha or head_sha, + "head_branch": branch, + "event": event, + "head_repository": {"full_name": repository}, + "pull_requests": associations + if associations is not None + else [ + pr_association( + pr_number, + head_sha=head_sha, + branch=branch, + repository=repository, + ) + ], + } + + +def live_pr( + *, + state: str = "open", + head_sha: str = "a" * 40, + number: int = 1, + base_ref: str = "main", +) -> dict[str, object]: + """Return the exact live PR identity used by revalidation tests.""" + return { + "number": number, + "state": state, + "head": { + "sha": head_sha, + "ref": "feature/current", + "repo": {"full_name": "ContextualWisdomLab/.github"}, + }, + "base": { + "sha": "c" * 40, + "ref": base_ref, + "repo": {"full_name": "ContextualWisdomLab/.github"}, + }, + } + + +def test_select_duplicate_queued_runs_keeps_one_authoritative_run_per_workflow() -> None: + """Older queued duplicates are retired while one exact-head run survives.""" + module = load_module() + runs = [run_record(100, 10), run_record(101, 10), run_record(102, 10), run_record(200, 20), run_record(201, 20)] + assert module.select_duplicate_queued_run_ids( + runs, + repository="ContextualWisdomLab/.github", + branch="feature/current", + head_sha="a" * 40, + ) == [100, 101, 200] + + +def test_in_progress_run_is_never_selected_and_makes_queued_siblings_redundant() -> None: + """A running authoritative workflow is preserved and queued duplicates retire.""" + module = load_module() + runs = [run_record(100, 10, status="in_progress"), run_record(101, 10), run_record(102, 10)] + assert module.select_duplicate_queued_run_ids( + runs, + repository="ContextualWisdomLab/.github", + branch="feature/current", + head_sha="a" * 40, + ) == [101, 102] + + +def test_pull_request_target_uses_associated_pr_head_not_execution_head() -> None: + """Trusted-base pull_request_target runs coalesce by their associated PR head.""" + module = load_module() + target = run_record(100, 10, event="pull_request_target", execution_head_sha="b" * 40) + newer = run_record(101, 10, event="pull_request_target", execution_head_sha="b" * 40) + assert module.select_duplicate_queued_run_ids( + [target, newer], + repository="ContextualWisdomLab/.github", + branch="feature/current", + head_sha="a" * 40, + ) == [100] + + +def test_other_identities_and_malformed_runs_are_not_coalesced() -> None: + """Coalescing stays inside one exact current-head pull-request workflow identity.""" + module = load_module() + runs = [ + run_record(100, 10), + run_record(101, 11), + run_record(102, 10, head_sha="b" * 40), + run_record(103, 10, branch="other"), + run_record(104, 10, repository="ContextualWisdomLab/TEPP"), + run_record(105, 10, event="push"), + run_record(0, 10), + run_record(106, 0), + {**run_record(107, 10), "status": "completed"}, + ] + assert module.select_duplicate_queued_run_ids( + runs, + repository="ContextualWisdomLab/.github", + branch="feature/current", + head_sha="a" * 40, + ) == [] + assert module._positive_int(True) is None + assert module._positive_int("1") is None + assert module._positive_int(0) is None + assert module._positive_int(1) == 1 + assert module._pull_request_associations({"pull_requests": "bad"}) == [] + assert module._association_number({"number": "1"}) is None + + +def test_revalidation_requires_a_distinct_newer_or_running_sibling() -> None: + """The sole or newest queued current-head run is never cancelled.""" + module = load_module() + candidate = run_record(100, 10) + for active in ([candidate], [candidate, run_record(99, 10)]): + with pytest.raises(module.CoalescingRefused, match="authoritative sibling"): + module.validate_candidate_against_live_state(candidate, live_pr=live_pr(), active_same_head_runs=active) + module.validate_candidate_against_live_state( + candidate, live_pr=live_pr(), active_same_head_runs=[candidate, run_record(101, 10)] + ) + module.validate_candidate_against_live_state( + candidate, + live_pr=live_pr(), + active_same_head_runs=[candidate, run_record(99, 10, status="in_progress")], + ) + + +def test_revalidation_fails_closed_for_status_state_identity_and_event_changes() -> None: + """Every live identity transition preserves the candidate before mutation.""" + module = load_module() + candidate = run_record(100, 10) + sibling = run_record(101, 10) + with pytest.raises(module.CoalescingRefused, match="no longer queued"): + module.validate_candidate_against_live_state( + run_record(100, 10, status="in_progress"), live_pr=live_pr(), active_same_head_runs=[sibling] + ) + with pytest.raises(module.CoalescingRefused, match="no longer open"): + module.validate_candidate_against_live_state(candidate, live_pr=live_pr(state="closed"), active_same_head_runs=[sibling]) + with pytest.raises(module.CoalescingRefused, match="head moved"): + module.validate_candidate_against_live_state(candidate, live_pr=live_pr(head_sha="b" * 40), active_same_head_runs=[sibling]) + with pytest.raises(module.CoalescingRefused, match="identity is malformed"): + module.validate_candidate_against_live_state(run_record(0, 10), live_pr=live_pr(), active_same_head_runs=[sibling]) + with pytest.raises(module.CoalescingRefused, match="not a pull-request"): + module.validate_candidate_against_live_state(run_record(100, 10, event="push"), live_pr=live_pr(), active_same_head_runs=[sibling]) + + +def test_pr_scope_rejects_other_open_pr_and_accepts_closed_matching_predecessor() -> None: + """Concurrent PRs keep independent evidence while a closed predecessor may coalesce.""" + module = load_module() + current = live_pr() + other_assoc = [pr_association(2)] + candidate = run_record(100, 10, pr_number=2, associations=other_assoc) + sibling = run_record(101, 10) + other_open = live_pr(number=2) + with pytest.raises(module.CoalescingRefused, match="independent pull request"): + module.validate_candidate_against_live_state( + candidate, + live_pr=current, + active_same_head_runs=[candidate, sibling], + current_pr_number=1, + associated_prs={2: other_open}, + ) + other_closed = live_pr(state="closed", number=2) + module.validate_candidate_against_live_state( + candidate, + live_pr=current, + active_same_head_runs=[candidate, sibling], + current_pr_number=1, + associated_prs={2: other_closed}, + ) + wrong_base = live_pr(state="closed", number=2, base_ref="release") + with pytest.raises(module.CoalescingRefused, match="independent pull request"): + module.validate_candidate_against_live_state( + candidate, + live_pr=current, + active_same_head_runs=[candidate, sibling], + current_pr_number=1, + associated_prs={2: wrong_base}, + ) + + +def test_revalidation_ignores_non_authoritative_sibling_shapes() -> None: + """Different workflow or malformed sibling records cannot authorize cancellation.""" + module = load_module() + candidate = run_record(100, 10) + siblings = [run_record(101, 11), run_record(102, 10, branch="other"), run_record(0, 10), candidate] + with pytest.raises(module.CoalescingRefused, match="authoritative sibling"): + module.validate_candidate_against_live_state(candidate, live_pr=live_pr(), active_same_head_runs=siblings) + + +def test_run_json_uses_token_timeout_decodes_success_and_bounds_failure(monkeypatch) -> None: + """GitHub transport is token-bound, JSON-only, individually timed, and bounded.""" + module = load_module() + monkeypatch.delenv("GH_TOKEN", raising=False) + with pytest.raises(RuntimeError, match="GH_TOKEN"): + module._run_json(["gh", "api", "repos/o/r"]) + + monkeypatch.setenv("GH_TOKEN", "token") + seen: dict[str, object] = {} + + def success(*args, **kwargs): + seen.update(kwargs) + return SimpleNamespace(returncode=0, stdout='{"ok":true}', stderr="") + + monkeypatch.setattr(module.subprocess, "run", success) + assert module._run_json(["gh", "api", "repos/o/r"]) == {"ok": True} + assert seen["timeout"] == module.API_TIMEOUT_SECONDS + + def timeout(*_args, **_kwargs): + raise subprocess.TimeoutExpired(cmd="gh", timeout=30) + + monkeypatch.setattr(module.subprocess, "run", timeout) + with pytest.raises(RuntimeError, match="timed out"): + module._run_json(["gh", "api", "repos/o/r"]) + + monkeypatch.setattr( + module.subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace(returncode=1, stdout="", stderr="x" * 700), + ) + with pytest.raises(RuntimeError) as exc_info: + module._run_json(["gh", "api", "repos/o/r"]) + assert len(str(exc_info.value)) == 600 + + +def test_fetch_helpers_fail_closed_and_paginate(monkeypatch) -> None: + """PR/run fetches reject malformed payloads and Actions pagination is complete.""" + module = load_module() + monkeypatch.setattr(module, "_run_json", lambda _args: {"state": "open"}) + assert module._fetch_pr("o/r", 1) == {"state": "open"} + assert module._fetch_run("o/r", 2) == {"state": "open"} + + monkeypatch.setattr(module, "_run_json", lambda _args: []) + with pytest.raises(RuntimeError, match="pull-request evidence"): + module._fetch_pr("o/r", 1) + with pytest.raises(RuntimeError, match="run identity evidence"): + module._fetch_run("o/r", 1) + + hundred = [run_record(index + 1, 10) for index in range(100)] + calls: list[list[str]] = [] + + def pages(args): + calls.append(list(args)) + status = next(item.split("=", 1)[1] for item in args if item.startswith("status=")) + page = int(next(item.split("=", 1)[1] for item in args if item.startswith("page="))) + if status == "queued" and page == 1: + return {"workflow_runs": hundred} + if status == "queued" and page == 2: + return {"workflow_runs": [run_record(101, 10)]} + return {"workflow_runs": []} + + monkeypatch.setattr(module, "_run_json", pages) + assert len(module._active_runs("o/r", "a" * 40)) == 101 + assert any("page=2" in call for call in calls) + assert not any(item.startswith("head_sha=") for call in calls for item in call) + + monkeypatch.setattr(module, "_run_json", lambda _args: {"workflow_runs": "bad"}) + with pytest.raises(RuntimeError, match="malformed Actions"): + module._active_runs("o/r", "a" * 40) + + +def test_cancel_run_uses_explicit_transport_and_ordinary_endpoint(monkeypatch) -> None: + """Cancellation shares the token/timeout transport and never uses force-cancel.""" + module = load_module() + calls: list[list[str]] = [] + monkeypatch.setattr(module, "_run_json", lambda args: calls.append(list(args))) + module._cancel_run("o/r", 123) + assert calls == [["gh", "api", "-X", "POST", "repos/o/r/actions/runs/123/cancel"]] + assert "force-cancel" not in " ".join(calls[0]) + + +def test_associated_pr_fetches_only_same_head_noncurrent_numbers(monkeypatch) -> None: + """Predecessor lookup ignores unrelated active runs and fetches each same-head PR once.""" + module = load_module() + calls: list[int] = [] + monkeypatch.setattr(module, "_fetch_pr", lambda _repo, number: calls.append(number) or live_pr(number=number, state="closed")) + runs = [ + run_record(100, 10), + run_record(101, 10, pr_number=2), + run_record(102, 10, pr_number=2), + run_record(103, 10, pr_number=999, head_sha="b" * 40), + ] + result = module._associated_prs( + "o/r", + runs, + 1, + repository="ContextualWisdomLab/.github", + branch="feature/current", + head_sha="a" * 40, + ) + assert list(result) == [2] + assert calls == [2] + + +def test_refresh_siblings_refetches_only_same_workflow_head_peers(monkeypatch) -> None: + """Sibling refresh is bounded to exact-head peers and fails closed without a candidate.""" + module = load_module() + candidate = run_record(100, 10) + sibling = run_record(101, 10) + other_workflow = run_record(102, 11) + other_head = run_record(103, 10, head_sha="b" * 40) + assert module._refresh_siblings( + "o/r", [sibling], 100, repository="ContextualWisdomLab/.github", branch="feature/current", head_sha="a" * 40 + ) == [] + assert module._refresh_siblings( + "o/r", [{**candidate, "workflow_id": 0}], 100, repository="ContextualWisdomLab/.github", branch="feature/current", head_sha="a" * 40 + ) == [] + calls: list[int] = [] + monkeypatch.setattr(module, "_fetch_run", lambda _repo, run_id: calls.append(run_id) or sibling) + refreshed = module._refresh_siblings( + "o/r", + [candidate, sibling, other_workflow, other_head], + 100, + repository="ContextualWisdomLab/.github", + branch="feature/current", + head_sha="a" * 40, + ) + assert [item["id"] for item in refreshed] == [101] + assert calls == [101] + + +def test_coalesce_validates_inputs_rechecks_each_candidate_and_preserves_races(monkeypatch, capsys) -> None: + """The mutation path revalidates live state per candidate and preserves races.""" + module = load_module() + for repo in ("../evil", "owner/..", "owner/repo/extra"): + with pytest.raises(RuntimeError, match="repository identity"): + module.coalesce(repo, 1, "owner/repo", "feature/current", "a" * 40) + with pytest.raises(RuntimeError, match="expected head"): + module.coalesce("owner/repo", 1, "owner/repo", "feature/current", "BAD") + with pytest.raises(RuntimeError, match="pull-request identity"): + module.coalesce("owner/repo", 0, "owner/repo", "feature/current", "a" * 40) + with pytest.raises(RuntimeError, match="pull-request identity"): + module.coalesce("owner/repo", 1, "owner/repo", "bad ref", "a" * 40) + + monkeypatch.setattr(module, "_fetch_pr", lambda *_args: live_pr(head_sha="b" * 40)) + with pytest.raises(module.CoalescingRefused, match="moved before"): + module.coalesce("ContextualWisdomLab/.github", 1, "ContextualWisdomLab/.github", "feature/current", "a" * 40) + + candidate = run_record(100, 10) + sibling = run_record(101, 10) + monkeypatch.setattr(module, "_fetch_pr", lambda *_args: live_pr()) + active_calls = iter([[candidate, sibling], [candidate]]) + monkeypatch.setattr(module, "_active_runs", lambda *_args: next(active_calls)) + monkeypatch.setattr(module, "_fetch_run", lambda *_args: candidate) + cancelled: list[int] = [] + monkeypatch.setattr(module, "_cancel_run", lambda _repo, run_id: cancelled.append(run_id)) + assert module.coalesce("ContextualWisdomLab/.github", 1, "ContextualWisdomLab/.github", "feature/current", "a" * 40) == [] + assert cancelled == [] + assert "Preserving run 100" in capsys.readouterr().out + + +def test_coalesce_refetches_candidate_last_and_preserves_started_run(monkeypatch) -> None: + """A candidate that starts after sibling validation is not cancelled.""" + module = load_module() + candidate = run_record(100, 10) + sibling = run_record(101, 10) + monkeypatch.setattr(module, "_fetch_pr", lambda *_args: live_pr()) + monkeypatch.setattr(module, "_active_runs", lambda *_args: [candidate, sibling]) + + def fetch_run(_repo: str, run_id: int): + return sibling if run_id == 101 else run_record(100, 10, status="in_progress") + + monkeypatch.setattr(module, "_fetch_run", fetch_run) + cancelled: list[int] = [] + monkeypatch.setattr(module, "_cancel_run", lambda _repo, run_id: cancelled.append(run_id)) + assert module.coalesce("ContextualWisdomLab/.github", 1, "ContextualWisdomLab/.github", "feature/current", "a" * 40) == [] + assert cancelled == [] + + +def test_coalesce_cancels_only_revalidated_redundant_candidates(monkeypatch, capsys) -> None: + """A proven older queued duplicate is cancelled and reported exactly once.""" + module = load_module() + candidate = run_record(100, 10) + sibling = run_record(101, 10) + monkeypatch.setattr(module, "_fetch_pr", lambda *_args: live_pr()) + monkeypatch.setattr(module, "_active_runs", lambda *_args: [candidate, sibling]) + monkeypatch.setattr(module, "_fetch_run", lambda _repo, run_id: sibling if run_id == 101 else candidate) + cancelled: list[int] = [] + monkeypatch.setattr(module, "_cancel_run", lambda _repo, run_id: cancelled.append(run_id)) + assert module.coalesce("ContextualWisdomLab/.github", 1, "ContextualWisdomLab/.github", "feature/current", "a" * 40) == [100] + assert cancelled == [100] + assert "Cancelled redundant queued current-head run 100" in capsys.readouterr().out + + +def test_parse_args_main_and_script_help(monkeypatch) -> None: + """CLI parsing forwards exact identity and the executable entrypoint is reachable.""" + module = load_module() + argv = [ + "--repo", "owner/repo", "--pr-number", "7", "--expected-head-repo", "owner/repo", + "--expected-head-ref", "feature/current", "--expected-head", "a" * 40, + ] + parsed = module.parse_args(argv) + assert parsed.pr_number == 7 + calls: list[tuple[object, ...]] = [] + monkeypatch.setattr(module, "coalesce", lambda *args: calls.append(args) or []) + assert module.main(argv) == 0 + assert calls == [("owner/repo", 7, "owner/repo", "feature/current", "a" * 40)] + + monkeypatch.setattr(sys, "argv", [str(SCRIPT), "--help"]) + with pytest.raises(SystemExit) as exc_info: + runpy.run_path(str(SCRIPT), run_name="__main__") + assert exc_info.value.code == 0 + + +def test_workflow_is_trusted_pr_target_with_minimum_actions_write() -> None: + """The production workflow uses trusted source and a shell-safe mutation scope.""" + assert WORKFLOW.is_file(), "current-head duplicate coalescer workflow is not implemented" + text = WORKFLOW.read_text(encoding="utf-8") + assert "pull_request_target:" in text + assert "types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]" in text + assert "actions: write" in text + assert "contents: read" in text + assert "pull-requests: read" in text + assert "persist-credentials: false" in text + assert "ref: ${{ github.workflow_sha }}" in text + assert "current_head_run_coalescer.py" in text + assert "cancel-in-progress: true" in text + assert "EXPECTED_HEAD_REF: ${{ github.event.pull_request.head.ref }}" in text + assert '--expected-head-ref "$EXPECTED_HEAD_REF"' in text + run_block = text.split("run: |", 1)[1] + assert "${{ github.event.pull_request.head.ref }}" not in run_block diff --git a/tests/test_current_head_run_coalescer_review_regressions.py b/tests/test_current_head_run_coalescer_review_regressions.py new file mode 100644 index 0000000000..6203241c50 --- /dev/null +++ b/tests/test_current_head_run_coalescer_review_regressions.py @@ -0,0 +1,311 @@ +"""Review regressions for current-head GitHub Actions run coalescing.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPT = REPO_ROOT / "scripts" / "ci" / "current_head_run_coalescer.py" +WORKFLOW = REPO_ROOT / ".github" / "workflows" / "current-head-run-coalescer.yml" + + +def load_module(): + """Load the production coalescer from the current checkout.""" + spec = importlib.util.spec_from_file_location("current_head_run_coalescer_review", SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def full_repo(name: str = "ContextualWisdomLab/.github") -> dict[str, object]: + """Return the full repository shape emitted by the pull-request endpoint.""" + return {"id": 1274066402, "full_name": name} + + +def minimal_repo(name: str = "ContextualWisdomLab/.github") -> dict[str, object]: + """Return the minimal repository shape embedded in Actions run PR associations.""" + owner, repository = name.split("/", 1) + return { + "id": 1274066402, + "name": repository, + "url": f"https://api.github.com/repos/{owner}/{repository}", + } + + +def pr_head( + *, + sha: str = "a" * 40, + ref: str = "feature/current", + repository: dict[str, object] | None = None, +) -> dict[str, object]: + """Return one PR-style head identity.""" + return { + "sha": sha, + "ref": ref, + "repo": repository or full_repo(), + } + + +def live_pr( + *, + state: str = "open", + base_ref: str = "main", + base_sha: str = "b" * 40, + base_repo: str = "ContextualWisdomLab/.github", +) -> dict[str, object]: + """Return one live PR identity with an explicit exact base boundary.""" + return { + "state": state, + "head": pr_head(), + "base": {"ref": base_ref, "sha": base_sha, "repo": full_repo(base_repo)}, + } + + +def run_record( + run_id: int, + *, + status: str = "queued", + event: str = "pull_request", + top_head_sha: str = "a" * 40, + top_head_branch: str = "feature/current", + pr_number: int = 2, + base_ref: str = "main", + base_sha: str = "b" * 40, + base_repo: str = "ContextualWisdomLab/.github", + minimal_association: bool = False, +) -> dict[str, object]: + """Return an Actions run with both workflow and associated-PR identities.""" + association_repo = minimal_repo() if minimal_association else full_repo() + association_base_repo = minimal_repo(base_repo) if minimal_association else full_repo(base_repo) + return { + "id": run_id, + "workflow_id": 10, + "status": status, + "event": event, + "head_sha": top_head_sha, + "head_branch": top_head_branch, + "head_repository": full_repo(), + "pull_requests": [ + { + "number": pr_number, + "head": pr_head(repository=association_repo), + "base": { + "ref": base_ref, + "sha": base_sha, + "repo": association_base_repo, + }, + } + ], + } + + +def test_real_actions_repository_shape_normalizes_to_pull_request_identity() -> None: + """Minimal Actions associations normalize to the same repository name as live PRs.""" + module = load_module() + minimal_head = pr_head(repository=minimal_repo()) + assert module._head_tuple(minimal_head) == ( + "ContextualWisdomLab/.github", + "feature/current", + "a" * 40, + ) + minimal_base = {"ref": "main", "sha": "b" * 40, "repo": minimal_repo()} + assert module._base_tuple(minimal_base) == ( + "ContextualWisdomLab/.github", + "main", + "b" * 40, + ) + + +@pytest.mark.parametrize( + ("repository_shape", "expected"), + [ + (None, ""), + (full_repo(), "ContextualWisdomLab/.github"), + ({"full_name": "bad", "url": minimal_repo()["url"]}, ""), + ({}, ""), + ({"url": 7}, ""), + ({"url": "http://api.github.com/repos/ContextualWisdomLab/.github"}, ""), + ({"url": "https://example.com/repos/ContextualWisdomLab/.github"}, ""), + ({"url": "https://api.github.com/repos/ContextualWisdomLab/.github?x=1"}, ""), + ({"url": "https://api.github.com/repos/ContextualWisdomLab"}, ""), + ({"url": "https://api.github.com/repos/../.github"}, ""), + (minimal_repo(), "ContextualWisdomLab/.github"), + ], +) +def test_repository_shape_normalization_fails_closed( + repository_shape: object, expected: str +) -> None: + """Repository normalization accepts only full names or canonical GitHub API URLs.""" + module = load_module() + assert module._repository_full_name(repository_shape) == expected + + +def test_minimal_actions_associations_pass_exact_scope_for_both_pr_events() -> None: + """Real Actions association shapes remain eligible for PR and target-event coalescing.""" + module = load_module() + for event in ("pull_request", "pull_request_target"): + candidate = run_record(100, event=event, minimal_association=True) + sibling = run_record(101, event=event, minimal_association=True) + module.validate_candidate_against_live_state( + candidate, + live_pr=live_pr(), + active_same_head_runs=[candidate, sibling], + current_pr_number=2, + associated_prs={}, + ) + + +def test_pull_request_target_matches_associated_pr_head_not_trusted_base_head() -> None: + """Target-event runs bind to associated PR head rather than workflow base head.""" + module = load_module() + target_run = run_record( + 100, + event="pull_request_target", + top_head_sha="c" * 40, + top_head_branch="main", + minimal_association=True, + ) + assert module._run_identity_matches( + target_run, + repository="ContextualWisdomLab/.github", + branch="feature/current", + head_sha="a" * 40, + ) + + +def test_distinct_open_pr_association_cannot_authorize_cross_pr_cancellation() -> None: + """An open sibling PR sharing one branch/SHA keeps its own workflow evidence.""" + module = load_module() + candidate = run_record(100, pr_number=1) + sibling = run_record(101, pr_number=2) + other_open_pr = live_pr(base_ref="develop") + with pytest.raises(module.CoalescingRefused, match="independent pull request"): + module.validate_candidate_against_live_state( + candidate, + live_pr=live_pr(), + active_same_head_runs=[candidate, sibling], + current_pr_number=2, + associated_prs={1: other_open_pr}, + ) + + +def test_closed_predecessor_must_share_exact_base_sha_and_repository() -> None: + """A closed predecessor on a different base snapshot cannot donate required evidence.""" + module = load_module() + current = live_pr() + sibling = run_record(101, pr_number=2) + + candidate_old_base = run_record(100, pr_number=1, base_sha="c" * 40) + predecessor_old_base = live_pr(state="closed", base_sha="c" * 40) + with pytest.raises(module.CoalescingRefused, match="independent pull request"): + module.validate_candidate_against_live_state( + candidate_old_base, + live_pr=current, + active_same_head_runs=[candidate_old_base, sibling], + current_pr_number=2, + associated_prs={1: predecessor_old_base}, + ) + + candidate_other_repo = run_record(100, pr_number=1, base_repo="ContextualWisdomLab/TEPP") + predecessor_other_repo = live_pr( + state="closed", + base_repo="ContextualWisdomLab/TEPP", + ) + with pytest.raises(module.CoalescingRefused, match="independent pull request"): + module.validate_candidate_against_live_state( + candidate_other_repo, + live_pr=current, + active_same_head_runs=[candidate_other_repo, sibling], + current_pr_number=2, + associated_prs={1: predecessor_other_repo}, + ) + + +def test_final_candidate_fetch_preserves_run_that_started_after_snapshot(monkeypatch) -> None: + """A queued snapshot candidate that starts before final mutation is preserved.""" + module = load_module() + queued = run_record(100) + started = run_record(100, status="in_progress") + sibling = run_record(101) + monkeypatch.setattr(module, "_fetch_pr", lambda *_args: live_pr()) + monkeypatch.setattr(module, "_active_runs", lambda *_args: [queued, sibling]) + monkeypatch.setattr(module, "_fetch_run", lambda *_args: started) + cancelled: list[int] = [] + monkeypatch.setattr(module, "_cancel_run", lambda _repo, run_id: cancelled.append(run_id)) + + assert module.coalesce( + "ContextualWisdomLab/.github", + 2, + "ContextualWisdomLab/.github", + "feature/current", + "a" * 40, + ) == [] + assert cancelled == [] + + +def test_authoritative_sibling_is_refetched_and_must_still_be_active(monkeypatch) -> None: + """A sibling that completed after the bulk snapshot cannot justify cancellation.""" + module = load_module() + candidate = run_record(100) + stale_sibling = run_record(101) + completed_sibling = run_record(101, status="completed") + monkeypatch.setattr(module, "_fetch_pr", lambda *_args: live_pr()) + monkeypatch.setattr(module, "_active_runs", lambda *_args: [candidate, stale_sibling]) + + def fetch_run(_repo: str, run_id: int): + if run_id == 101: + return completed_sibling + return candidate + + monkeypatch.setattr(module, "_fetch_run", fetch_run) + cancelled: list[int] = [] + monkeypatch.setattr(module, "_cancel_run", lambda _repo, run_id: cancelled.append(run_id)) + + assert module.coalesce( + "ContextualWisdomLab/.github", + 2, + "ContextualWisdomLab/.github", + "feature/current", + "a" * 40, + ) == [] + assert cancelled == [] + + +def test_transport_is_token_bound_and_individually_timeout_bounded(monkeypatch) -> None: + """Read and cancellation transports require GH_TOKEN and a per-call timeout.""" + module = load_module() + monkeypatch.delenv("GH_TOKEN", raising=False) + with pytest.raises(RuntimeError, match="GH_TOKEN"): + module._cancel_run("owner/repo", 123) + + monkeypatch.setenv("GH_TOKEN", "token") + calls: list[tuple[list[str], dict[str, object]]] = [] + + def success(args, **kwargs): + calls.append((list(args), dict(kwargs))) + stdout = "{}" if "/cancel" not in " ".join(args) else "" + return SimpleNamespace(returncode=0, stdout=stdout, stderr="") + + monkeypatch.setattr(module.subprocess, "run", success) + assert module._run_json(["gh", "api", "repos/owner/repo"]) == {} + module._cancel_run("owner/repo", 123) + assert len(calls) == 2 + assert all(call_kwargs.get("timeout") == module.API_TIMEOUT_SECONDS for _, call_kwargs in calls) + + +def test_workflow_covers_ready_transition_and_never_expands_head_ref_inside_shell() -> None: + """Ready events coalesce duplicates and untrusted refs cross the shell via env only.""" + text = WORKFLOW.read_text(encoding="utf-8") + trigger_line = next(line.strip() for line in text.splitlines() if line.strip().startswith("types:")) + for event_name in ("opened", "synchronize", "reopened", "ready_for_review"): + assert event_name in trigger_line + assert "EXPECTED_HEAD_REF: ${{ github.event.pull_request.head.ref }}" in text + run_block = text.split("run: |", 1)[1] + assert '--expected-head-ref "$EXPECTED_HEAD_REF"' in run_block + assert 'github.event.pull_request.head.ref' not in run_block