From 7d6c347c1eb9b56d5d400288b1d2b212b02da7c8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 13 Jul 2026 10:54:46 +0900 Subject: [PATCH] fix(scheduler): unblock central review dispatch with same-repository runner token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The org queue sweep and the central scan both execute inside ContextualWisdomLab/.github, which is exactly the repository the required OpenCode/Strix workflows are dispatched on. The OpenCode app token has no Actions permission and no PR_REVIEW_MERGE_TOKEN / OPENCODE_APPROVE_TOKEN PAT is configured, so workflow_dispatch_wait_reason parked every PR that needed current-head review evidence with 'no cross-repository workflow-dispatch credential' — an org-wide approved-but-unmerged deadlock. Pass the runner's own github.token (actions: write) through a new SCHEDULER_DISPATCH_TOKEN env, route the two 'gh workflow run' dispatch calls through it, and let the wait gate accept it only when GITHUB_REPOSITORY equals the dispatch repository, so a target-repository scan can never misuse it cross-repo. Co-Authored-By: Claude Fable 5 --- .../workflows/pr-review-merge-scheduler.yml | 13 ++++ scripts/ci/pr_review_merge_scheduler.py | 35 +++++++++- tests/test_pr_review_merge_scheduler.py | 65 +++++++++++++++++++ .../test_required_workflow_queue_contract.py | 18 +++++ 4 files changed, 129 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-review-merge-scheduler.yml b/.github/workflows/pr-review-merge-scheduler.yml index 779203829..7a88b5b4a 100644 --- a/.github/workflows/pr-review-merge-scheduler.yml +++ b/.github/workflows/pr-review-merge-scheduler.yml @@ -337,6 +337,12 @@ jobs: env: GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token || github.token }} SCHEDULER_ACTIONS_TOKEN: ${{ github.token }} + # Same-repository dispatch credential: when this scheduler runs inside + # ContextualWisdomLab/.github (the repository the required workflows are + # dispatched on), the runner token can dispatch them without any + # cross-repository PAT. The scheduler only uses it when + # GITHUB_REPOSITORY equals the dispatch repository. + SCHEDULER_DISPATCH_TOKEN: ${{ github.token }} SCHEDULER_READ_TOKEN: ${{ github.token }} SCHEDULER_MUTATION_TOKEN_SOURCE: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' && 'PR_REVIEW_MERGE_TOKEN' || secrets.OPENCODE_APPROVE_TOKEN != '' && 'OPENCODE_APPROVE_TOKEN' || steps.scheduler_app_token.outputs.available == 'true' && 'opencode-app' || 'github-token' }} SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY: ContextualWisdomLab/.github @@ -582,6 +588,13 @@ jobs: env: GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.sweep_app_token.outputs.token || github.token }} SCHEDULER_ACTIONS_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.sweep_app_token.outputs.token || github.token }} + # The sweep executes inside ContextualWisdomLab/.github, which is exactly + # where the central required workflows are dispatched, so the runner's own + # github.token (actions: write) is a sufficient dispatch credential even + # though the OpenCode app token has no Actions permission. Without this the + # sweep deadlocks every PR that needs current-head review evidence with + # "no cross-repository workflow-dispatch credential". + SCHEDULER_DISPATCH_TOKEN: ${{ github.token }} SCHEDULER_MUTATION_TOKEN_SOURCE: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' && 'PR_REVIEW_MERGE_TOKEN' || secrets.OPENCODE_APPROVE_TOKEN != '' && 'OPENCODE_APPROVE_TOKEN' || steps.sweep_app_token.outputs.available == 'true' && 'opencode-app' || 'github-token' }} SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY: ContextualWisdomLab/.github SCHEDULER_REQUIRED_WORKFLOW_REF: main diff --git a/scripts/ci/pr_review_merge_scheduler.py b/scripts/ci/pr_review_merge_scheduler.py index 01380f800..d42afd707 100644 --- a/scripts/ci/pr_review_merge_scheduler.py +++ b/scripts/ci/pr_review_merge_scheduler.py @@ -465,6 +465,31 @@ def run_github_actions(args: Sequence[str], *, stdin: str | None = None) -> str: return run_with_env(args, stdin=stdin, env=env) +def scheduler_dispatch_env() -> dict[str, str] | None: + """Return an env override for central-repository workflow dispatch when configured. + + The OpenCode app installation has no Actions permission, so the mutation token + cannot dispatch `gh workflow run`. When the scheduler executes inside the + central repository the required workflows are dispatched on, the runner's own + github.token (actions: write) is a sufficient dispatch credential; the workflow + passes it through SCHEDULER_DISPATCH_TOKEN. + """ + dispatch_token = os.environ.get("SCHEDULER_DISPATCH_TOKEN") + if not dispatch_token or dispatch_token == os.environ.get("GH_TOKEN"): + return None + env = os.environ.copy() + env["GH_TOKEN"] = dispatch_token + return env + + +def run_github_dispatch(args: Sequence[str], *, stdin: str | None = None) -> str: + """Run a workflow dispatch command with the dispatch token when configured.""" + env = scheduler_dispatch_env() + if env is None: + return run_github_actions(args, stdin=stdin) + return run_with_env(args, stdin=stdin, env=env) + + def split_repo(repo: str) -> tuple[str, str]: """Split an owner/name repository string into owner and repository name.""" try: @@ -552,6 +577,12 @@ def workflow_dispatch_wait_reason(repo: str, workflow: str) -> str | None: dispatch_repo = validate_github_repository(dispatch_repo) if dispatch_repo == target_repo or env_flag_enabled("SCHEDULER_ALLOW_CROSS_REPO_WORKFLOW_DISPATCH"): return None + execution_repo = (os.environ.get("GITHUB_REPOSITORY") or "").strip() + if os.environ.get("SCHEDULER_DISPATCH_TOKEN") and execution_repo == dispatch_repo: + # The dispatch targets the repository this scheduler run executes in and the + # workflow provided a dispatch-capable runner token for it, so no + # cross-repository credential is needed. + return None return ( f"{workflow} dispatch waits for central required workflow materialization; " f"required workflow source is {dispatch_repo}, but this scheduler run has no " @@ -1643,7 +1674,7 @@ def dispatch_opencode_review(repo: str, workflow: str, pr: dict[str, Any], *, dr base_ref, base_sha, head_sha = validated_pr_dispatch_fields(pr) head_ref = validate_git_ref(pr["headRefName"]) dispatch_repo, dispatch_ref, extra_inputs = workflow_dispatch_target(repo, base_ref) - run_github_actions( + run_github_dispatch( [ "gh", "workflow", @@ -1679,7 +1710,7 @@ def dispatch_strix_evidence(repo: str, workflow: str, pr: dict[str, Any], *, dry return base_ref, base_sha, head_sha = validated_pr_dispatch_fields(pr) dispatch_repo, dispatch_ref, extra_inputs = workflow_dispatch_target(repo, base_ref) - run_github_actions( + run_github_dispatch( [ "gh", "workflow", diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index 3d80474d9..1ac34b6e1 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -1500,12 +1500,77 @@ def test_stacked_pr_waits_when_opencode_dispatch_is_already_active(monkeypatch): def test_cross_repo_dispatch_wait_reason_can_be_explicitly_enabled(monkeypatch): monkeypatch.setenv("SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY", "ContextualWisdomLab/.github") monkeypatch.delenv("SCHEDULER_ALLOW_CROSS_REPO_WORKFLOW_DISPATCH", raising=False) + monkeypatch.delenv("SCHEDULER_DISPATCH_TOKEN", raising=False) assert sched.workflow_dispatch_wait_reason("owner/repo", "Strix Security Scan") monkeypatch.setenv("SCHEDULER_ALLOW_CROSS_REPO_WORKFLOW_DISPATCH", "true") assert sched.workflow_dispatch_wait_reason("owner/repo", "Strix Security Scan") is None +def test_same_repository_dispatch_token_unblocks_central_workflow_dispatch(monkeypatch): + """A runner token for the dispatch repository is a sufficient dispatch credential. + + The OpenCode app token has no Actions permission and no cross-repository PAT is + configured, so without this allowance the org sweep deadlocks every PR that + needs current-head review evidence. + """ + monkeypatch.setenv("SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY", "ContextualWisdomLab/.github") + monkeypatch.delenv("SCHEDULER_ALLOW_CROSS_REPO_WORKFLOW_DISPATCH", raising=False) + monkeypatch.setenv("GITHUB_REPOSITORY", "ContextualWisdomLab/.github") + monkeypatch.setenv("SCHEDULER_DISPATCH_TOKEN", "runner-token") + + assert sched.workflow_dispatch_wait_reason("owner/repo", "Strix Security Scan") is None + + # A dispatch token for a DIFFERENT execution repository is not dispatch evidence. + monkeypatch.setenv("GITHUB_REPOSITORY", "ContextualWisdomLab/naruon") + assert sched.workflow_dispatch_wait_reason("owner/repo", "Strix Security Scan") + + # Same execution repository without a dispatch token still waits. + monkeypatch.setenv("GITHUB_REPOSITORY", "ContextualWisdomLab/.github") + monkeypatch.delenv("SCHEDULER_DISPATCH_TOKEN", raising=False) + assert sched.workflow_dispatch_wait_reason("owner/repo", "Strix Security Scan") + + +def test_scheduler_dispatch_env_prefers_distinct_dispatch_token(monkeypatch): + monkeypatch.setenv("GH_TOKEN", "mutation-token") + monkeypatch.setenv("SCHEDULER_DISPATCH_TOKEN", "runner-token") + + env = sched.scheduler_dispatch_env() + + assert env is not None + assert env["GH_TOKEN"] == "runner-token" + + +def test_scheduler_dispatch_env_is_noop_without_distinct_token(monkeypatch): + monkeypatch.delenv("SCHEDULER_DISPATCH_TOKEN", raising=False) + assert sched.scheduler_dispatch_env() is None + + monkeypatch.setenv("GH_TOKEN", "same-token") + monkeypatch.setenv("SCHEDULER_DISPATCH_TOKEN", "same-token") + assert sched.scheduler_dispatch_env() is None + + +def test_run_github_dispatch_uses_dispatch_token_env(monkeypatch): + calls = [] + monkeypatch.setenv("GH_TOKEN", "mutation-token") + monkeypatch.setenv("SCHEDULER_DISPATCH_TOKEN", "runner-token") + monkeypatch.setattr( + sched, + "run_with_env", + lambda args, stdin=None, env=None: calls.append((tuple(args), env["GH_TOKEN"])) or "dispatched", + ) + + assert sched.run_github_dispatch(["gh", "workflow", "run"]) == "dispatched" + assert calls == [(("gh", "workflow", "run"), "runner-token")] + + +def test_run_github_dispatch_falls_back_to_actions_token(monkeypatch): + monkeypatch.delenv("SCHEDULER_DISPATCH_TOKEN", raising=False) + monkeypatch.setattr(sched, "run_github_actions", lambda args, stdin=None: "fallback") + + assert sched.run_github_dispatch(["gh", "workflow", "run"]) == "fallback" + + def test_dispatch_opencode_review_force_cancels_same_pr_old_head_runs(monkeypatch): calls = [] head_sha = "a" * 40 diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index e0cd64975..08d34e4d7 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -25,6 +25,24 @@ def test_merge_scheduler_dispatches_one_review_by_default() -> None: assert "secrets.PR_REVIEW_MERGE_TOKEN != '' || secrets.OPENCODE_APPROVE_TOKEN != ''" in workflow +def test_merge_scheduler_provides_same_repository_dispatch_credential() -> None: + """Guard the runner-token dispatch credential for central review workflows. + + The OpenCode app installation has no Actions permission and no + PR_REVIEW_MERGE_TOKEN / OPENCODE_APPROVE_TOKEN PAT is configured, so before + this credential existed the org sweep deadlocked every PR needing current-head + review evidence with "no cross-repository workflow-dispatch credential". The + scheduler and the sweep both run inside ContextualWisdomLab/.github — the same + repository the required workflows are dispatched on — so the runner's own + github.token (actions: write) must be passed through SCHEDULER_DISPATCH_TOKEN + in BOTH jobs; the scheduler only uses it when GITHUB_REPOSITORY equals the + dispatch repository. + """ + workflow = workflow_text("pr-review-merge-scheduler.yml") + + assert workflow.count("SCHEDULER_DISPATCH_TOKEN: ${{ github.token }}") == 2 + + def test_required_pull_request_workflows_cancel_superseded_runs() -> None: for filename in ( "close-empty-pr.yml",