From 80e85fd88e36655f97dc72efd5da966276b933f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 30 Jun 2026 13:53:09 +0900 Subject: [PATCH] Use workflow token for scheduler Actions control --- .../workflows/pr-review-merge-scheduler.yml | 1 + scripts/ci/pr_review_merge_scheduler.py | 46 ++++++++++++++++--- tests/test_pr_review_merge_scheduler.py | 30 +++++++++++- 3 files changed, 69 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pr-review-merge-scheduler.yml b/.github/workflows/pr-review-merge-scheduler.yml index 455c03a13..6a6df6ce3 100644 --- a/.github/workflows/pr-review-merge-scheduler.yml +++ b/.github/workflows/pr-review-merge-scheduler.yml @@ -245,6 +245,7 @@ jobs: - name: Inspect PR review and merge queue 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 }} 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' }} run: | diff --git a/scripts/ci/pr_review_merge_scheduler.py b/scripts/ci/pr_review_merge_scheduler.py index b9a8bf715..0d622f004 100644 --- a/scripts/ci/pr_review_merge_scheduler.py +++ b/scripts/ci/pr_review_merge_scheduler.py @@ -415,6 +415,24 @@ def run_github_read(args: Sequence[str], *, stdin: str | None = None) -> str: return run_with_env(args, stdin=stdin, env=env) +def scheduler_actions_env() -> dict[str, str] | None: + """Return an env override for GitHub Actions control calls when configured.""" + actions_token = os.environ.get("SCHEDULER_ACTIONS_TOKEN") + if not actions_token or actions_token == os.environ.get("GH_TOKEN"): + return None + env = os.environ.copy() + env["GH_TOKEN"] = actions_token + return env + + +def run_github_actions(args: Sequence[str], *, stdin: str | None = None) -> str: + """Run a GitHub Actions control command with the workflow token when configured.""" + env = scheduler_actions_env() + if env is None: + return run(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: @@ -1180,12 +1198,26 @@ def require_github_actions_mutation_actor(action: str) -> None: ) +def require_github_actions_control_actor(action: str) -> None: + """Refuse Actions rerun or dispatch calls without a workflow control token.""" + if os.environ.get("GITHUB_ACTIONS") != "true": + raise RuntimeError( + f"{action} refused outside GitHub Actions; dispatch PR Review Merge Scheduler " + "so the workflow actions credential performs the guarded GitHub Actions control call" + ) + if not os.environ.get("SCHEDULER_ACTIONS_TOKEN") and not os.environ.get("GH_TOKEN"): + raise RuntimeError( + f"{action} refused without SCHEDULER_ACTIONS_TOKEN or GH_TOKEN; configure the scheduler " + "job to pass github.token through SCHEDULER_ACTIONS_TOKEN for workflow rerun and dispatch calls" + ) + + def rerun_actions_job(repo: str, job_id: str, *, dry_run: bool, action: str) -> None: """Ask GitHub Actions to rerun an existing required-workflow job.""" if dry_run: return - require_github_actions_mutation_actor(action) - run(["gh", "api", "-X", "POST", f"repos/{repo}/actions/jobs/{job_id}/rerun"]) + require_github_actions_control_actor(action) + run_github_actions(["gh", "api", "-X", "POST", f"repos/{repo}/actions/jobs/{job_id}/rerun"]) def active_workflow_runs(repo: str) -> list[dict[str, Any]]: @@ -1193,7 +1225,7 @@ def active_workflow_runs(repo: str) -> list[dict[str, Any]]: runs: list[dict[str, Any]] = [] for status in ("queued", "in_progress"): payload = json.loads( - run( + run_github_actions( [ "gh", "api", @@ -1238,12 +1270,12 @@ def cancel_stale_opencode_runs(repo: str, workflow: str, pr: dict[str, Any], *, """Force-cancel older OpenCode runs for the same PR before retrying current head.""" if dry_run: return [] - require_github_actions_mutation_actor("force-cancel-stale-opencode-review") + require_github_actions_control_actor("force-cancel-stale-opencode-review") run_ids = stale_opencode_run_ids(repo, workflow, pr) if not run_ids: return [] for run_id in run_ids: - run(["gh", "api", "-X", "POST", f"repos/{repo}/actions/runs/{run_id}/force-cancel"]) + run_github_actions(["gh", "api", "-X", "POST", f"repos/{repo}/actions/runs/{run_id}/force-cancel"]) return run_ids @@ -1256,7 +1288,7 @@ def dispatch_opencode_review(repo: str, workflow: str, pr: dict[str, Any], *, dr return if dry_run: return - run( + run_github_actions( [ "gh", "workflow", @@ -1286,7 +1318,7 @@ def dispatch_strix_evidence(repo: str, workflow: str, pr: dict[str, Any], *, dry return if dry_run: return - run( + run_github_actions( [ "gh", "workflow", diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index 01c7477f2..b3e1f7b48 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -907,6 +907,33 @@ def fake_run(args, stdin=None): ] +def test_actions_control_uses_workflow_token_when_mutation_token_is_app(monkeypatch): + calls = [] + + def fake_run_with_env(args, *, stdin=None, env=None): + calls.append((args, stdin, None if env is None else env.get("GH_TOKEN"))) + if args[:5] == ["gh", "api", "--method", "GET", "repos/owner/repo/actions/runs"]: + return '{"workflow_runs": []}' + return "" + + monkeypatch.setattr(sched, "run_with_env", fake_run_with_env) + monkeypatch.setenv("GITHUB_ACTIONS", "true") + monkeypatch.setenv("GH_TOKEN", "opencode-app-token") + monkeypatch.setenv("SCHEDULER_ACTIONS_TOKEN", "workflow-actions-token") + + pr = make_pr() + sched.rerun_actions_job("owner/repo", "101", dry_run=False, action="rerun-opencode-review") + sched.dispatch_strix_evidence("owner/repo", "Strix Security Scan", pr, dry_run=False) + sched.dispatch_opencode_review("owner/repo", "OpenCode Review", pr, dry_run=False) + + assert [call[2] for call in calls] == ["workflow-actions-token"] * len(calls) + assert calls[0][0] == ["gh", "api", "-X", "POST", "repos/owner/repo/actions/jobs/101/rerun"] + assert calls[1][0][:5] == ["gh", "workflow", "run", "Strix Security Scan", "--repo"] + assert calls[2][0][:5] == ["gh", "api", "--method", "GET", "repos/owner/repo/actions/runs"] + assert calls[3][0][:5] == ["gh", "api", "--method", "GET", "repos/owner/repo/actions/runs"] + assert calls[4][0][:5] == ["gh", "workflow", "run", "OpenCode Review", "--repo"] + + def test_dispatch_opencode_review_force_cancels_same_pr_old_head_runs(monkeypatch): calls = [] stale_same_pr = { @@ -980,10 +1007,11 @@ def test_mutations_refuse_local_credentials(monkeypatch): monkeypatch.setenv("GITHUB_ACTIONS", "true") monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.delenv("SCHEDULER_ACTIONS_TOKEN", raising=False) for mutation in (sched.update_branch, sched.enable_auto_merge, sched.merge_pr, sched.disable_auto_merge): with pytest.raises(RuntimeError, match="refused without GH_TOKEN"): mutation("owner/repo", make_pr(), dry_run=False) - with pytest.raises(RuntimeError, match="refused without GH_TOKEN"): + with pytest.raises(RuntimeError, match="refused without SCHEDULER_ACTIONS_TOKEN or GH_TOKEN"): sched.dispatch_opencode_review("owner/repo", "OpenCode Review", rerun_pr, dry_run=False) assert calls == []