diff --git a/.github/workflows/pr-review-merge-scheduler.yml b/.github/workflows/pr-review-merge-scheduler.yml index 4ab0c1b83..9d448b1a9 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 != '' && github.event.client_payload.pr_number != '' && 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,80 @@ jobs: echo "token=$app_token" } >>"$GITHUB_OUTPUT" + - name: Validate targeted repository dispatch + id: targeted_dispatch + env: + GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token || github.token }} + TARGET_REPOSITORY_INPUT: ${{ github.event.client_payload.target_repository || '' }} + TARGET_PR_NUMBER: ${{ github.event.client_payload.pr_number || '' }} + TARGET_BASE_BRANCH_INPUT: ${{ github.event.client_payload.base_branch || '' }} + ALLOWED_TARGET_REPOSITORIES: ${{ vars.OPENCODE_REPOSITORY_DISPATCH_TARGETS }} + run: | + set -euo pipefail + + if [ -z "$TARGET_REPOSITORY_INPUT" ]; then + { + printf 'repository=%s\n' "$GITHUB_REPOSITORY" + printf 'base_branch=%s\n' "$DEFAULT_BRANCH" + } >>"$GITHUB_OUTPUT" + exit 0 + fi + + if [ "$GITHUB_EVENT_NAME" != "repository_dispatch" ] || + [ "$GITHUB_REPOSITORY" != "ContextualWisdomLab/.github" ]; then + printf '::error::Targeted scheduler dispatch is restricted to repository_dispatch in ContextualWisdomLab/.github. event=%s execution_repository=%s\n' "$GITHUB_EVENT_NAME" "$GITHUB_REPOSITORY" + exit 1 + fi + if ! [[ "$TARGET_REPOSITORY_INPUT" =~ ^ContextualWisdomLab/[A-Za-z0-9_.-]+$ ]] || + ! [[ "$TARGET_PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then + printf '::error::Targeted scheduler dispatch rejected an invalid repository or pull request number. target=%s pr=%s\n' "${TARGET_REPOSITORY_INPUT:-}" "${TARGET_PR_NUMBER:-}" + exit 1 + fi + + target_allowed=0 + IFS=',' read -r -a allowed_targets <<<"$ALLOWED_TARGET_REPOSITORIES" + for allowed_target in "${allowed_targets[@]}"; do + allowed_target="${allowed_target//[[:space:]]/}" + if [ -n "$allowed_target" ] && + [ "$TARGET_REPOSITORY_INPUT" = "$allowed_target" ]; then + target_allowed=1 + break + fi + done + if [ "$target_allowed" -ne 1 ]; then + printf '::error::Targeted scheduler dispatch rejected repository %s because it is absent from the configured exact allowlist.\n' "$TARGET_REPOSITORY_INPUT" + exit 1 + fi + + pull_json="$(gh api "repos/${TARGET_REPOSITORY_INPUT}/pulls/${TARGET_PR_NUMBER}")" + live_number="$(jq -r '.number // 0' <<<"$pull_json")" + live_state="$(jq -r '.state // empty' <<<"$pull_json")" + live_base_repository="$(jq -r '.base.repo.full_name // empty' <<<"$pull_json")" + live_head_repository="$(jq -r '.head.repo.full_name // empty' <<<"$pull_json")" + live_base_branch="$(jq -r '.base.ref // empty' <<<"$pull_json")" + live_head_sha="$(jq -r '.head.sha // empty' <<<"$pull_json")" + if [ "$live_number" != "$TARGET_PR_NUMBER" ] || + [ "$live_state" != "open" ] || + [ "$live_base_repository" != "$TARGET_REPOSITORY_INPUT" ] || + [ "$live_head_repository" != "$TARGET_REPOSITORY_INPUT" ] || + [ -z "$live_base_branch" ] || + ! [[ "$live_head_sha" =~ ^[0-9a-fA-F]{40}$ ]]; then + printf '::error::Targeted scheduler dispatch rejected closed, cross-repository, or malformed live PR metadata. target=%s pr=%s state=%s base_repository=%s head_repository=%s base_branch=%s head_sha=%s\n' "$TARGET_REPOSITORY_INPUT" "$TARGET_PR_NUMBER" "${live_state:-}" "${live_base_repository:-}" "${live_head_repository:-}" "${live_base_branch:-}" "${live_head_sha:-}" + exit 1 + fi + if [ -n "$TARGET_BASE_BRANCH_INPUT" ] && + [ "$TARGET_BASE_BRANCH_INPUT" != "$live_base_branch" ]; then + printf '::error::Targeted scheduler dispatch base branch does not match the live PR. supplied=%s live=%s\n' "$TARGET_BASE_BRANCH_INPUT" "$live_base_branch" + exit 1 + fi + + { + printf 'repository=%s\n' "$TARGET_REPOSITORY_INPUT" + printf 'base_branch=%s\n' "$live_base_branch" + printf 'head_sha=%s\n' "$live_head_sha" + } >>"$GITHUB_OUTPUT" + printf 'Validated exact targeted scheduler dispatch for %s#%s at %s on base %s.\n' "$TARGET_REPOSITORY_INPUT" "$TARGET_PR_NUMBER" "$live_head_sha" "$live_base_branch" + - name: Resolve trusted scheduler source ref id: trusted_source env: @@ -405,14 +480,16 @@ jobs: if: steps.review_followup.outputs.proceed != 'false' 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 }} + 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 != '' && (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. SCHEDULER_DISPATCH_TOKEN: ${{ github.token }} - SCHEDULER_READ_TOKEN: ${{ github.token }} + SCHEDULER_READ_TOKEN: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.target_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' }} @@ -420,7 +497,7 @@ jobs: set -euo pipefail project_flow="$PROJECT_FLOW_INPUT" if [ -z "$project_flow" ]; then - case "$DEFAULT_BRANCH" in + case "$TARGET_DEFAULT_BRANCH" in main|master) project_flow="github-flow" ;; develop) project_flow="git-flow" ;; *) project_flow="github-flow" ;; @@ -435,8 +512,8 @@ jobs: branch_update_limit="1" fi args=( - --repo "$GITHUB_REPOSITORY" - --base-branch "$DEFAULT_BRANCH" + --repo "$TARGET_REPOSITORY" + --base-branch "$TARGET_DEFAULT_BRANCH" --max-prs "$MAX_PRS" --project-flow "$project_flow" --review-workflow "Required OpenCode Review" diff --git a/docs/org-required-workflow-rollout.md b/docs/org-required-workflow-rollout.md index cc004575f..9c42ab063 100644 --- a/docs/org-required-workflow-rollout.md +++ b/docs/org-required-workflow-rollout.md @@ -48,6 +48,34 @@ The central `.github/workflows/opencode-review.yml` is now part of the active or - Model-exhaustion posture: command exit codes and deterministic checks cannot synthesize an approval. Exhaustion remains `MODEL_OUTPUT_UNAVAILABLE`; only a prior real-model approval bound to the exact current head can satisfy the review gate after all checks, alerts, and threads are revalidated. - Adversarial-evidence posture: every probe must cite its exact changed path and positive in-range line in the materialized current-head source tree. Unrelated paths, nonexistent lines, circular claims, and missing observed results fail closed with a concrete rejection reason. +For a bounded current-head retry in one repository, dispatch `merge-scheduler` +to the central repository with `target_repository`, `pr_number`, and the live +`base_branch`. The target must exactly match +`OPENCODE_REPOSITORY_DISPATCH_TARGETS`; the scheduler then re-reads the open PR +and rejects a noncanonical repository name, fork head, base mismatch, malformed +head SHA, or changed/closed PR before using cross-repository credentials: + +```bash +jq -n '{ + event_type: "merge-scheduler", + client_payload: { + target_repository: "ContextualWisdomLab/naruon", + pr_number: 1179, + base_branch: "develop", + trigger_reviews: true, + review_dispatch_limit: "1", + enable_auto_merge: false, + update_branches: false, + merge_mode: "disabled" + } +}' | gh api --method POST \ + repos/ContextualWisdomLab/.github/dispatches --input - +``` + +Use the canonical `full_name` returned by the GitHub repository API. Keep +mutation options disabled for an evidence-only retry; enabling branch updates +or merge behavior is a separate operational decision. + Keep the OpenCode required workflow active only while the central workflow keeps proving current-head coverage, CodeGraph initialization, bounded evidence, model review output, and approval-gate publication on the current head. ## Code scanning required workflow posture diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 3934b71e3..6deef2f22 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1464,7 +1464,14 @@ 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: ${{ github.event_name == 'repository_dispatch' " + "&& github.event.client_payload.target_repository != '' && " + "(secrets.PR_REVIEW_MERGE_TOKEN || " + "secrets.OPENCODE_APPROVE_TOKEN || " + "steps.scheduler_app_token.outputs.token) || github.token }}" + 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_opencode_workflow_shell_syntax.py b/tests/test_opencode_workflow_shell_syntax.py index 12bd003d0..ec6edca40 100644 --- a/tests/test_opencode_workflow_shell_syntax.py +++ b/tests/test_opencode_workflow_shell_syntax.py @@ -1,3 +1,5 @@ +import json +import os import shutil import subprocess import sys @@ -117,3 +119,147 @@ def test_merge_scheduler_review_followup_run_block_is_valid_bash(): ) assert result.returncode == 0, result.stderr + + +def test_merge_scheduler_targeted_dispatch_run_block_is_valid_bash(): + """The exact-target allowlist and live-PR validation stays valid Bash.""" + if sys.platform == "win32": + return + bash = shutil.which("bash") + if bash is None: + return + + workflow_text = ( + REPO_ROOT / ".github/workflows/pr-review-merge-scheduler.yml" + ).read_text(encoding="utf-8") + script = _extract_run_block( + workflow_text, + "Validate targeted repository dispatch", + ) + result = subprocess.run( + [bash, "-n"], + input=script, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + + +def test_merge_scheduler_targeted_dispatch_validates_live_exact_pr(tmp_path): + """Only an allowlisted same-repository open PR reaches scheduler outputs.""" + if sys.platform == "win32": + return + bash = shutil.which("bash") + jq = shutil.which("jq") + if bash is None or jq is None: + return + + workflow_text = ( + REPO_ROOT / ".github/workflows/pr-review-merge-scheduler.yml" + ).read_text(encoding="utf-8") + script = _extract_run_block( + workflow_text, + "Validate targeted repository dispatch", + ) + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + fake_gh = fake_bin / "gh" + fake_gh.write_text( + """#!/usr/bin/env bash +set -euo pipefail +test "$1" = api +test "$2" = repos/ContextualWisdomLab/naruon/pulls/1179 +printf '%s\\n' "$FAKE_PULL_JSON" +""", + encoding="utf-8", + ) + fake_gh.chmod(0o755) + pull = { + "number": 1179, + "state": "open", + "base": { + "ref": "develop", + "repo": {"full_name": "ContextualWisdomLab/naruon"}, + }, + "head": { + "sha": "4afd4af7ad343660356791873d940aa2846f40c2", + "repo": {"full_name": "ContextualWisdomLab/naruon"}, + }, + } + output = tmp_path / "github-output" + env = { + **os.environ, + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "FAKE_PULL_JSON": json.dumps(pull), + "GITHUB_EVENT_NAME": "repository_dispatch", + "GITHUB_REPOSITORY": "ContextualWisdomLab/.github", + "GITHUB_OUTPUT": str(output), + "DEFAULT_BRANCH": "main", + "TARGET_REPOSITORY_INPUT": "ContextualWisdomLab/naruon", + "TARGET_PR_NUMBER": "1179", + "TARGET_BASE_BRANCH_INPUT": "develop", + "ALLOWED_TARGET_REPOSITORIES": ( + "ContextualWisdomLab/.github, ContextualWisdomLab/naruon" + ), + } + + accepted = subprocess.run( + [bash], + input=script, + text=True, + capture_output=True, + check=False, + env=env, + ) + + assert accepted.returncode == 0, accepted.stderr + assert output.read_text(encoding="utf-8").splitlines() == [ + "repository=ContextualWisdomLab/naruon", + "base_branch=develop", + "head_sha=4afd4af7ad343660356791873d940aa2846f40c2", + ] + + output.unlink() + rejected_env = { + **env, + "ALLOWED_TARGET_REPOSITORIES": "ContextualWisdomLab/.github", + } + rejected = subprocess.run( + [bash], + input=script, + text=True, + capture_output=True, + check=False, + env=rejected_env, + ) + + assert rejected.returncode == 1 + assert "absent from the configured exact allowlist" in rejected.stdout + assert not output.exists() + + output.unlink(missing_ok=True) + cross_repo_pull = { + **pull, + "head": { + **pull["head"], + "repo": {"full_name": "outside/fork"}, + }, + } + cross_repo_env = { + **env, + "FAKE_PULL_JSON": json.dumps(cross_repo_pull), + } + cross_repo = subprocess.run( + [bash], + input=script, + text=True, + capture_output=True, + check=False, + env=cross_repo_env, + ) + + assert cross_repo.returncode == 1 + assert "cross-repository" in cross_repo.stdout + assert not output.exists() diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index 96c3c3e86..50601fbff 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -56,6 +56,47 @@ def test_merge_scheduler_provides_same_repository_dispatch_credential() -> None: assert workflow.count("SCHEDULER_DISPATCH_TOKEN: ${{ github.token }}") == 2 +def test_targeted_scheduler_dispatch_is_allowlisted_and_exact_pr_scoped() -> None: + """Central single-PR dispatch must validate live metadata before cross-repo use.""" + workflow = workflow_text("pr-review-merge-scheduler.yml") + validation = workflow_step(workflow, "Validate targeted repository dispatch") + inspect = workflow_step(workflow, "Inspect PR review and merge queue") + + assert "TARGET_REPOSITORY_INPUT:" in validation + assert "TARGET_PR_NUMBER:" in validation + assert "TARGET_BASE_BRANCH_INPUT:" in validation + assert ( + "ALLOWED_TARGET_REPOSITORIES: ${{ " + "vars.OPENCODE_REPOSITORY_DISPATCH_TARGETS }}" + ) in validation + assert 'GITHUB_REPOSITORY" != "ContextualWisdomLab/.github"' in validation + assert "target_allowed=0" in validation + assert '"repos/${TARGET_REPOSITORY_INPUT}/pulls/${TARGET_PR_NUMBER}"' in validation + assert '[ "$live_state" != "open" ]' in validation + assert '[ "$live_base_repository" != "$TARGET_REPOSITORY_INPUT" ]' in validation + assert '[ "$live_head_repository" != "$TARGET_REPOSITORY_INPUT" ]' in validation + assert "Targeted scheduler dispatch base branch does not match the live PR" in validation + assert "TARGET_REPOSITORY: ${{ steps.targeted_dispatch.outputs.repository }}" in inspect + assert ( + "TARGET_DEFAULT_BRANCH: ${{ steps.targeted_dispatch.outputs.base_branch }}" + in inspect + ) + assert '--repo "$TARGET_REPOSITORY"' in inspect + assert '--base-branch "$TARGET_DEFAULT_BRANCH"' in inspect + assert 'args+=(--pr-number "$PULL_REQUEST_NUMBER")' in inspect + assert ( + "github.event_name == 'repository_dispatch' && " + "github.event.client_payload.target_repository != '' && " + "(secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || " + "steps.scheduler_app_token.outputs.token) || github.token" + ) in inspect + assert ( + "format('target-{0}-pr-{1}', " + "github.event.client_payload.target_repository, " + "github.event.client_payload.pr_number)" + ) in workflow + + def test_privileged_review_retries_use_default_branch_repository_dispatch() -> None: """Privileged retries must never load workflow code from a selected ref.""" expected_types = {