diff --git a/.github/workflows/pr-review-merge-scheduler.yml b/.github/workflows/pr-review-merge-scheduler.yml index d32918cf45..872285f323 100644 --- a/.github/workflows/pr-review-merge-scheduler.yml +++ b/.github/workflows/pr-review-merge-scheduler.yml @@ -488,16 +488,14 @@ jobs: TARGET_REPOSITORY: ${{ steps.targeted_dispatch.outputs.repository }} TARGET_DEFAULT_BRANCH: ${{ steps.targeted_dispatch.outputs.base_branch }} SCHEDULER_ACTIONS_TOKEN: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.target_repository != '' && github.event.client_payload.target_repository != github.repository && (secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.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. + # Central Actions read/control credential. Repository-dispatch POSTs + # use only the separately exchanged repository-scoped App token below. SCHEDULER_DISPATCH_TOKEN: ${{ github.token }} + SCHEDULER_REPOSITORY_DISPATCH_TOKEN: ${{ steps.scheduler_app_token.outputs.token }} SCHEDULER_READ_TOKEN: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.target_repository != '' && github.event.client_payload.target_repository != github.repository && (secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.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 - SCHEDULER_ALLOW_CROSS_REPO_REPOSITORY_DISPATCH: ${{ (secrets.PR_REVIEW_MERGE_TOKEN != '' || secrets.OPENCODE_APPROVE_TOKEN != '') && 'true' || 'false' }} + SCHEDULER_ALLOW_CROSS_REPO_REPOSITORY_DISPATCH: ${{ steps.scheduler_app_token.outputs.available == 'true' && 'true' || 'false' }} run: | set -euo pipefail case "$STALE_OPENCODE_MINUTES" in diff --git a/CHANGELOG.md b/CHANGELOG.md index bf192f6a9e..8447e60481 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +### Scheduler repository dispatch uses one canonical App identity + +- `pr-review-merge-scheduler.yml` now passes the OIDC-exchanged OpenCode App token through a dedicated `SCHEDULER_REPOSITORY_DISPATCH_TOKEN` boundary. OpenCode and Strix `repository_dispatch` POSTs use only that credential and fail closed when the exchange is unavailable; they no longer fall back to `github.token`, a mutation token, or the central Actions-read token. The existing `SCHEDULER_DISPATCH_TOKEN` remains limited to `.github` Actions artifact reads/control, so the App token does not gain Actions permission. This preserves the receiver's actor=sender allowlist check while making both producer paths arrive as the same `opencode-agent[bot]` identity. Regression tests pin the workflow wiring, missing-token failure, token separation, target repository, exact payload, and shared admission budget. Refs #1927. + ### Failed-check finding names the Strix sandbox instead of the gateway - `opencode-review-dispatch.yml`'s `emit_strix_provider_failure_finding` rendered one fixed finding for every `STRIX_PROVIDER_UNAVAILABLE` line, whose Root cause read "The contextual-orchestrator gateway or its discovered provider pool was unavailable for this run". `#1953` had just given the Strix sandbox bootstrap failure its own second verdict token (`STRIX_SANDBOX_UNAVAILABLE`) precisely because that attribution is wrong for it -- the sandbox container never reaches its Caido proxy, so the run dies before the gateway serves anything -- and this consumer re-applied the wrong attribution one step downstream, into the review findings and the failure census. The emitter now branches on the second token: a sandbox verdict gets a finding that names Strix's sandbox, says the verdict does not name the gateway, and tells the reader not to change gateway or provider configuration on its strength. A `STRIX_PROVIDER_UNAVAILABLE` line without the token keeps its existing text verbatim, so the gateway class has no regression surface. No test covered this finding text at all before (`gateway or its discovered provider pool` matched nothing under `tests/`); `tests/test_opencode_dispatch_strix_sandbox_finding.py` now runs the production emitter from the published run block and pins both directions plus the no-signal case. Refs #1953, #1935. diff --git a/docs/doctoring/fork-head-review-dispatch.md b/docs/doctoring/fork-head-review-dispatch.md index fe326459f8..34ee1974c5 100644 --- a/docs/doctoring/fork-head-review-dispatch.md +++ b/docs/doctoring/fork-head-review-dispatch.md @@ -56,15 +56,32 @@ grant the workflow token sibling-repository access or make it authoritative for branch mutation. This separates the repository-local rate-limit bucket from the shared App installation bucket and follows GitHub's documented authentication rate-limit scopes (GitHub, Inc., n.d.-c). The existing -`SCHEDULER_DISPATCH_TOKEN` remains the repository token because GitHub explicitly -permits `repository_dispatch` created with `GITHUB_TOKEN` to start a workflow -(GitHub, Inc., n.d.-d). +`SCHEDULER_DISPATCH_TOKEN` remains the repository token for central Actions +artifact reads and control calls. It is not a repository-dispatch credential. The static regression requires both read and Actions-control expressions to distinguish a same-repository target from a cross-repository target. The full Python suite, 100% statement/branch/docstring gates, and the CI-budget Strix shell gate remain authoritative before publication. +## Canonical repository-dispatch identity + +Later production evidence showed that the two trusted producers reached the +receiver as different principals: the OpenCode leaf path used its OIDC-exchanged +repository-scoped App token and arrived as `opencode-agent[bot]`, while the +scheduler used `github.token` and arrived as `github-actions[bot]`. Maintaining +both identities in the receiver allowlist would preserve two mutable producer +boundaries for the same operation. + +The scheduler therefore uses the same OIDC-exchanged OpenCode App token for +OpenCode and Strix repository-dispatch POSTs. The token is passed through the +dedicated `SCHEDULER_REPOSITORY_DISPATCH_TOKEN` environment boundary and has no +fallback: an unavailable exchange fails closed before dispatch. The scheduler's +Actions-read token and mutation credential remain separate. The receiving +workflows still require both `github.triggering_actor` and +`github.event.sender.login` to equal the one allowlisted principal; no ambient +token, new target, or authorization relaxation is introduced. Refs #1927. + ## APA 7th references GitHub, Inc. (n.d.-a). *REST API endpoints for pull requests*. GitHub Docs. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 1cc9e20313..ead61d9917 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -32,6 +32,18 @@ 스냅샷은 작성 시점의 open/close delta만 기록한다. 병합 판단에는 재사용하지 않는다. +### 1.4 중앙 repository-dispatch identity Gap + +`GOV-DISPATCH-IDENTITY`는 중앙 OpenCode/Strix dispatch producer가 서로 다른 +GitHub principal로 수신되는 설정 드리프트를 추적한다. Receiver의 +actor=sender 동일성 검사와 정확한 target allowlist는 유지한다. Producer는 +OIDC로 교환한 repository-scoped OpenCode App token 하나만 POST에 사용하고, +Actions read/control 및 PR mutation credential과 분리한다. App 교환 부재는 +ambient `github.token`이나 PAT로 fallback하지 않고 fail closed한다. Source +GREEN 뒤에도 보호된 main, single-valued live allowlist, unchanged consumer +head의 terminal callback을 각각 fresh 확인하기 전에는 Gap을 닫지 않는다. +Owner: #1927. + ## 2. PRD / TRD / UML 기준 ### 2.1 PRD acceptance diff --git a/scripts/ci/pr_review_merge_scheduler_core.py b/scripts/ci/pr_review_merge_scheduler_core.py index cffb52cb53..5abdf28ef5 100644 --- a/scripts/ci/pr_review_merge_scheduler_core.py +++ b/scripts/ci/pr_review_merge_scheduler_core.py @@ -803,12 +803,13 @@ def run_github_actions(args: Sequence[str], *, stdin: str | None = None) -> str: def scheduler_dispatch_env() -> dict[str, str] | None: - """Return an env override for central repository dispatch when configured. + """Return an env override for central Actions reads when configured. The OpenCode app installation has no Actions permission, so the mutation token - cannot create a repository dispatch. When the scheduler executes inside the - central repository receiving the event, the runner's own github.token is a - sufficient credential; the workflow passes it through SCHEDULER_DISPATCH_TOKEN. + cannot read the central workflow's artifacts. The scheduler's own github.token + can read those same-repository artifacts; the workflow passes it through the + legacy-named ``SCHEDULER_DISPATCH_TOKEN`` boundary. Repository-dispatch POSTs + use :func:`scheduler_repository_dispatch_env` instead. """ dispatch_token = os.environ.get("SCHEDULER_DISPATCH_TOKEN") if not dispatch_token or dispatch_token == os.environ.get("GH_TOKEN"): @@ -819,13 +820,42 @@ def scheduler_dispatch_env() -> dict[str, str] | None: def run_github_dispatch(args: Sequence[str], *, stdin: str | None = None) -> str: - """Run a repository dispatch command with the dispatch token when configured.""" + """Run a central Actions read/control command with the runner token.""" 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 scheduler_repository_dispatch_env() -> dict[str, str]: + """Return the repository-scoped OpenCode App credential for dispatch POSTs. + + A repository dispatch is an authorization boundary, so it must never fall + back to the scheduler mutation credential, an ambient token, or the runner + token. The caller fails closed when the OIDC-backed App exchange was not + available for this run. + """ + app_token = os.environ.get("SCHEDULER_REPOSITORY_DISPATCH_TOKEN") + if not app_token: + raise RuntimeError( + "repository dispatch requires the repository-scoped OpenCode App token" + ) + env = os.environ.copy() + env["GH_TOKEN"] = app_token + return env + + +def run_github_repository_dispatch( + args: Sequence[str], *, stdin: str | None = None +) -> str: + """Create a repository dispatch using only the scoped App credential.""" + return run_with_env( + args, + stdin=stdin, + env=scheduler_repository_dispatch_env(), + ) + + def split_repo(repo: str) -> tuple[str, str]: """Split an owner/name repository string into owner and repository name.""" try: @@ -909,10 +939,12 @@ def repository_dispatch_wait_reason(repo: str, workflow: str) -> str | None: if dispatch_repo == target_repo or env_flag_enabled("SCHEDULER_ALLOW_CROSS_REPO_REPOSITORY_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. + if ( + os.environ.get("SCHEDULER_REPOSITORY_DISPATCH_TOKEN") + and execution_repo == dispatch_repo + ): + # The dispatch targets the repository this scheduler run executes in and + # the workflow supplied its repository-scoped App token. return None return ( f"{workflow} dispatch waits for central required workflow materialization; " @@ -1168,17 +1200,17 @@ def gh_api_json(path: str) -> Any: def gh_api_json_via_dispatch_token(path: str) -> Any: - """Run a GitHub REST API GET via the central-repository dispatch credential. + """Run a GitHub REST API GET via the central Actions-read credential. The OpenCode app installation has no Actions permission (see :func:`scheduler_dispatch_env`), and the target-repository read credential (:func:`gh_api_json`) is not guaranteed to have it either for a cross-repository dispatch. A read against ``.github``'s own Actions artifacts -- which always host the central draft-review-request marker - regardless of which repository the PR belongs to -- must use the same - central-repository dispatch credential already used for creating a - ``repository_dispatch`` there, not the target-repository read - credential. + regardless of which repository the PR belongs to -- must use the central + runner token, not the target-repository read credential or the separate + App token used only for ``repository_dispatch`` POSTs. The function name + is retained as an internal compatibility boundary. """ return json.loads(run_github_dispatch(["gh", "api", path])) @@ -3727,7 +3759,7 @@ def dispatch_opencode_review(repo: str, workflow: str, pr: dict[str, Any], *, dr client_payload["required_run_id"] = required_run_id if not live_dispatch_head_matches(target_repo, pr): return "stale_head" - run_github_dispatch( + run_github_repository_dispatch( [ "gh", "api", @@ -3816,7 +3848,7 @@ def dispatch_strix_evidence(repo: str, workflow: str, pr: dict[str, Any], *, dry base_ref, base_sha, head_sha = validated_pr_dispatch_fields(pr) if not live_dispatch_head_matches(target_repo, pr): return "stale_head" - run_github_dispatch( + run_github_repository_dispatch( [ "gh", "api", diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index 8b924291a5..91b32f0b6c 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -2317,7 +2317,9 @@ def test_dispatch_opencode_review_falls_back_to_bounded_discovery(monkeypatch): ) dispatch_calls = [] monkeypatch.setattr( - sched, "run_github_dispatch", lambda args, stdin=None: dispatch_calls.append(stdin) + sched, + "run_github_repository_dispatch", + lambda args, stdin=None: dispatch_calls.append(stdin), ) head_sha = "a" * 40 @@ -2340,7 +2342,9 @@ def _dispatch_with_merge_state(monkeypatch, **overrides): monkeypatch.setattr(sched, "discover_opencode_required_run_id", lambda repo, head_sha: None) dispatched: list[str | None] = [] monkeypatch.setattr( - sched, "run_github_dispatch", lambda args, stdin=None: dispatched.append(stdin) + sched, + "run_github_repository_dispatch", + lambda args, stdin=None: dispatched.append(stdin), ) admitted: list[str] = [] @@ -4592,6 +4596,11 @@ def fake_run(args, stdin=None): return "" monkeypatch.setattr(sched, "run", fake_run) + monkeypatch.setattr( + sched, + "run_with_env", + lambda args, *, stdin=None, env=None: fake_run(args, stdin=stdin), + ) pr = make_pr(baseRefOid=base_sha, headRefOid=head_sha) sched.enable_auto_merge("owner/repo", pr, dry_run=True) sched.merge_pr("owner/repo", pr, dry_run=True) @@ -4604,6 +4613,7 @@ def fake_run(args, stdin=None): monkeypatch.setenv("GITHUB_ACTIONS", "true") monkeypatch.setenv("GH_TOKEN", "workflow-token") + monkeypatch.setenv("SCHEDULER_REPOSITORY_DISPATCH_TOKEN", "app-token") sched.enable_auto_merge("owner/repo", pr, dry_run=False) sched.merge_pr("owner/repo", pr, dry_run=False) sched.disable_auto_merge("owner/repo", pr, dry_run=False) @@ -4917,6 +4927,7 @@ def fake_run_with_env(args, *, stdin=None, env=None): monkeypatch.setenv("GITHUB_ACTIONS", "true") monkeypatch.setenv("GH_TOKEN", "opencode-app-token") monkeypatch.setenv("SCHEDULER_ACTIONS_TOKEN", "workflow-actions-token") + monkeypatch.setenv("SCHEDULER_REPOSITORY_DISPATCH_TOKEN", "repository-app-token") pr = make_pr(baseRefOid="b" * 40, headRefOid="a" * 40) monkeypatch.setattr(sched, "fetch_pr", lambda *_args: [pr]) @@ -4924,7 +4935,18 @@ def fake_run_with_env(args, *, stdin=None, env=None): 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 [call[2] for call in calls] == [ + "workflow-actions-token", + "workflow-actions-token", + "workflow-actions-token", + "repository-app-token", + "workflow-actions-token", + "workflow-actions-token", + "workflow-actions-token", + "workflow-actions-token", + "workflow-actions-token", + "repository-app-token", + ] assert calls[0][0] == ["gh", "api", "-X", "POST", "repos/owner/repo/actions/jobs/101/rerun"] assert calls[1][0][:5] == ["gh", "api", "--method", "GET", "repos/owner/repo/actions/runs"] assert calls[2][0][:5] == ["gh", "api", "--method", "GET", "repos/owner/repo/actions/runs"] @@ -5319,6 +5341,7 @@ def fake_run_with_env(args, *, stdin=None, env=None): monkeypatch.setenv("GITHUB_ACTIONS", "true") monkeypatch.setenv("GH_TOKEN", "opencode-app-token") monkeypatch.setenv("SCHEDULER_ACTIONS_TOKEN", "workflow-actions-token") + monkeypatch.setenv("SCHEDULER_REPOSITORY_DISPATCH_TOKEN", "repository-app-token") monkeypatch.setenv("SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY", "ContextualWisdomLab/.github") monkeypatch.setenv("SCHEDULER_REQUIRED_WORKFLOW_REF", "main") @@ -5344,7 +5367,16 @@ def fake_run_with_env(args, *, stdin=None, env=None): strix_call = dispatch_calls[0][0] opencode_call = dispatch_calls[1][0] - assert calls and all(call[2] == "workflow-actions-token" for call in calls) + assert calls + assert all( + call[2] == "repository-app-token" + for call in dispatch_calls + ) + assert all( + call[2] == "workflow-actions-token" + for call in calls + if call not in dispatch_calls + ) assert strix_call == [ "gh", "api", @@ -5477,24 +5509,19 @@ def test_stacked_pr_waits_when_review_dispatch_budget_is_exhausted(): 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_REPOSITORY_DISPATCH", raising=False) - monkeypatch.delenv("SCHEDULER_DISPATCH_TOKEN", raising=False) + monkeypatch.delenv("SCHEDULER_REPOSITORY_DISPATCH_TOKEN", raising=False) assert sched.repository_dispatch_wait_reason("owner/repo", "Strix Security Scan") monkeypatch.setenv("SCHEDULER_ALLOW_CROSS_REPO_REPOSITORY_DISPATCH", "true") assert sched.repository_dispatch_wait_reason("owner/repo", "Strix Security Scan") is None -def test_same_repository_dispatch_token_unblocks_central_repository_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. - """ +def test_same_repository_app_token_unblocks_central_repository_dispatch(monkeypatch): + """The repository-scoped App token is sufficient for central dispatch.""" monkeypatch.setenv("SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY", "ContextualWisdomLab/.github") monkeypatch.delenv("SCHEDULER_ALLOW_CROSS_REPO_REPOSITORY_DISPATCH", raising=False) monkeypatch.setenv("GITHUB_REPOSITORY", "ContextualWisdomLab/.github") - monkeypatch.setenv("SCHEDULER_DISPATCH_TOKEN", "runner-token") + monkeypatch.setenv("SCHEDULER_REPOSITORY_DISPATCH_TOKEN", "app-token") assert sched.repository_dispatch_wait_reason("owner/repo", "Strix Security Scan") is None @@ -5504,10 +5531,43 @@ def test_same_repository_dispatch_token_unblocks_central_repository_dispatch(mon # Same execution repository without a dispatch token still waits. monkeypatch.setenv("GITHUB_REPOSITORY", "ContextualWisdomLab/.github") - monkeypatch.delenv("SCHEDULER_DISPATCH_TOKEN", raising=False) + monkeypatch.delenv("SCHEDULER_REPOSITORY_DISPATCH_TOKEN", raising=False) assert sched.repository_dispatch_wait_reason("owner/repo", "Strix Security Scan") +def test_repository_dispatch_env_requires_repository_scoped_app_token(monkeypatch): + monkeypatch.setenv("GH_TOKEN", "mutation-token") + monkeypatch.setenv("SCHEDULER_DISPATCH_TOKEN", "runner-token") + monkeypatch.delenv("SCHEDULER_REPOSITORY_DISPATCH_TOKEN", raising=False) + + with pytest.raises(RuntimeError, match="repository-scoped OpenCode App token"): + sched.scheduler_repository_dispatch_env() + + +def test_repository_dispatch_uses_only_repository_scoped_app_token(monkeypatch): + calls = [] + monkeypatch.setenv("GH_TOKEN", "mutation-token") + monkeypatch.setenv("SCHEDULER_DISPATCH_TOKEN", "runner-token") + monkeypatch.setenv("SCHEDULER_REPOSITORY_DISPATCH_TOKEN", "app-token") + monkeypatch.setattr( + sched, + "run_with_env", + lambda args, stdin=None, env=None: calls.append((tuple(args), stdin, env["GH_TOKEN"])) + or "dispatched", + ) + + assert ( + sched.run_github_repository_dispatch( + ["gh", "api", "-X", "POST", "repos/org/repo/dispatches"], + stdin="{}", + ) + == "dispatched" + ) + assert calls == [ + (("gh", "api", "-X", "POST", "repos/org/repo/dispatches"), "{}", "app-token") + ] + + def test_scheduler_dispatch_env_prefers_distinct_dispatch_token(monkeypatch): monkeypatch.setenv("GH_TOKEN", "mutation-token") monkeypatch.setenv("SCHEDULER_DISPATCH_TOKEN", "runner-token") @@ -10611,7 +10671,7 @@ def test_actual_opencode_dispatch_path_obeys_one_shared_admission_budget( monkeypatch.setattr(sched, "repository_dispatch_target", lambda _repo: "ContextualWisdomLab/.github") monkeypatch.setattr( sched, - "run_github_dispatch", + "run_github_repository_dispatch", lambda args, *, stdin=None: dispatched.append((args, stdin)), ) diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index 4ab09b1d0c..eba7470024 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -124,7 +124,7 @@ def test_merge_scheduler_dispatches_one_review_by_default() -> None: assert "vars.REVIEW_DISPATCH_LIMIT || '1'" in workflow assert "SCHEDULER_ALLOW_CROSS_REPO_REPOSITORY_DISPATCH" in workflow assert ( - "secrets.PR_REVIEW_MERGE_TOKEN != '' || secrets.OPENCODE_APPROVE_TOKEN != ''" + "steps.scheduler_app_token.outputs.available == 'true' && 'true' || 'false'" in workflow ) @@ -203,14 +203,15 @@ def test_merge_scheduler_uses_native_auto_merge_after_required_checks() -> None: assert "github.event_name == 'repository_dispatch'" in concurrency_contract -def test_merge_scheduler_provides_same_repository_dispatch_credential() -> None: - """Guard the runner-token dispatch credential for central review workflows. - - The scheduler runs inside the same repository as the central required - workflows, so its repository-scoped token is the single dispatch credential. - """ +def test_merge_scheduler_separates_repository_dispatch_from_actions_credentials() -> None: + """Repository dispatch uses only the exchanged, repository-scoped App token.""" workflow = workflow_text("pr-review-merge-scheduler.yml") + assert workflow.count( + "SCHEDULER_REPOSITORY_DISPATCH_TOKEN: " + "${{ steps.scheduler_app_token.outputs.token }}" + ) == 1 + # The runner token remains a distinct credential for central Actions reads. assert workflow.count("SCHEDULER_DISPATCH_TOKEN: ${{ github.token }}") == 1