Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/pr-review-merge-scheduler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,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
Expand Down Expand Up @@ -598,6 +604,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
Expand Down
35 changes: 33 additions & 2 deletions scripts/ci/pr_review_merge_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 "
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
65 changes: 65 additions & 0 deletions tests/test_pr_review_merge_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions tests/test_required_workflow_queue_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading