diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index 1966a7562..1e596539c 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -37,6 +37,7 @@ jobs: permissions: contents: read pull-requests: read + id-token: write outputs: target_repository: ${{ steps.validate.outputs.target_repository }} pr_number: ${{ steps.validate.outputs.pr_number }} @@ -46,10 +47,81 @@ jobs: head_sha: ${{ steps.validate.outputs.head_sha }} is_private: ${{ steps.validate.outputs.is_private }} steps: + - name: Exchange OpenCode app token for target repository metadata reads + id: metadata_read_app_token + if: >- + github.event_name == 'repository_dispatch' + && github.event.client_payload.target_repository != '' + && github.event.client_payload.target_repository != github.repository + env: + OIDC_AUDIENCE: opencode-github-action + OPENCODE_API_BASE_URL: https://api.opencode.ai + run: | + set -euo pipefail + + mark_unavailable() { + echo "available=false" >>"$GITHUB_OUTPUT" + } + + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || + [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then + echo "OpenCode app token exchange unavailable: OIDC request environment is missing." + mark_unavailable + exit 0 + fi + + request_url="${ACTIONS_ID_TOKEN_REQUEST_URL}" + separator="&" + case "$request_url" in + *\?*) ;; + *) separator="?" ;; + esac + + if ! oidc_response="$( + curl -fsS \ + -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ + "${request_url}${separator}audience=${OIDC_AUDIENCE}" + )"; then + echo "OpenCode app token exchange unavailable: OIDC token request did not complete." + mark_unavailable + exit 0 + fi + + oidc_token="$(jq -r '.value // empty' <<<"$oidc_response")" + if [ -z "$oidc_token" ]; then + echo "OpenCode app token exchange unavailable: OIDC token response was empty." + mark_unavailable + exit 0 + fi + + if ! token_response="$( + curl -fsS \ + -X POST \ + -H "Authorization: Bearer ${oidc_token}" \ + "${OPENCODE_API_BASE_URL}/exchange_github_app_token" + )"; then + echo "OpenCode app token exchange unavailable: app token request did not complete." + mark_unavailable + exit 0 + fi + + app_token="$(jq -r '.token // empty' <<<"$token_response")" + if [ -z "$app_token" ]; then + echo "OpenCode app token exchange unavailable: app token response was empty." + mark_unavailable + exit 0 + fi + + echo "::add-mask::$app_token" + { + echo "available=true" + echo "token=$app_token" + } >>"$GITHUB_OUTPUT" + - name: Bind workflow inputs to live organization pull request metadata id: validate env: - GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || github.token }} + GH_TOKEN: ${{ steps.metadata_read_app_token.outputs.token || secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || github.token }} EVENT_NAME: ${{ github.event_name }} # A rerun retains github.actor from the original dispatch; authorize # the identity that initiated the current run or rerun instead. diff --git a/.github/workflows/pr-review-merge-scheduler.yml b/.github/workflows/pr-review-merge-scheduler.yml index 9d448b1a9..8e1157060 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 == 'schedule' && format('schedule-{0}', github.event.schedule) || 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 || @@ -563,7 +564,11 @@ jobs: (github.event_name == 'repository_dispatch' && github.event.client_payload.org_sweep == true) ) runs-on: ubuntu-latest - timeout-minutes: 30 + # The complete organization walk exceeded the legacy 30-minute boundary in + # production. Keep one running and one latest pending */15 sweep through the + # schedule-specific concurrency key above, while allowing the current walk + # enough time to finish instead of cancelling before later repositories. + timeout-minutes: 60 permissions: actions: write checks: read @@ -583,10 +588,10 @@ jobs: ORG_SWEEP_MAX_PRS: ${{ github.event.client_payload.max_prs || inputs.max_prs || vars.ORG_SWEEP_MAX_PRS || '1000' }} ORG_SWEEP_REVIEW_DISPATCH_LIMIT: ${{ github.event.client_payload.review_dispatch_limit || inputs.review_dispatch_limit || vars.ORG_SWEEP_REVIEW_DISPATCH_LIMIT || '1' }} ORG_SWEEP_BRANCH_UPDATE_LIMIT: ${{ github.event.client_payload.branch_update_limit || inputs.branch_update_limit || vars.ORG_SWEEP_BRANCH_UPDATE_LIMIT || '1' }} - ORG_SWEEP_TRIGGER_REVIEWS: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.trigger_reviews != false || inputs.trigger_reviews == true }} - ORG_SWEEP_ENABLE_AUTO_MERGE: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.enable_auto_merge != false || inputs.enable_auto_merge == true }} + ORG_SWEEP_TRIGGER_REVIEWS: ${{ github.event_name == 'schedule' || github.event_name == 'repository_dispatch' && github.event.client_payload.trigger_reviews != false || inputs.trigger_reviews == true }} + ORG_SWEEP_ENABLE_AUTO_MERGE: ${{ github.event_name == 'schedule' || github.event_name == 'repository_dispatch' && github.event.client_payload.enable_auto_merge != false || inputs.enable_auto_merge == true }} ORG_SWEEP_MERGE_MODE: ${{ github.event.client_payload.merge_mode || inputs.merge_mode || 'direct_or_auto' }} - ORG_SWEEP_UPDATE_BRANCHES: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.update_branches != false || inputs.update_branches == true }} + ORG_SWEEP_UPDATE_BRANCHES: ${{ github.event_name == 'schedule' || github.event_name == 'repository_dispatch' && github.event.client_payload.update_branches != false || inputs.update_branches == true }} ORG_SWEEP_STALE_QUEUE_HOURS: ${{ vars.ORG_SWEEP_STALE_QUEUE_HOURS || '24' }} # A repository the sweep credential structurally cannot read (the OpenCode # app is not installed there / the PR_REVIEW_MERGE_TOKEN lacks it) returns diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index a7d36b417..6afe1a944 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1445,8 +1445,13 @@ assert_pr_review_merge_scheduler_uses_github_actions_bot_token() { assert_file_not_contains "$workflow_file" "github.event.pull_request.number == 240" "scheduler must not hard-code repository-specific PR bypasses" assert_file_contains "$workflow_file" "github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number)" "scheduler scopes pull_request_target concurrency to the active PR" assert_file_contains "$workflow_file" "github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number && format('pr-{0}', github.event.workflow_run.pull_requests[0].number)" "scheduler scopes workflow_run concurrency to the completed review PR" + assert_file_contains "$workflow_file" "github.event_name == 'schedule' && format('schedule-{0}', github.event.schedule)" "scheduler isolates the 15-minute organization sweep from the separate 30-minute scheduled scan" assert_file_contains "$workflow_file" "github.event_name == 'repository_dispatch' && github.run_id" "scheduler keeps manual queue scans isolated per run" assert_file_contains "$workflow_file" "cancel-in-progress: \${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'repository_dispatch' }}" "scheduler cancels stale PR/review/manual queue scans instead of accumulating merge/update attempts" + assert_file_contains "$workflow_file" "timeout-minutes: 60" "organization sweep has enough headroom to finish the complete repository walk" + assert_file_contains "$workflow_file" "ORG_SWEEP_TRIGGER_REVIEWS: \${{ github.event_name == 'schedule' ||" "scheduled organization sweeps retry missing current-head OpenCode reviews" + assert_file_contains "$workflow_file" "ORG_SWEEP_ENABLE_AUTO_MERGE: \${{ github.event_name == 'schedule' ||" "scheduled organization sweeps merge approved current heads" + assert_file_contains "$workflow_file" "ORG_SWEEP_UPDATE_BRANCHES: \${{ github.event_name == 'schedule' ||" "scheduled organization sweeps refresh eligible stale branches" assert_file_contains "$workflow_file" 'github.event.workflow_run.pull_requests[0].number' "scheduler scopes OpenCode workflow_run events to the completed review PR" assert_file_contains "$workflow_file" "github.event.client_payload.trigger_reviews != false" "scheduler enables review dispatch by default for default-branch dispatch events" assert_file_contains "$workflow_file" "github.event_name == 'workflow_run' || github.event_name == 'push'" "scheduler can dispatch a bounded follow-up OpenCode review after review workflow completion" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index bd300a839..a75e88a40 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -346,6 +346,23 @@ def test_opencode_target_coverage_materializes_only_after_authorized_dispatch(): assert " coverage-source-tree:\n" in workflow assert " coverage-evidence:\n" in workflow + metadata_start = workflow.index(" validate-pr-metadata:\n") + metadata_end = workflow.index("\n coverage-source-tree:", metadata_start) + metadata_job = workflow[metadata_start:metadata_end] + assert "id-token: write" in metadata_job + assert ( + "Exchange OpenCode app token for target repository metadata reads" + in metadata_job + ) + assert ( + "GH_TOKEN: ${{ steps.metadata_read_app_token.outputs.token || " + "secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || github.token }}" + ) in metadata_job + assert ( + "github.event.client_payload.target_repository != github.repository" + in metadata_job + ) + source_start = workflow.index(" coverage-source-tree:\n") source_end = workflow.index("\n coverage-evidence:", source_start) source_job = workflow[source_start:source_end] diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index 1ea6376fe..84cbe9b2b 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -535,7 +535,9 @@ def test_org_queue_sweep_covers_target_repositories_on_a_heartbeat() -> None: github.token silently), skip the central repository itself, and fail with a visible reason when it cannot mutate sibling repositories. The sweep runs every 15 minutes so an approval that lands after a PR's last event is - auto-updated/merged within ~15 minutes instead of idling for up to an hour. + auto-updated/merged promptly instead of idling indefinitely. Its cron has a + distinct concurrency key from the separate 30-minute scan, and the job has + enough runtime headroom to finish a complete organization walk. """ workflow = workflow_text("pr-review-merge-scheduler.yml") @@ -544,6 +546,20 @@ def test_org_queue_sweep_covers_target_repositories_on_a_heartbeat() -> None: assert "github.repository == 'ContextualWisdomLab/.github'" in workflow assert "github.event.schedule == '*/15 * * * *'" in workflow assert "github.event.client_payload.org_sweep == true" in workflow + assert ( + "github.event_name == 'schedule' && format('schedule-{0}', " + "github.event.schedule)" + ) in workflow + org_sweep_header = workflow.split(" org-queue-sweep:", 1)[1].split( + " permissions:", 1 + )[0] + assert "timeout-minutes: 60" in org_sweep_header + for setting in ( + "ORG_SWEEP_TRIGGER_REVIEWS", + "ORG_SWEEP_ENABLE_AUTO_MERGE", + "ORG_SWEEP_UPDATE_BRANCHES", + ): + assert f"{setting}: ${{{{ github.event_name == 'schedule' ||" in workflow # The single-repository scan must not double-run on the sweep cron. assert "github.event.schedule != '*/15 * * * *'" in workflow assert "github.event.client_payload.org_sweep != true" in workflow @@ -636,18 +652,18 @@ def test_org_queue_sweep_manual_cadence_inputs_reach_the_sweep_job() -> None: "ORG_SWEEP_MAX_PRS: ${{ github.event.client_payload.max_prs || inputs.max_prs || vars.ORG_SWEEP_MAX_PRS || '1000' }}" ) in workflow assert ( - "ORG_SWEEP_TRIGGER_REVIEWS: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.trigger_reviews != false || inputs.trigger_reviews == true }}" + "ORG_SWEEP_TRIGGER_REVIEWS: ${{ github.event_name == 'schedule' || github.event_name == 'repository_dispatch' && github.event.client_payload.trigger_reviews != false || inputs.trigger_reviews == true }}" in workflow ) assert ( - "ORG_SWEEP_ENABLE_AUTO_MERGE: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.enable_auto_merge != false || inputs.enable_auto_merge == true }}" + "ORG_SWEEP_ENABLE_AUTO_MERGE: ${{ github.event_name == 'schedule' || github.event_name == 'repository_dispatch' && github.event.client_payload.enable_auto_merge != false || inputs.enable_auto_merge == true }}" ) in workflow assert ( "ORG_SWEEP_MERGE_MODE: ${{ github.event.client_payload.merge_mode || inputs.merge_mode || 'direct_or_auto' }}" in workflow ) assert ( - "ORG_SWEEP_UPDATE_BRANCHES: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.update_branches != false || inputs.update_branches == true }}" + "ORG_SWEEP_UPDATE_BRANCHES: ${{ github.event_name == 'schedule' || github.event_name == 'repository_dispatch' && github.event.client_payload.update_branches != false || inputs.update_branches == true }}" in workflow ) assert 'if [ "$ORG_SWEEP_TRIGGER_REVIEWS" = "true" ]; then' in workflow