Skip to content
Open
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
87 changes: 81 additions & 6 deletions .github/workflows/pr-review-merge-scheduler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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" ;;
Expand All @@ -435,17 +507,20 @@ 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"
--review-dispatch-limit "$review_dispatch_limit"
--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)
Expand Down
56 changes: 55 additions & 1 deletion scripts/ci/pr_review_merge_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion tests/test_opencode_agent_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading