diff --git a/.github/workflows/pr-review-merge-scheduler.yml b/.github/workflows/pr-review-merge-scheduler.yml index d98a72e605..6e8e01a469 100644 --- a/.github/workflows/pr-review-merge-scheduler.yml +++ b/.github/workflows/pr-review-merge-scheduler.yml @@ -87,8 +87,17 @@ concurrency: 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.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'repository_dispatch' }} + github.ref }}-${{ + github.event_name == 'pull_request_target' && github.event.action == 'closed' && format('head-{0}-closed', github.event.pull_request.head.sha) || + github.event_name == 'pull_request_target' && format('head-{0}', github.event.pull_request.head.sha) || + github.event_name == 'pull_request_review' && format('head-{0}', github.event.pull_request.head.sha) || + github.event_name == 'repository_dispatch' && github.event.client_payload.pr_head_sha != '' && format('head-{0}', github.event.client_payload.pr_head_sha) || + 'no-head' }} + # GitHub's native FIFO queue preserves duplicate admissions for one exact + # head. A successor head receives a distinct group, then the bounded cleanup + # job below retires only revalidated predecessor runs. Close uses a distinct + # suffix so it can retire an active scan for the same final head. + queue: max # Scorecard Token-Permissions (alert #9): declare a least-privilege default at # the workflow level. The scan-pr-queue job that actually needs write access @@ -98,6 +107,136 @@ permissions: contents: read jobs: + cancel-superseded-pr-runs: + if: >- + github.event_name == 'pull_request_target' && + ( + github.event.action == 'synchronize' || + github.event.action == 'closed' + ) + runs-on: ubuntu-24.04 + permissions: + actions: write + contents: read + pull-requests: read + env: + GH_TOKEN: ${{ github.token }} + TARGET_REPOSITORY: ${{ github.repository }} + TARGET_PR_NUMBER: ${{ github.event.pull_request.number }} + TARGET_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + TARGET_ACTION: ${{ github.event.action }} + steps: + - name: Cancel revalidated predecessor scheduler runs + shell: bash + run: | + set -euo pipefail + + if ! [[ "$TARGET_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || + ! [[ "$TARGET_PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || + ! [[ "$TARGET_PR_HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "::error::Superseded-run cleanup rejected malformed target identity." + exit 1 + fi + + live_target_matches() { + local live_pull live_repository live_number live_state live_head_sha + live_pull="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${TARGET_PR_NUMBER}")" + live_repository="$(jq -r '.base.repo.full_name // empty' <<<"$live_pull")" + live_number="$(jq -r '.number // 0' <<<"$live_pull")" + live_state="$(jq -r '.state // empty' <<<"$live_pull")" + live_head_sha="$(jq -r '.head.sha // empty' <<<"$live_pull")" + [ "$live_repository" = "$TARGET_REPOSITORY" ] && + [ "$live_number" = "$TARGET_PR_NUMBER" ] && + [ "$live_head_sha" = "$TARGET_PR_HEAD_SHA" ] && { + [ "$TARGET_ACTION:$live_state" = "synchronize:open" ] || + [ "$TARGET_ACTION:$live_state" = "closed:closed" ] + } + } + + if ! live_target_matches; then + echo "Superseded-run cleanup skipped because the event no longer matches the live pull request." + exit 0 + fi + + workflow_runs="$( + gh api --paginate --slurp \ + "repos/${TARGET_REPOSITORY}/actions/workflows/pr-review-merge-scheduler.yml/runs?event=pull_request_target&per_page=100" + )" + mapfile -t superseded_run_ids < <( + jq -r \ + --argjson pr_number "$TARGET_PR_NUMBER" \ + --argjson current_run_id "$GITHUB_RUN_ID" \ + --arg target_head "$TARGET_PR_HEAD_SHA" \ + --arg target_action "$TARGET_ACTION" \ + ' + .[] | .workflow_runs[]? + | select(.id != $current_run_id) + | select( + .status == "requested" or + .status == "waiting" or + .status == "pending" or + .status == "queued" or + .status == "in_progress" + ) + | select( + any( + .pull_requests[]?; + (.head.sha // "") as $run_pr_head + | .number == $pr_number + | select( + $target_action == "closed" or + ( + ($run_pr_head | test("^[0-9a-fA-F]{40}$")) and + $run_pr_head != $target_head + ) + ) + ) + ) + | .id + ' <<<"$workflow_runs" + ) + + for run_id in "${superseded_run_ids[@]}"; do + if ! live_target_matches; then + echo "Superseded-run cleanup stopped because the target changed before cancellation." + exit 0 + fi + if gh api -X POST \ + "repos/${TARGET_REPOSITORY}/actions/runs/${run_id}/force-cancel" \ + >/dev/null 2>&1; then + cancellation_verified=false + for attempt in 1 2 3 4 5 6; do + IFS=$'\t' read -r run_status run_conclusion < <( + gh api "repos/${TARGET_REPOSITORY}/actions/runs/${run_id}" \ + --jq '[.status // "", .conclusion // ""] | @tsv' + ) + if [ "$run_status" = "completed" ] && + [ "$run_conclusion" = "cancelled" ]; then + cancellation_verified=true + break + fi + if [ "$attempt" -lt 6 ]; then + sleep 1 + fi + done + if [ "$cancellation_verified" != "true" ]; then + echo "::error::Scheduler run $run_id did not reach completed/cancelled after accepted force-cancel." + exit 1 + fi + echo "Verified cancelled scheduler run $run_id." + continue + fi + IFS=$'\t' read -r run_status run_conclusion < <( + gh api "repos/${TARGET_REPOSITORY}/actions/runs/${run_id}" \ + --jq '[.status // "", .conclusion // ""] | @tsv' + ) + if [ "$run_status" = "completed" ]; then + continue + fi + echo "::error::Could not force-cancel nonterminal scheduler run $run_id." + exit 1 + done + scan-pr-queue: # repository_dispatch review runs do not reliably carry pull_requests metadata. # Without this guard, one completed central review can wake a repo-wide scan. @@ -212,6 +351,7 @@ jobs: GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token || github.token }} TARGET_REPOSITORY_INPUT: ${{ github.event.client_payload.target_repository || '' }} TARGET_PR_NUMBER: ${{ github.event.client_payload.pr_number || '' }} + TARGET_HEAD_SHA_INPUT: ${{ github.event.client_payload.pr_head_sha || '' }} TARGET_BASE_BRANCH_INPUT: ${{ github.event.client_payload.base_branch || '' }} ALLOWED_TARGET_REPOSITORIES: ${{ vars.OPENCODE_REPOSITORY_DISPATCH_TARGETS }} run: | @@ -231,8 +371,9 @@ jobs: exit 1 fi if ! [[ "$TARGET_REPOSITORY_INPUT" =~ ^ContextualWisdomLab/[A-Za-z0-9_.-]+$ ]] || - ! [[ "$TARGET_PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then - printf '::error::Targeted scheduler dispatch rejected an invalid repository or pull request number. target=%s pr=%s\n' "${TARGET_REPOSITORY_INPUT:-}" "${TARGET_PR_NUMBER:-}" + ! [[ "$TARGET_PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || + ! [[ "$TARGET_HEAD_SHA_INPUT" =~ ^[0-9a-fA-F]{40}$ ]]; then + printf '::error::Targeted scheduler dispatch rejected invalid repository, pull request, or head identity. target=%s pr=%s head=%s\n' "${TARGET_REPOSITORY_INPUT:-}" "${TARGET_PR_NUMBER:-}" "${TARGET_HEAD_SHA_INPUT:-}" exit 1 fi @@ -274,6 +415,10 @@ jobs: printf '::error::Targeted scheduler dispatch base branch does not match the live PR. supplied=%s live=%s\n' "$TARGET_BASE_BRANCH_INPUT" "$live_base_branch" exit 1 fi + if [ "${TARGET_HEAD_SHA_INPUT,,}" != "${live_head_sha,,}" ]; then + printf '::error::Targeted scheduler dispatch head does not match the live PR. supplied=%s live=%s\n' "$TARGET_HEAD_SHA_INPUT" "$live_head_sha" + exit 1 + fi { printf 'repository=%s\n' "$TARGET_REPOSITORY_INPUT" diff --git a/CHANGELOG.d/20260920-scheduler-cancellation-verification.md b/CHANGELOG.d/20260920-scheduler-cancellation-verification.md new file mode 100644 index 0000000000..c6efbdc28f --- /dev/null +++ b/CHANGELOG.d/20260920-scheduler-cancellation-verification.md @@ -0,0 +1,3 @@ +### Scheduler cleanup proves cancellation before completion + +The required-review merge scheduler now revalidates the live pull request immediately before each superseded-run mutation and treats an accepted force-cancel request as incomplete until GitHub reports the run as `completed/cancelled`. Bounded executable regressions cover a concurrent head advance, a cancellation that never terminates, and a successful asynchronous cancellation. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index c617e3ad73..2b071cba41 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -7,6 +7,12 @@ 이 문서는 제품·기술·운영 Gap을 현재 문서와 현재 GitHub 상태에 묶어 두는 기준선이다. 새 작업은 먼저 이 문서의 Gap ID를 PR 설명과 테스트 증거에 연결하고, PR의 정확한 exact HEAD·Checks·리뷰를 다시 수집한 뒤 구현한다. 표의 상태는 작성 시점의 관측값이므로, 병합 판단에는 재사용하지 않는다. 이 인벤토리는 스냅샷이며 merge authorization이 아니다. +### 2026-09-20 scheduler concurrency authority delta + +| Gap ID | 상태 | exact-head evidence | causal owner / next gate | +|---|---|---|---| +| CONTROL-SCHEDULER-PENDING-PRESERVATION-01 | **Proposed / source repaired on stacked `.github#2289`; protected-main integration pending** | GitHub's native concurrency contract replaces an existing pending run under default `queue: single`. [RED `17be694a`](https://github.com/ContextualWisdomLab/.github/commit/17be694aaff421f8b48aa24250aeedd8eb2d696c) established the missing pending-preservation contract; [GREEN `ce3a4d9f`](https://github.com/ContextualWisdomLab/.github/commit/ce3a4d9f0b93584de85a076fb314840d262d3b68) adopted `queue: max`. Concurrent RED `e2647c5b` then exposed tests without production exact-head cleanup; production repair [`739a34c0`](https://github.com/ContextualWisdomLab/.github/commit/739a34c07d56f331767e6200d5172c648d0e27cf), authority RED [`648674c9`](https://github.com/ContextualWisdomLab/.github/commit/648674c92f1016f5cf8420f75d9d8679ff2dc665), and GREEN [`406c7178`](https://github.com/ContextualWisdomLab/.github/commit/406c7178974c3b887694a229a59c66a02e0810dd) bind cleanup to associated PR `head.sha`, not `pull_request_target` run `.head_sha`. Ordinary two-parent stack [`873a35f4`](https://github.com/ContextualWisdomLab/.github/commit/873a35f45c9c3b25f72797b25b6bbe98051d0f3d) preserves #2289 and canonical #2283 histories. Exact remote verification: static contracts 14/14, jq synchronize/closed fixtures 2/2, Python compile 2/2, YAML parse 1/1, cleanup Bash syntax 1/1. Cancellation-verification RED at `406c7178` failed all three executable regressions (post-selection live-head revalidation, accepted-but-nonterminal cancellation, and asynchronous terminal proof); GREEN [`8067fb65`](https://github.com/ContextualWisdomLab/.github/commit/8067fb65daa6b01265f3c0e011ffd83b7f439273), tree `004a1713`, passes those regressions 3/3 and the affected scheduler contracts 6/6 under both default and `GITHUB_ACTIONS=true` environments. Targeted cross-repository dispatch still accepted a caller-supplied `pr_head_sha` for concurrency without comparing it to the live pull request; executable RED [`2a81604d`](https://github.com/ContextualWisdomLab/.github/commit/2a81604d1be7b015d3675fa847466edb8f5101f7) rejects a stale value, and GREEN [`4767b64b`](https://github.com/ContextualWisdomLab/.github/commit/4767b64be35455fc7b36d2a656f014929343f456) requires a 40-hex identity and exact case-insensitive live-head match before scheduling. Hosted exact-head Checks remain pending. | Central `.github` owns scheduler concurrency; #2289 is stacked on #2283 rather than acting as a divergent writer. Same-head events retain native FIFO admission, successor heads enter a distinct group, and metadata-only cleanup revalidates live repository/PR/head before retiring predecessor runs. Completion requires prerequisite #2283 integration, fresh exact-head hosted Checks, no actionable review, qualifying independent approval, and ordinary protected-main merge. | + ### 2026-09-13 current-head incident delta | Gap ID | 상태 | exact-head evidence | causal owner / next gate | diff --git a/tests/test_close_empty_pr_queue_pressure.py b/tests/test_close_empty_pr_queue_pressure.py index 6da88f63f1..d8ec8547f9 100644 --- a/tests/test_close_empty_pr_queue_pressure.py +++ b/tests/test_close_empty_pr_queue_pressure.py @@ -29,8 +29,21 @@ def test_closed_pull_request_does_not_allocate_a_noop_runner( assert "closed" in workflow assert "github.event.pull_request.number" in concurrency - assert "github.event.pull_request.head.sha" not in concurrency - assert re.search(r"(?m)^[ \t]+cancel-in-progress:[ \t]+\S", concurrency) + if filename == "pr-review-merge-scheduler.yml": + assert "github.event.pull_request.head.sha" in concurrency + assert "queue: max" in concurrency + assert "cancel-in-progress:" not in concurrency + cleanup_job = workflow.split( + " cancel-superseded-pr-runs:", 1 + )[1].split(" scan-pr-queue:", 1)[0] + assert "actions: write" in cleanup_job + assert "actions/checkout" not in cleanup_job + assert "github.event.action == 'closed'" in cleanup_job + assert "TARGET_PR_NUMBER" in cleanup_job + assert "TARGET_PR_HEAD_SHA" in cleanup_job + else: + assert "github.event.pull_request.head.sha" not in concurrency + assert re.search(r"(?m)^[ \t]+cancel-in-progress:[ \t]+\S", concurrency) assert "cancel-closed-pr-runs:" not in workflow assert "github.event.action != 'closed'" in workflow assert evidence_job in workflow diff --git a/tests/test_current_head_coalescer_self_cancellation.py b/tests/test_current_head_coalescer_self_cancellation.py index e49193d4df..59662174c0 100644 --- a/tests/test_current_head_coalescer_self_cancellation.py +++ b/tests/test_current_head_coalescer_self_cancellation.py @@ -10,7 +10,7 @@ def test_current_head_coalescer_shares_pr_scoped_scheduler_admission() -> None: - """The integrated step reuses PR-scoped scheduler admission and its runner.""" + """Exact-head admission and metadata cleanup share the scheduler boundary.""" workflow_text = WORKFLOW_PATH.read_text(encoding="utf-8") coalescer = workflow_text.split("\n scan-pr-queue:\n", 1)[1] concurrency_block = workflow_text.split("\nconcurrency:\n", 1)[1].split( @@ -24,11 +24,19 @@ def test_current_head_coalescer_shares_pr_scoped_scheduler_admission() -> None: assert "Retire redundant queued exact-head runs" in coalescer assert "github.repository == 'ContextualWisdomLab/.github'" in coalescer - assert "github.event.pull_request.head.sha" not in concurrency_block + assert "github.event.pull_request.head.sha" in concurrency_block assert "github.event.pull_request.number" in concurrency_block - assert any( - line.startswith("cancel-in-progress:") - and "github.event_name == 'pull_request_target'" in line - for line in active_lines - ) - assert "queue: max" not in workflow_text + assert "github.event.client_payload.pr_head_sha" in concurrency_block + assert "queue: max" in active_lines + assert not any(line.startswith("cancel-in-progress:") for line in active_lines) + + cleanup = workflow_text.split( + "\n cancel-superseded-pr-runs:\n", 1 + )[1].split("\n scan-pr-queue:\n", 1)[0] + assert "actions: write" in cleanup + assert "actions/checkout" not in cleanup + assert "TARGET_REPOSITORY:" in cleanup + assert "TARGET_PR_NUMBER:" in cleanup + assert "TARGET_PR_HEAD_SHA:" in cleanup + assert "live_target_matches" in cleanup + assert 'actions/runs/${run_id}/force-cancel' in cleanup diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 5a41cb7cdc..4351b42a62 100644 Binary files a/tests/test_opencode_agent_contract.py and b/tests/test_opencode_agent_contract.py differ diff --git a/tests/test_opencode_workflow_shell_syntax.py b/tests/test_opencode_workflow_shell_syntax.py index b0a672b1a2..487fb5f31a 100644 --- a/tests/test_opencode_workflow_shell_syntax.py +++ b/tests/test_opencode_workflow_shell_syntax.py @@ -208,6 +208,7 @@ def test_merge_scheduler_targeted_dispatch_validates_live_exact_pr(tmp_path): "DEFAULT_BRANCH": "main", "TARGET_REPOSITORY_INPUT": "ContextualWisdomLab/naruon", "TARGET_PR_NUMBER": "1179", + "TARGET_HEAD_SHA_INPUT": "4afd4af7ad343660356791873d940aa2846f40c2", "TARGET_BASE_BRANCH_INPUT": "develop", "ALLOWED_TARGET_REPOSITORIES": ( "ContextualWisdomLab/.github, ContextualWisdomLab/naruon" @@ -248,6 +249,23 @@ def test_merge_scheduler_targeted_dispatch_validates_live_exact_pr(tmp_path): assert "absent from the configured exact allowlist" in rejected.stdout assert not output.exists() + stale_head_env = { + **env, + "TARGET_HEAD_SHA_INPUT": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + } + stale_head = subprocess.run( + [bash], + input=script, + text=True, + capture_output=True, + check=False, + env=stale_head_env, + ) + + assert stale_head.returncode == 1 + assert "head does not match the live PR" in stale_head.stdout + assert not output.exists() + output.unlink(missing_ok=True) cross_repo_pull = { **pull, diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index 95f4b14279..97ca73fa52 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -226,12 +226,27 @@ def test_merge_scheduler_uses_native_auto_merge_after_required_checks() -> None: assert "github.event_name == 'repository_dispatch' && github.run_id" not in ( concurrency_contract ) - # Anchored, not a substring: this workflow's value is an expression rather - # than a constant, so it cannot use the boolean helper, but a commented-out - # setting must not satisfy it either. - assert re.search(r"(?m)^[ \t]+cancel-in-progress:[ \t]+\$\{\{", concurrency_contract) + # The workflow-level queue serializes only duplicate admissions for one + # exact head. The scan job has the PR-stable cancellation boundary that + # retires a predecessor head after the successor has been admitted. + assert "queue: max" in concurrency_contract + assert "cancel-in-progress:" not in concurrency_contract + assert "github.event.pull_request.head.sha" in concurrency_contract + assert "github.event.client_payload.pr_head_sha" in concurrency_contract assert "github.event_name == 'repository_dispatch'" in concurrency_contract + cleanup_job = workflow.split(" cancel-superseded-pr-runs:", 1)[1].split( + " scan-pr-queue:", 1 + )[0] + assert "actions: write" in cleanup_job + assert "actions/checkout" not in cleanup_job + assert "github.event.pull_request.number" in cleanup_job + assert "github.event.pull_request.head.sha" in cleanup_job + assert ".pull_requests[]?" in cleanup_job + assert ".head.sha" in cleanup_job + assert ".head_sha != $target_head" not in cleanup_job + assert "force-cancel" in cleanup_job + def test_merge_scheduler_provides_same_repository_dispatch_credential() -> None: """Guard the runner-token dispatch credential for central review workflows. @@ -1102,9 +1117,27 @@ def test_pull_request_close_events_cancel_superseded_runs_without_heavy_jobs() - assert "actions: write" in cleanup_job assert "actions/checkout" not in cleanup_job assert "cleanup skipped" not in cleanup_job + elif filename == "pr-review-merge-scheduler.yml": + # Close skips the scan job. Exact-head admission preserves same-head + # work, while the job-level PR group retires a predecessor head. + assert "cancel-closed-pr-runs:" not in workflow + concurrency_contract = workflow.split("concurrency:", 1)[1].split( + "permissions:", 1 + )[0] + assert "github.event.pull_request.number" in concurrency_contract + assert "github.event.pull_request.head.sha" in concurrency_contract + assert "queue: max" in concurrency_contract + assert "cancel-in-progress:" not in concurrency_contract + assert "cancel-superseded-pr-runs:" in workflow + cleanup_job = workflow.split( + " cancel-superseded-pr-runs:", 1 + )[1].split(" scan-pr-queue:", 1)[0] + assert "actions: write" in cleanup_job + assert "github.event.pull_request.number" in cleanup_job + assert "github.event.pull_request.head.sha" in cleanup_job + assert "actions/checkout" not in cleanup_job elif filename in { "codeql-pr.yml", - "pr-review-merge-scheduler.yml", "python-security.yml", "sast-semgrep.yml", "security-scan.yml", @@ -1488,6 +1521,185 @@ def test_merge_scheduler_has_no_workflow_run_trigger() -> None: assert "workflow_run:" not in workflow.split("workflow_call:", 1)[0] +def test_review_events_preserve_same_head_and_retire_predecessor_head() -> None: + """Serialize one head without making a new head wait behind stale work. + + The workflow-level exact-head queue preserves all admissions for a head. + A different head enters another workflow group and its bounded metadata + cleanup retires only the predecessor scan after revalidating repository, + pull request, and head identity. + """ + workflow = workflow_text("pr-review-merge-scheduler.yml") + concurrency = workflow.split("concurrency:", 1)[1].split("permissions:", 1)[0] + + assert "queue: max" in concurrency + assert "cancel-in-progress:" not in concurrency + assert "github.event.pull_request.head.sha" in concurrency + assert "github.event.client_payload.pr_head_sha" in concurrency + assert "format('pr-{0}', github.event.pull_request.number)" in concurrency + + cleanup_job = workflow.split(" cancel-superseded-pr-runs:", 1)[1].split( + " scan-pr-queue:", 1 + )[0] + assert "github.event.action == 'synchronize'" in cleanup_job + assert "github.event.action == 'closed'" in cleanup_job + assert "actions: write" in cleanup_job + assert "actions/checkout" not in cleanup_job + assert "TARGET_REPOSITORY" in cleanup_job + assert "TARGET_PR_NUMBER" in cleanup_job + assert "TARGET_PR_HEAD_SHA" in cleanup_job + assert "live_target_matches" in cleanup_job + assert "pull_requests" in cleanup_job + assert "force-cancel" in cleanup_job + + +def _run_merge_scheduler_cleanup( + tmp_path: Path, + pull_states: list[dict[str, object]], + run_states: list[dict[str, object]], +) -> tuple[subprocess.CompletedProcess[str], str]: + """Execute the production scheduler cleanup against a stateful fake ``gh``.""" + if shutil.which("jq") is None: + pytest.skip("jq is required to execute the production cleanup") + step = workflow_step( + workflow_text("pr-review-merge-scheduler.yml"), + "Cancel revalidated predecessor scheduler runs", + ) + run_block = step.split(" run: |\n", 1)[1].split( + "\n scan-pr-queue:", 1 + )[0] + script = textwrap.dedent(run_block) + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + calls = tmp_path / "calls" + pulls = tmp_path / "pulls" + runs = tmp_path / "runs" + pulls.write_text( + "\n".join(json.dumps(state) for state in pull_states) + "\n", + encoding="utf-8", + ) + runs.write_text( + "\n".join(json.dumps(state) for state in run_states) + "\n", + encoding="utf-8", + ) + fake_gh = fake_bin / "gh" + fake_gh.write_text( + """#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >>"$FAKE_CALLS" +next_line() { + local source="$1" + local count_file="${source}.count" + local count=0 + [[ ! -f "$count_file" ]] || count="$(cat "$count_file")" + count=$((count + 1)) + printf '%s' "$count" >"$count_file" + sed -n "${count}p" "$source" +} +if [[ "$*" == *"/pulls/7"* ]]; then + next_line "$FAKE_PULLS" + exit 0 +fi +if [[ "$*" == *"actions/workflows/pr-review-merge-scheduler.yml/runs"* ]]; then + printf '%s\n' '[{"workflow_runs":[{"id":100,"status":"queued","pull_requests":[{"number":7,"head":{"sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}]}]}]' + exit 0 +fi +if [[ "$*" == *"actions/runs/100/force-cancel"* ]]; then + exit 0 +fi +if [[ "$*" == *"actions/runs/100"* ]]; then + state="$(next_line "$FAKE_RUNS")" + if [[ "$*" == *"--jq"* ]]; then + jq -r '[.status // "", .conclusion // ""] | @tsv' <<<"$state" + else + printf '%s\n' "$state" + fi + exit 0 +fi +exit 1 +""", + encoding="utf-8", + ) + fake_gh.chmod(0o755) + result = subprocess.run( # noqa: S603, S607 + ["bash", "-c", script], + env={ + **os.environ, + "PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}", + "FAKE_CALLS": str(calls), + "FAKE_PULLS": str(pulls), + "FAKE_RUNS": str(runs), + "GH_TOKEN": "synthetic-actions-token", + "GITHUB_RUN_ID": "999", + "TARGET_REPOSITORY": "owner/repo", + "TARGET_PR_NUMBER": "7", + "TARGET_PR_HEAD_SHA": "a" * 40, + "TARGET_ACTION": "synchronize", + }, + capture_output=True, + text=True, + check=False, + ) + return result, calls.read_text(encoding="utf-8") + + +def _live_scheduler_pull(*, head_sha: str = "a" * 40) -> dict[str, object]: + """Build one live pull-request response for scheduler cleanup evidence.""" + return { + "base": {"repo": {"full_name": "owner/repo"}}, + "number": 7, + "state": "open", + "head": {"sha": head_sha}, + } + + +def test_scheduler_cleanup_revalidates_target_after_run_selection(tmp_path: Path) -> None: + """A concurrent head advance after selection must prevent cancellation.""" + result, calls = _run_merge_scheduler_cleanup( + tmp_path, + [ + _live_scheduler_pull(), + _live_scheduler_pull(head_sha="c" * 40), + ], + [{"status": "completed", "conclusion": "cancelled"}], + ) + + assert result.returncode == 0, result.stderr + assert calls.count("/pulls/7") == 2 + assert "/actions/runs/100/force-cancel" not in calls + + +def test_scheduler_cleanup_fails_when_accepted_cancel_never_finishes( + tmp_path: Path, +) -> None: + """A successful POST is not proof that the run reached terminal cancellation.""" + result, calls = _run_merge_scheduler_cleanup( + tmp_path, + [_live_scheduler_pull()] * 8, + [{"status": "in_progress", "conclusion": None}] * 6, + ) + + assert result.returncode == 1 + assert calls.count("actions/runs/100 --jq") == 6 + assert "did not reach completed/cancelled" in result.stdout + + +def test_scheduler_cleanup_verifies_accepted_cancelled_state(tmp_path: Path) -> None: + """Finish only after GitHub reports the accepted cancellation as terminal.""" + result, calls = _run_merge_scheduler_cleanup( + tmp_path, + [_live_scheduler_pull()] * 8, + [ + {"status": "in_progress", "conclusion": None}, + {"status": "completed", "conclusion": "cancelled"}, + ], + ) + + assert result.returncode == 0, result.stderr + assert calls.count("actions/runs/100 --jq") == 2 + assert "Verified cancelled scheduler run 100." in result.stdout + + def test_review_events_can_dispatch_after_threads_are_resolved() -> None: """Let the scheduler dispatch OpenCode when a review event clears its last blocker.""" workflow = workflow_text("pr-review-merge-scheduler.yml")