diff --git a/.github/workflows/pr-review-merge-scheduler.yml b/.github/workflows/pr-review-merge-scheduler.yml index 4ab0c1b8..a41f039e 100644 --- a/.github/workflows/pr-review-merge-scheduler.yml +++ b/.github/workflows/pr-review-merge-scheduler.yml @@ -94,6 +94,7 @@ concurrency: github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number && format('pr-{0}', github.event.workflow_run.pull_requests[0].number) || github.event_name == 'workflow_call' && inputs.pr_number != '' && format('pr-{0}', inputs.pr_number) || github.event_name == 'workflow_call' && inputs.base_branch != '' && format('call-{0}', inputs.base_branch) || + github.event_name == 'repository_dispatch' && github.event.client_payload.target_repository != '' && format('target-{0}-pr-{1}', github.event.client_payload.target_repository, github.event.client_payload.pr_number) || 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' && github.run_id || github.ref }} @@ -227,6 +228,68 @@ jobs: echo "token=$app_token" } >>"$GITHUB_OUTPUT" + - name: Bind targeted central dispatch to live pull request metadata + id: dispatch_target + if: >- + github.event_name == 'repository_dispatch' && + github.event.client_payload.target_repository != '' + env: + GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token || github.token }} + 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' }} + TARGET_REPOSITORY: ${{ github.event.client_payload.target_repository }} + TARGET_PR_NUMBER: ${{ github.event.client_payload.pr_number }} + run: | + set -euo pipefail + + if [ "$GITHUB_REPOSITORY" != "ContextualWisdomLab/.github" ]; then + echo "::error::Cross-repository targeted dispatch is only allowed from ContextualWisdomLab/.github." + exit 1 + fi + if [[ ! "$TARGET_REPOSITORY" =~ ^ContextualWisdomLab/[A-Za-z0-9._-]+$ ]]; then + echo "::error::target_repository must name a ContextualWisdomLab repository." + exit 1 + fi + if [[ ! "$TARGET_PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::pr_number must be a positive integer for targeted dispatch." + exit 1 + fi + if [ "$MUTATION_TOKEN_SOURCE" = "github-token" ]; then + echo "::error::Targeted central dispatch has no sibling-repository credential. Configure PR_REVIEW_MERGE_TOKEN, OPENCODE_APPROVE_TOKEN, or the OpenCode app token exchange." + exit 1 + fi + + pull_json="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${TARGET_PR_NUMBER}")" + live_state="$(jq -r '.state // empty' <<<"$pull_json")" + base_repository="$(jq -r '.base.repo.full_name // empty' <<<"$pull_json")" + base_branch="$(jq -r '.base.ref // empty' <<<"$pull_json")" + base_sha="$(jq -r '.base.sha // empty' <<<"$pull_json")" + head_sha="$(jq -r '.head.sha // empty' <<<"$pull_json")" + + if [ "$live_state" != "open" ] || [ "$base_repository" != "$TARGET_REPOSITORY" ]; then + echo "::error::Targeted dispatch must resolve to an open PR whose base repository exactly matches target_repository." + exit 1 + fi + if [[ ! "$base_branch" =~ ^[A-Za-z0-9._/-]+$ ]] || + [[ "$base_branch" == *".."* ]] || [[ "$base_branch" == *"@{"* ]] || + [[ "$base_branch" == .* ]] || [[ "$base_branch" == */.* ]] || + [[ "$base_branch" == */ ]] || [[ "$base_branch" == *. ]]; then + echo "::error::Live PR base ref is not safe for targeted scheduler arguments." + exit 1 + fi + if [[ ! "$base_sha" =~ ^[0-9a-fA-F]{40}$ ]] || [[ ! "$head_sha" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "::error::Live PR metadata did not provide exact 40-character base and head SHAs." + exit 1 + fi + + { + echo "repository=$TARGET_REPOSITORY" + echo "pr_number=$TARGET_PR_NUMBER" + echo "base_branch=$base_branch" + echo "base_sha=$base_sha" + echo "head_sha=$head_sha" + } >>"$GITHUB_OUTPUT" + echo "Targeted scheduler bound to ${TARGET_REPOSITORY}#${TARGET_PR_NUMBER} at head ${head_sha} (base ${base_branch}@${base_sha})." + - name: Resolve trusted scheduler source ref id: trusted_source env: @@ -406,21 +469,30 @@ 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 }} + # A targeted central run controls only central Actions. The app/PAT in + # GH_TOKEN reads and mutates the sibling PR, while github.token inspects + # and dispatches the central required workflows without making a + # doomed sibling Actions API request first. + SCHEDULER_ACTIONS_REPOSITORY: ${{ steps.dispatch_target.outputs.repository != '' && github.repository || '' }} # 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_READ_TOKEN: ${{ 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_TARGET_REPOSITORY: ${{ steps.dispatch_target.outputs.repository || github.repository }} + SCHEDULER_TARGET_BASE_BRANCH: ${{ steps.dispatch_target.outputs.base_branch || env.DEFAULT_BRANCH }} + SCHEDULER_TARGET_PR_NUMBER: ${{ steps.dispatch_target.outputs.pr_number || env.PULL_REQUEST_NUMBER }} + SCHEDULER_TARGET_HEAD_SHA: ${{ steps.dispatch_target.outputs.head_sha || '' }} run: | set -euo pipefail project_flow="$PROJECT_FLOW_INPUT" if [ -z "$project_flow" ]; then - case "$DEFAULT_BRANCH" in + case "$SCHEDULER_TARGET_BASE_BRANCH" in main|master) project_flow="github-flow" ;; develop) project_flow="git-flow" ;; *) project_flow="github-flow" ;; @@ -435,8 +507,8 @@ jobs: branch_update_limit="1" fi args=( - --repo "$GITHUB_REPOSITORY" - --base-branch "$DEFAULT_BRANCH" + --repo "$SCHEDULER_TARGET_REPOSITORY" + --base-branch "$SCHEDULER_TARGET_BASE_BRANCH" --max-prs "$MAX_PRS" --project-flow "$project_flow" --review-workflow "Required OpenCode Review" @@ -444,8 +516,11 @@ jobs: --branch-update-limit "$branch_update_limit" --stale-opencode-minutes "$STALE_OPENCODE_MINUTES" ) - if [ -n "$PULL_REQUEST_NUMBER" ]; then - args+=(--pr-number "$PULL_REQUEST_NUMBER") + if [ -n "$SCHEDULER_TARGET_PR_NUMBER" ]; then + args+=(--pr-number "$SCHEDULER_TARGET_PR_NUMBER") + fi + if [ -n "$SCHEDULER_TARGET_HEAD_SHA" ]; then + args+=(--expected-head-sha "$SCHEDULER_TARGET_HEAD_SHA") fi if [ "$DRY_RUN" = "true" ]; then args+=(--dry-run) diff --git a/scripts/ci/pr_review_merge_scheduler.py b/scripts/ci/pr_review_merge_scheduler.py index c13adfe9..871d124a 100644 --- a/scripts/ci/pr_review_merge_scheduler.py +++ b/scripts/ci/pr_review_merge_scheduler.py @@ -489,6 +489,22 @@ def run_github_actions(args: Sequence[str], *, stdin: str | None = None) -> str: return run_with_env(args, stdin=stdin, env=env) +def scheduler_actions_repository_in_scope(repo: str) -> bool: + """Return whether the Actions control credential may access ``repo``. + + A central targeted scheduler run deliberately uses the central repository's + ``github.token`` for Actions control while an app/PAT reads and mutates the + sibling target repository. The runner token cannot read sibling Actions + runs, so querying them would turn a safe targeted dispatch into a 403 before + the central workflow can be inspected or dispatched. + """ + target_repo = validate_github_repository(repo) + scoped_repo = (os.environ.get("SCHEDULER_ACTIONS_REPOSITORY") or "").strip() + if not scoped_repo: + return True + return target_repo == validate_github_repository(scoped_repo) + + def scheduler_dispatch_env() -> dict[str, str] | None: """Return an env override for central repository dispatch when configured. @@ -1818,12 +1834,24 @@ def rerun_actions_job(repo: str, job_id: str, *, dry_run: bool, action: str) -> """Ask GitHub Actions to rerun an existing required-workflow job.""" if dry_run: return + if not scheduler_actions_repository_in_scope(repo): + raise RuntimeError( + f"{action} refused for {repo}; the Actions credential is scoped to " + f"{os.environ.get('SCHEDULER_ACTIONS_REPOSITORY')}" + ) 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, statuses: Sequence[str] = ("queued", "in_progress")) -> list[dict[str, Any]]: """Return active workflow runs for a repository.""" + if not scheduler_actions_repository_in_scope(repo): + print( + "Actions run inspection skipped for " + f"{repo}: control credential is scoped to " + f"{os.environ.get('SCHEDULER_ACTIONS_REPOSITORY')}" + ) + return [] runs: list[dict[str, Any]] = [] for status in statuses: payload = json.loads( @@ -2099,7 +2127,7 @@ def dispatch_opencode_review(repo: str, workflow: str, pr: dict[str, Any], *, dr def dispatch_strix_evidence(repo: str, workflow: str, pr: dict[str, Any], *, dry_run: bool) -> str: """Dispatch same-head Strix workflow evidence before OpenCode reviews.""" job_id = matching_actions_job_id(pr, is_strix_context) - if job_id: + if job_id and scheduler_actions_repository_in_scope(repo): rerun_actions_job(repo, job_id, dry_run=dry_run, action="rerun-strix-evidence") return "rerun" if not dry_run else "dry_run" if dry_run: @@ -3672,6 +3700,11 @@ def parse_args(argv: list[str]) -> argparse.Namespace: parser.add_argument("--project-flow", default=os.environ.get("PROJECT_FLOW", "")) parser.add_argument("--max-prs", type=int, default=100) parser.add_argument("--pr-number", type=int, default=0) + parser.add_argument( + "--expected-head-sha", + default="", + help="Optional exact PR head bound by a trusted targeted-dispatch preflight", + ) parser.add_argument("--dry-run", action="store_true") parser.add_argument("--trigger-reviews", action=argparse.BooleanOptionalAction, default=True) parser.add_argument( @@ -3718,11 +3751,32 @@ def main(argv: list[str]) -> int: raise SystemExit("--project-flow is required") if args.pr_number < 0: raise SystemExit("--pr-number must not be negative") + if args.expected_head_sha: + if not args.pr_number: + raise SystemExit("--expected-head-sha requires --pr-number") + try: + validate_git_sha(args.expected_head_sha) + except ValueError as exc: + raise SystemExit(str(exc)) from exc if args.review_dispatch_limit < -1: raise SystemExit("--review-dispatch-limit must be -1 or greater") if args.branch_update_limit < -1: raise SystemExit("--branch-update-limit must be -1 or greater") prs = fetch_pr(args.repo, args.pr_number) if args.pr_number else fetch_open_prs(args.repo, args.max_prs) + if args.expected_head_sha: + if not prs: + print( + f"Targeted scheduler skipped: PR #{args.pr_number} is no longer available in {args.repo}." + ) + return 0 + live_head = str(prs[0].get("headRefOid") or "") + if live_head.lower() != args.expected_head_sha.lower(): + print( + "Targeted scheduler skipped stale dispatch: " + f"{args.repo}#{args.pr_number} expected head {args.expected_head_sha}, " + f"live head {live_head or 'missing'}." + ) + return 0 decisions = [] review_dispatches_used = 0 branch_updates_used = 0 diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index c8eb1d34..6e6759f4 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1433,7 +1433,12 @@ def test_merge_scheduler_uses_escalating_mutation_credentials(): assert "secrets.PR_REVIEW_MERGE_TOKEN" in workflow assert "secrets.OPENCODE_APPROVE_TOKEN" in workflow assert "steps.scheduler_app_token.outputs.token" in workflow - assert "SCHEDULER_READ_TOKEN: ${{ github.token }}" in workflow + assert ( + "SCHEDULER_READ_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || " + "secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token || " + "github.token }}" + ) in workflow + assert "SCHEDULER_ACTIONS_REPOSITORY:" in workflow assert "SCHEDULER_MUTATION_TOKEN_SOURCE" in workflow assert 'default: "1"' in workflow assert 'review_dispatch_limit="-1"' in workflow diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index 1fe10da7..1ed3e623 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -1995,6 +1995,62 @@ def test_scheduler_dispatch_env_is_noop_without_distinct_token(monkeypatch): assert sched.scheduler_dispatch_env() is None +def test_actions_repository_scope_skips_inaccessible_sibling_queries(monkeypatch, capsys): + """A central runner token must not query sibling Actions before dispatch.""" + monkeypatch.setenv( + "SCHEDULER_ACTIONS_REPOSITORY", + "ContextualWisdomLab/.github", + ) + monkeypatch.setattr( + sched, + "run_github_actions", + lambda *args, **kwargs: pytest.fail("out-of-scope Actions API call"), + ) + + assert sched.active_workflow_runs("ContextualWisdomLab/scopeweave") == [] + assert "Actions run inspection skipped for ContextualWisdomLab/scopeweave" in capsys.readouterr().out + + +def test_actions_repository_scope_keeps_central_run_inspection(monkeypatch): + """The same scope still permits deduplication against central workflows.""" + calls = [] + monkeypatch.setenv( + "SCHEDULER_ACTIONS_REPOSITORY", + "ContextualWisdomLab/.github", + ) + monkeypatch.setattr( + sched, + "run_github_actions", + lambda args, stdin=None: calls.append(args) or json.dumps({"workflow_runs": []}), + ) + + assert sched.active_workflow_runs("ContextualWisdomLab/.github") == [] + assert len(calls) == 2 + assert all("repos/ContextualWisdomLab/.github/actions/runs" in call for call in calls) + + +def test_actions_repository_scope_refuses_sibling_job_rerun(monkeypatch): + """A central runner token must not rerun a sibling repository job.""" + monkeypatch.setenv( + "SCHEDULER_ACTIONS_REPOSITORY", + "ContextualWisdomLab/.github", + ) + + with pytest.raises( + RuntimeError, + match=( + "retry failed job refused for ContextualWisdomLab/scopeweave; " + "the Actions credential is scoped to ContextualWisdomLab/.github" + ), + ): + sched.rerun_actions_job( + "ContextualWisdomLab/scopeweave", + "42", + dry_run=False, + action="retry failed job", + ) + + def test_run_github_dispatch_uses_dispatch_token_env(monkeypatch): calls = [] monkeypatch.setenv("GH_TOKEN", "mutation-token") @@ -4216,6 +4272,95 @@ def test_main_rejects_invalid_branch_update_limit(): ) +def test_main_expected_head_requires_pr_number(): + with pytest.raises(SystemExit, match="--expected-head-sha requires --pr-number"): + sched.main( + [ + "--repo", + "owner/repo", + "--base-branch", + "main", + "--project-flow", + "github-flow", + "--expected-head-sha", + "a" * 40, + ] + ) + + +def test_main_expected_head_rejects_invalid_sha(): + with pytest.raises(SystemExit, match="invalid git sha"): + sched.main( + [ + "--repo", + "owner/repo", + "--base-branch", + "main", + "--project-flow", + "github-flow", + "--pr-number", + "7", + "--expected-head-sha", + "not-a-sha", + ] + ) + + +def test_main_expected_head_skips_closed_targeted_pr(monkeypatch, capsys): + """A delayed targeted event must no-op after its pull request closes.""" + monkeypatch.setattr(sched, "fetch_pr", lambda repo, number: []) + + assert ( + sched.main( + [ + "--repo", + "owner/repo", + "--base-branch", + "main", + "--project-flow", + "github-flow", + "--pr-number", + "7", + "--expected-head-sha", + "a" * 40, + ] + ) + == 0 + ) + assert ( + "Targeted scheduler skipped: PR #7 is no longer available in owner/repo." + in capsys.readouterr().out + ) + + +def test_main_expected_head_rejects_stale_targeted_dispatch(monkeypatch, capsys): + """A PR synchronize race must not let an old targeted event act on a new head.""" + monkeypatch.setattr( + sched, + "fetch_pr", + lambda repo, number: [make_pr(headRefOid="b" * 40)], + ) + + assert ( + sched.main( + [ + "--repo", + "owner/repo", + "--base-branch", + "main", + "--project-flow", + "github-flow", + "--pr-number", + "7", + "--expected-head-sha", + "a" * 40, + ] + ) + == 0 + ) + assert "Targeted scheduler skipped stale dispatch" in capsys.readouterr().out + + def test_print_summary_self_test_parse_args_and_main(monkeypatch, capsys): sched.print_summary( [sched.Decision(1, "wait", "ready"), sched.Decision(2, "wait", "queued")], diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index 8e66277f..7ff1a144 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -428,6 +428,23 @@ def test_unassociated_review_workflow_runs_do_not_scan_the_whole_pr_queue() -> N assert "github.event.workflow_run.pull_requests[0].number" in workflow +def test_targeted_central_scheduler_binds_live_sibling_pr_metadata() -> None: + """A bounded central dispatch must validate and operate on the requested live PR.""" + workflow = workflow_text("pr-review-merge-scheduler.yml") + + assert "Bind targeted central dispatch to live pull request metadata" in workflow + assert "^ContextualWisdomLab/[A-Za-z0-9._-]+$" in workflow + assert 'pull_json="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${TARGET_PR_NUMBER}")"' in workflow + assert 'base_repository" != "$TARGET_REPOSITORY"' in workflow + assert "Live PR metadata did not provide exact 40-character base and head SHAs" in workflow + assert "SCHEDULER_ACTIONS_REPOSITORY:" in workflow + assert '--repo "$SCHEDULER_TARGET_REPOSITORY"' in workflow + assert '--base-branch "$SCHEDULER_TARGET_BASE_BRANCH"' in workflow + assert 'args+=(--pr-number "$SCHEDULER_TARGET_PR_NUMBER")' in workflow + assert 'args+=(--expected-head-sha "$SCHEDULER_TARGET_HEAD_SHA")' in workflow + assert 'case "$SCHEDULER_TARGET_BASE_BRANCH" in' in workflow + + def test_org_queue_sweep_covers_target_repositories_on_a_heartbeat() -> None: """Guard the org-wide approved-PR fallback sweep contract.