diff --git a/.github/workflows/pr-review-merge-scheduler.yml b/.github/workflows/pr-review-merge-scheduler.yml index d32918cf45..8025e748d2 100644 --- a/.github/workflows/pr-review-merge-scheduler.yml +++ b/.github/workflows/pr-review-merge-scheduler.yml @@ -88,7 +88,24 @@ concurrency: 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' }} + cancel-in-progress: >- + ${{ + github.event_name == 'pull_request_target' || + github.event_name == 'repository_dispatch' || + ( + github.event_name == 'pull_request_review' && + ( + github.event.action == 'dismissed' || + ( + github.event.action == 'submitted' && + ( + github.event.review.state == 'approved' || + github.event.review.state == 'changes_requested' + ) + ) + ) + ) + }} # Scorecard Token-Permissions (alert #9): declare a least-privilege default at # the workflow level. The scan-pr-queue job that actually needs write access @@ -109,6 +126,17 @@ jobs: ( github.event_name != 'repository_dispatch' || github.event.client_payload.org_sweep != true + ) && + ( + github.event_name != 'pull_request_review' || + github.event.action == 'dismissed' || + ( + github.event.action == 'submitted' && + ( + github.event.review.state == 'approved' || + github.event.review.state == 'changes_requested' + ) + ) ) runs-on: ubuntu-24.04 # Bound scan-pr-queue to a wall-clock ceiling well short of GitHub's @@ -377,112 +405,7 @@ jobs: - name: Self-test scheduler run: python3 scripts/ci/pr_review_merge_scheduler.py --self-test - - name: Wait for approved OpenCode publication run to finish - id: review_followup - if: >- - github.event_name == 'pull_request_review' - && github.event.action == 'submitted' - && github.event.review.state == 'approved' - && ( - github.event.review.user.login == 'opencode-agent' - || github.event.review.user.login == 'opencode-agent[bot]' - ) - env: - GH_TOKEN: ${{ github.token }} - REVIEW_HEAD_SHA: ${{ github.event.review.commit_id }} - REVIEW_PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set -euo pipefail - echo "proceed=true" >>"$GITHUB_OUTPUT" - - if [[ ! "${REVIEW_HEAD_SHA:-}" =~ ^[0-9a-fA-F]{40}$ ]]; then - printf '::warning::Post-approval direct-merge follow-up skipped because the OpenCode App review did not carry a 40-character commit SHA. value=%s.\n' "${REVIEW_HEAD_SHA:-missing}" - echo "proceed=false" >>"$GITHUB_OUTPUT" - exit 0 - fi - - pull_error_file="$(mktemp)" - if ! pull_json="$( - gh api "repos/${GITHUB_REPOSITORY}/pulls/${REVIEW_PR_NUMBER}" \ - 2>"$pull_error_file" - )"; then - pull_reason="$(tail -n 1 "$pull_error_file" 2>/dev/null || true)" - [ -n "$pull_reason" ] || pull_reason="GitHub pull-request lookup failed without an error body" - rm -f "$pull_error_file" - printf '::warning::Post-approval direct-merge follow-up skipped because the live pull request snapshot could not be read. PR=%s review_head=%s reason=%s.\n' "$REVIEW_PR_NUMBER" "$REVIEW_HEAD_SHA" "$pull_reason" - echo "proceed=false" >>"$GITHUB_OUTPUT" - exit 0 - fi - rm -f "$pull_error_file" - live_state="$(jq -r '.state // "unknown"' <<<"$pull_json")" - live_head="$(jq -r '.head.sha // empty' <<<"$pull_json")" - if [ "$live_state" != "open" ] || [ "$live_head" != "$REVIEW_HEAD_SHA" ]; then - printf '::notice::Post-approval direct-merge follow-up skipped because the pull request snapshot changed. PR=%s review_head=%s live_head=%s state=%s.\n' "$REVIEW_PR_NUMBER" "$REVIEW_HEAD_SHA" "${live_head:-missing}" "$live_state" - echo "proceed=false" >>"$GITHUB_OUTPUT" - exit 0 - fi - - opencode_state="missing" - opencode_reason="no opencode-review check run was visible for the approved head" - for check_attempt in 1 2 3 4 5 6 7 8; do - check_error_file="$(mktemp)" - if checks_json="$( - gh api --paginate --slurp \ - "repos/${GITHUB_REPOSITORY}/commits/${REVIEW_HEAD_SHA}/check-runs?per_page=100" \ - 2>"$check_error_file" - )"; then - opencode_state="$( - jq -r ' - [.[].check_runs[] - | select(.name == "opencode-review") - | select(.app.slug == "github-actions")] as $runs - | if ($runs | length) == 0 then "missing" - elif any($runs[]; .status != "completed") then "running" - elif any($runs[]; .conclusion != "success") then - "failed:" + ([$runs[] | (.conclusion // "missing")] | unique | join(",")) - else "success" - end - ' <<<"$checks_json" - )" - case "$opencode_state" in - success) - printf 'Approved OpenCode publication run completed successfully for PR %s at %s after check attempt %s.\n' "$REVIEW_PR_NUMBER" "$REVIEW_HEAD_SHA" "$check_attempt" - rm -f "$check_error_file" - break - ;; - failed:*) - opencode_reason="opencode-review completed without success (${opencode_state#failed:})" - rm -f "$check_error_file" - break - ;; - running) - opencode_reason="opencode-review is still running for the approved head" - ;; - *) - opencode_reason="no opencode-review check run was visible for the approved head" - ;; - esac - else - opencode_state="api-error" - opencode_reason="$(tail -n 1 "$check_error_file" 2>/dev/null || true)" - [ -n "$opencode_reason" ] || opencode_reason="GitHub check-runs lookup failed without an error body" - fi - rm -f "$check_error_file" - - if [ "$check_attempt" -lt 8 ]; then - check_delay="$((check_attempt * 2))" - printf 'Approved OpenCode publication run is not complete for PR %s at %s after check attempt %s: %s. Retrying in %ss.\n' "$REVIEW_PR_NUMBER" "$REVIEW_HEAD_SHA" "$check_attempt" "$opencode_reason" "$check_delay" - sleep "$check_delay" - fi - done - - if [ "$opencode_state" != "success" ]; then - printf '::warning::Post-approval direct-merge follow-up skipped because the approved OpenCode publication run did not complete successfully. PR=%s head=%s state=%s reason=%s. Native events and the explicit org-sweep recovery remain authoritative.\n' "$REVIEW_PR_NUMBER" "$REVIEW_HEAD_SHA" "$opencode_state" "$opencode_reason" - echo "proceed=false" >>"$GITHUB_OUTPUT" - fi - - name: Inspect PR review and merge queue - if: steps.review_followup.outputs.proceed != 'false' env: GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token || github.token }} TARGET_REPOSITORY: ${{ steps.targeted_dispatch.outputs.repository }} diff --git a/CHANGELOG.md b/CHANGELOG.md index bf192f6a9e..7ffcc38763 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,16 @@ - Raised `hourly-review-repair.yml`'s discovery ceiling from 50 to 200 while rotating deterministic 50-PR deep-inspection windows by hourly run number. The scheduler hydrates only the selected window and stops immediately after its single dispatch, preserving access to newer PRs without quadrupling expensive review/check/comment work. See `docs/doctoring/hourly-review-repair-single-file-consolidation.md`'s 2026-09-03 follow-up. ## [Unreleased] +- Stop `pull_request_review: submitted` events with state `commented` at the + merge scheduler's job-admission boundary, before a hosted runner is + requested. `approved`, `changes_requested`, and `dismissed` review + transitions retain their existing exact-PR scheduler path and permissions; + COMMENTED submissions also no longer cancel an already-running actionable + review transition through workflow-level concurrency. Actionable review + events now enter the scheduler core directly instead of holding the runner + for up to 56 seconds while polling OpenCode publication. OpenCode's existing + post-publication scheduler call and GitHub's native auto-merge continuation + remain the merge owners. - Include merge-scheduler entrypoint, core, and regression-test changes in the existing runtime-quality workflow's trigger and suite selector. Scheduler workflow edits retain queue checks and also select the full review-repair diff --git a/docs/doctoring/pr-review-merge-scheduler-trigger-audit-20260903.md b/docs/doctoring/pr-review-merge-scheduler-trigger-audit-20260903.md index b81778cfef..b1d97d273c 100644 --- a/docs/doctoring/pr-review-merge-scheduler-trigger-audit-20260903.md +++ b/docs/doctoring/pr-review-merge-scheduler-trigger-audit-20260903.md @@ -1,5 +1,32 @@ # Doctoring record: pr-review-merge-scheduler.yml's "fires at every step" pattern is by-design, not a bug (2026-09-03) +> **2026-09-07 correction.** An actionable `pull_request_review` event now runs +> the scheduler core immediately. The removed pre-core step polled the +> `opencode-review` check up to eight times and could hold a runner for 56 +> seconds, even though `opencode-review-dispatch.yml` already performs the +> current-head approval check and invokes the scheduler with merge authority +> after publication. The review-event path keeps its existing admission, +> credentials, and fail-closed core checks; it does not gain merge authority. +> Once auto-merge is armed, GitHub's native required-check handling remains the +> terminal continuation. The old fallback message naming an org-wide sweep was +> removed with the obsolete wait step; no replacement workflow was added. + +> **2026-09-05 correction.** The broad claim below that every submitted review +> is an actionable approval-state change was incomplete. GitHub emits +> `pull_request_review: submitted` for `COMMENTED` reviews, which do not create +> an `APPROVED` or `CHANGES_REQUESTED` state. On PR #1885, CodeRabbit submitted +> `COMMENTED` reviews at 03:08:52Z, 04:27:31Z, and 05:30:37Z; the central +> scheduler admitted runner-backed runs 33941045179, 33944606701, and +> 33947394894 within seconds. The scheduler still needs the review trigger for +> `APPROVED`, `CHANGES_REQUESTED`, and `dismissed`, but `COMMENTED` is now +> rejected by the `scan-pr-queue` job-level `if` before runner acquisition. +> The executable truth-table contract is +> `tests/test_merge_scheduler_review_event_admission.py`. This correction does +> not reinterpret a bot comment as formal review evidence. It preserves the +> exact-PR concurrency group while narrowing cancellation so a COMMENTED +> submission cannot cancel an already-running APPROVED, CHANGES_REQUESTED, or +> dismissed transition; scheduler permissions remain unchanged. + - **Date:** 2026-09-03 - **Subject:** the user directly observed the scheduler workflow firing repeatedly ("왜 각 모든 단계마다 Trigger 되고 있죠?") after live evidence surfaced today of severe org-wide Actions thrashing (near-zero completion diff --git a/tests/test_current_head_coalescer_self_cancellation.py b/tests/test_current_head_coalescer_self_cancellation.py index e49193d4df..45ad4b469e 100644 --- a/tests/test_current_head_coalescer_self_cancellation.py +++ b/tests/test_current_head_coalescer_self_cancellation.py @@ -26,9 +26,10 @@ def test_current_head_coalescer_shares_pr_scoped_scheduler_admission() -> None: assert "github.repository == 'ContextualWisdomLab/.github'" in coalescer assert "github.event.pull_request.head.sha" not 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 - ) + normalized_concurrency = " ".join(active_lines) + assert "cancel-in-progress: >- ${{" in normalized_concurrency + assert "github.event_name == 'pull_request_target'" in normalized_concurrency + assert "github.event_name == 'repository_dispatch'" in normalized_concurrency + assert "github.event.review.state == 'approved'" in normalized_concurrency + assert "github.event.review.state == 'changes_requested'" in normalized_concurrency assert "queue: max" not in workflow_text diff --git a/tests/test_merge_scheduler_review_event_admission.py b/tests/test_merge_scheduler_review_event_admission.py new file mode 100644 index 0000000000..7f21461ca4 --- /dev/null +++ b/tests/test_merge_scheduler_review_event_admission.py @@ -0,0 +1,222 @@ +"""Executable admission contract for merge-scheduler review events.""" + +from __future__ import annotations + +import ast +import re +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github" / "workflows" / "pr-review-merge-scheduler.yml" +OPENCODE_WORKFLOW = ROOT / ".github" / "workflows" / "opencode-review-dispatch.yml" + + +def workflow_step(workflow: str, name: str) -> str: + """Return exactly one named workflow step.""" + marker = f" - name: {name}\n" + start = workflow.index(marker) + try: + end = workflow.index("\n - name:", start + len(marker)) + except ValueError: + end = len(workflow) + return workflow[start:end] + + +def scan_job_condition() -> str: + """Return the normalized pre-runner condition for ``scan-pr-queue``.""" + workflow = WORKFLOW.read_text(encoding="utf-8") + scan_job = workflow.split("\n scan-pr-queue:\n", 1)[1] + condition = scan_job.split("\n runs-on:", 1)[0].split("\n if: >-\n", 1)[1] + return " ".join(line.strip() for line in condition.splitlines()) + + +def cancellation_condition() -> str: + """Return the normalized workflow-level cancellation expression.""" + workflow = WORKFLOW.read_text(encoding="utf-8") + marker = "\n cancel-in-progress: " + raw = workflow.split(marker, 1)[1] + if raw.startswith(">-\n"): + condition = raw.split("\n\n", 1)[0].removeprefix(">-\n") + else: + condition = raw.splitlines()[0] + normalized = " ".join(line.strip() for line in condition.splitlines()) + assert normalized.startswith("${{ ") and normalized.endswith(" }}") + return normalized.removeprefix("${{ ").removesuffix(" }}") + + +def auto_merge_condition() -> str: + """Return the executable scheduler auto-merge input expression.""" + workflow = WORKFLOW.read_text(encoding="utf-8") + raw = workflow.split(" ENABLE_AUTO_MERGE: ${{ ", 1)[1].split(" }}", 1)[0] + return " ".join(raw.split()) + + +def evaluate_review_expression( + *, expression: str, action: str, state: str, event_name: str = "pull_request_review" +) -> bool: + """Evaluate a workflow review-event expression for one trusted fixture.""" + expression = expression.replace("&&", " and ").replace("||", " or ") + expression = re.sub(r"\btrue\b", "True", expression) + expression = re.sub(r"\bfalse\b", "False", expression) + values = { + "github.event_name": event_name, + "github.event.action": action, + "github.event.review.state": state, + "github.event.client_payload.org_sweep": False, + "github.event.client_payload.enable_auto_merge": False, + "inputs.enable_auto_merge": False, + } + + def evaluate(node: ast.AST) -> object: + """Interpret only the boolean/comparison subset used by the job guard.""" + if isinstance(node, ast.Expression): + return evaluate(node.body) + if isinstance(node, ast.BoolOp): + operands = [bool(evaluate(value)) for value in node.values] + return all(operands) if isinstance(node.op, ast.And) else any(operands) + if isinstance(node, ast.Compare) and len(node.ops) == len(node.comparators) == 1: + left = evaluate(node.left) + right = evaluate(node.comparators[0]) + if isinstance(node.ops[0], ast.Eq): + return left == right + if isinstance(node.ops[0], ast.NotEq): + return left != right + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, (ast.Attribute, ast.Name)): + key = ast.unparse(node) + if key in values: + return values[key] + raise AssertionError(f"unsupported scheduler expression node: {ast.dump(node)}") + + return bool(evaluate(ast.parse(expression, mode="eval"))) + + +def admits_review_event(*, action: str, state: str) -> bool: + """Evaluate the pre-runner admission condition for one review fixture.""" + return evaluate_review_expression( + expression=scan_job_condition(), action=action, state=state + ) + + +def cancels_predecessor( + *, action: str, state: str, event_name: str = "pull_request_review" +) -> bool: + """Evaluate whether one review event cancels the prior scheduler run.""" + return evaluate_review_expression( + expression=cancellation_condition(), + action=action, + state=state, + event_name=event_name, + ) + + +@pytest.mark.parametrize( + ("action", "state", "expected"), + [ + ("submitted", "commented", False), + ("submitted", "approved", True), + ("submitted", "changes_requested", True), + ("dismissed", "commented", True), + ], +) +def test_review_event_admission_truth_table( + action: str, state: str, expected: bool +) -> None: + """Admit only review transitions that can change merge eligibility.""" + assert admits_review_event(action=action, state=state) is expected + + +def test_review_filter_preserves_exact_pr_group_and_least_privilege() -> None: + """Filtering COMMENTED reviews must not weaken scheduler trust boundaries.""" + workflow = WORKFLOW.read_text(encoding="utf-8") + assert ( + "github.event_name == 'pull_request_review' && " + "format('pr-{0}', github.event.pull_request.number)" in workflow + ) + assert "permissions:\n contents: read" in workflow + + scan_header = workflow.split("\n scan-pr-queue:\n", 1)[1].split( + "\n env:\n", 1 + )[0] + for permission in ( + "actions: write", + "checks: read", + "contents: write", + "id-token: write", + "pull-requests: write", + ): + assert permission in scan_header + + +def test_commented_review_does_not_cancel_approved_review_execution() -> None: + """A later COMMENTED review must preserve an approved scheduler execution.""" + assert cancels_predecessor(action="submitted", state="approved") is True + assert cancels_predecessor(action="submitted", state="commented") is False + assert cancels_predecessor(action="submitted", state="changes_requested") is True + assert cancels_predecessor(action="dismissed", state="commented") is True + + +@pytest.mark.parametrize( + ("event_name", "expected"), + [ + ("pull_request_target", True), + ("repository_dispatch", True), + ("push", False), + ("schedule", False), + ("workflow_call", False), + ], +) +def test_review_filter_preserves_non_review_cancellation_semantics( + event_name: str, expected: bool +) -> None: + """Narrow only review-event cancellation, preserving other trigger behavior.""" + assert ( + cancels_predecessor( + event_name=event_name, action="submitted", state="commented" + ) + is expected + ) + + +def test_actionable_review_event_reaches_core_without_runner_held_publication_wait() -> None: + """An admitted review transition must invoke the core without a sleep gate.""" + workflow = WORKFLOW.read_text(encoding="utf-8") + assert "Wait for approved OpenCode publication run to finish" not in workflow + assert "steps.review_followup.outputs.proceed" not in workflow + assert 'check_delay="$((check_attempt * 2))"' not in workflow + + self_test_index = workflow.index(" - name: Self-test scheduler") + inspect_index = workflow.index(" - name: Inspect PR review and merge queue") + between = workflow[self_test_index:inspect_index] + assert "sleep " not in between + assert "gh api" not in between + assert evaluate_review_expression( + expression=auto_merge_condition(), + action="submitted", + state="approved", + ) is False + + +def test_opencode_follow_up_retains_merge_authority_after_wait_removal() -> None: + """OpenCode's existing follow-up remains the post-publication merge owner.""" + scheduler = WORKFLOW.read_text(encoding="utf-8") + opencode = OPENCODE_WORKFLOW.read_text(encoding="utf-8") + assert "Wait for approved OpenCode publication run to finish" not in scheduler + + follow_up = workflow_step(opencode, "Run merge scheduler after approval") + for contract in ( + "python3 scripts/ci/opencode_existing_approval_gate.py", + "--require-opencode-app", + "python3 scripts/ci/pr_review_merge_scheduler.py", + "--review-dispatch-limit 0", + "--no-trigger-reviews", + "--enable-auto-merge", + "--merge-mode direct_or_auto", + "--no-update-branches", + ): + assert contract in follow_up + assert "gh workflow run pr-review-merge-scheduler.yml" not in follow_up diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 321d25bd57..d7a3686e07 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -2398,24 +2398,21 @@ def test_merge_scheduler_uses_escalating_mutation_credentials(): "github.event_name == 'pull_request_review' && " "format('pr-{0}', github.event.pull_request.number)" in workflow ) - assert "Wait for approved OpenCode publication run to finish" in workflow - assert "github.event.review.user.login == 'opencode-agent'" in workflow - assert "github.event.review.user.login == 'opencode-agent[bot]'" in workflow - assert "REVIEW_HEAD_SHA: ${{ github.event.review.commit_id }}" in workflow - assert "repos/${GITHUB_REPOSITORY}/pulls/${REVIEW_PR_NUMBER}" in workflow - assert "live pull request snapshot could not be read" in workflow - assert ( - "repos/${GITHUB_REPOSITORY}/commits/${REVIEW_HEAD_SHA}/check-runs?per_page=100" - in workflow - ) - assert 'select(.name == "opencode-review")' in workflow - assert 'check_delay="$((check_attempt * 2))"' in workflow - assert "steps.review_followup.outputs.proceed != 'false'" in workflow - assert "Native events and the explicit org-sweep recovery remain authoritative." in workflow - assert ( - "github.event_name == 'pull_request_review' || " - "github.event_name == 'repository_dispatch'" in workflow - ) + assert "Wait for approved OpenCode publication run to finish" not in workflow + assert "steps.review_followup.outputs.proceed" not in workflow + assert 'check_delay="$((check_attempt * 2))"' not in workflow + assert "Native events and the explicit org-sweep recovery remain authoritative." not in workflow + concurrency_block = workflow.split("\nconcurrency:\n", 1)[1].split( + "\n# Scorecard", 1 + )[0] + assert "cancel-in-progress: >-" in concurrency_block + for required_transition in ( + "github.event.action == 'dismissed'", + "github.event.review.state == 'approved'", + "github.event.review.state == 'changes_requested'", + "github.event_name == 'repository_dispatch'", + ): + assert required_transition in concurrency_block def test_opencode_runs_merge_scheduler_after_review_without_repo_local_dispatch(): diff --git a/tests/test_opencode_workflow_shell_syntax.py b/tests/test_opencode_workflow_shell_syntax.py index b0a672b1a2..af18bf4f8c 100644 --- a/tests/test_opencode_workflow_shell_syntax.py +++ b/tests/test_opencode_workflow_shell_syntax.py @@ -95,8 +95,8 @@ def test_opencode_review_comment_helpers_are_shared_and_valid_bash(): assert result.returncode == 0, result.stderr -def test_merge_scheduler_review_followup_run_block_is_valid_bash(): - """The App-review follow-up keeps its dynamic wait logic valid Bash.""" +def test_merge_scheduler_inspect_run_block_is_valid_bash(): + """The direct review-event scheduler path remains valid Bash.""" if sys.platform == "win32": return bash = shutil.which("bash") @@ -108,7 +108,7 @@ def test_merge_scheduler_review_followup_run_block_is_valid_bash(): ).read_text(encoding="utf-8") script = _extract_run_block( workflow_text, - "Wait for approved OpenCode publication run to finish", + "Inspect PR review and merge queue", ) result = subprocess.run( [bash, "-n"], diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index ba47b89c8d..f69ae1642e 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -8889,6 +8889,31 @@ def test_inspect_pr_handles_approved_reviews_and_dispatch(monkeypatch): assert "no OpenCode approval" in missing_approval_auto.reason +def test_review_event_enters_inspection_without_merge_authority(monkeypatch): + """An approved live head is inspected but cannot mutate on the review event.""" + mutations = [] + monkeypatch.setattr( + sched, + "merge_pr", + lambda *_args, **_kwargs: mutations.append("merge"), + ) + monkeypatch.setattr( + sched, + "enable_auto_merge", + lambda *_args, **_kwargs: mutations.append("auto-merge"), + ) + approved = make_pr( + reviewDecision="APPROVED", + reviews=merge_approved_reviews(commit="head"), + ) + + decision = inspect(approved, enable_auto_merge_flag=False) + + assert decision.action == "wait" + assert decision.reason == "current head is approved; auto-merge disabled by scheduler inputs" + assert mutations == [] + + def test_inspect_pr_waits_when_same_head_dispatch_is_already_running(monkeypatch): monkeypatch.setattr(sched, "repository_dispatch_wait_reason", lambda repo, workflow: None) monkeypatch.setattr( diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index 4ab09b1d0c..88553419c8 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -196,10 +196,12 @@ 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) + # Anchored, not a substring: this workflow's value is a folded expression + # rather than a constant, so a commented-out setting must not satisfy it. + assert re.search( + r"(?m)^[ \t]+cancel-in-progress:[ \t]+>-\n[ \t]+\$\{\{", + concurrency_contract, + ) assert "github.event_name == 'repository_dispatch'" in concurrency_contract