Required PR Review Merge Scheduler #49421
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Required PR Review Merge Scheduler | |
| on: | |
| push: | |
| branches: [main, develop, master] | |
| pull_request_target: | |
| types: [opened, synchronize, reopened, ready_for_review, auto_merge_enabled, closed] | |
| pull_request_review: | |
| types: [submitted, dismissed] | |
| workflow_run: | |
| workflows: ["Required OpenCode Review", "Strix Security Scan"] | |
| types: [completed] | |
| workflow_call: | |
| inputs: | |
| dry_run: | |
| description: Print planned actions without mutating PRs | |
| required: false | |
| default: false | |
| type: boolean | |
| max_prs: | |
| description: Maximum open PRs to inspect | |
| required: false | |
| default: "100" | |
| type: string | |
| pr_number: | |
| description: Optional single pull request number to inspect immediately | |
| required: false | |
| default: "" | |
| type: string | |
| trigger_reviews: | |
| description: Dispatch OpenCode Review for PR heads without current approval | |
| required: false | |
| default: true | |
| type: boolean | |
| review_dispatch_limit: | |
| description: OpenCode/Strix review dispatch budget per scheduler run (-1 dispatches every eligible current-head review) | |
| required: false | |
| default: "1" | |
| type: string | |
| branch_update_limit: | |
| description: Branch update budget per scheduler run (-1 updates every eligible outdated branch) | |
| required: false | |
| default: "1" | |
| type: string | |
| enable_auto_merge: | |
| description: Enable auto-merge for current-head approved PRs | |
| required: false | |
| default: true | |
| type: boolean | |
| merge_mode: | |
| description: "Merge behavior for current-head approved PRs: direct_or_auto, auto, direct, or disabled" | |
| required: false | |
| default: direct_or_auto | |
| type: string | |
| update_branches: | |
| description: Update outdated PR branches after OpenCode approval | |
| required: false | |
| default: true | |
| type: boolean | |
| stale_opencode_minutes: | |
| description: Redispatch OpenCode Review when an in-progress OpenCode check is older than this many minutes | |
| required: false | |
| default: "90" | |
| type: string | |
| project_flow: | |
| description: Project flow, usually github-flow or git-flow | |
| required: false | |
| default: "" | |
| type: string | |
| base_branch: | |
| description: Base branch to scan; defaults to the caller repository default branch | |
| required: false | |
| default: "" | |
| type: string | |
| schedule: | |
| - cron: "*/30 * * * *" | |
| # Every-15-minutes org-wide sweep cadence for the org-queue-sweep job below. Target | |
| # repositories only receive scheduler runs on PR events, review/security | |
| # workflow completion, and protected-branch pushes; a PR whose approval or | |
| # required checks land AFTER its last event has no later trigger and sits | |
| # approved-but-unmerged until a human pushes something. The sweep closes | |
| # that gap on a fixed heartbeat. Runs every 15 minutes so an approval or | |
| # required check that lands after a PR's last event is auto-updated/merged | |
| # within ~15 minutes instead of sitting idle for up to an hour. | |
| - cron: "*/15 * * * *" | |
| repository_dispatch: | |
| types: [merge-scheduler] | |
| concurrency: | |
| group: >- | |
| central-pr-review-merge-scheduler-${{ github.repository }}-${{ | |
| github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number) || | |
| github.event_name == 'pull_request_review' && format('pr-{0}', github.event.pull_request.number) || | |
| 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_run' && !github.event.workflow_run.pull_requests[0].number && format('workflow-run-no-pr-{0}', github.repository) || | |
| 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.org_sweep == true && format('org-sweep-{0}', github.repository) || | |
| 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' && format('repo-dispatch-{0}', github.repository) || | |
| github.ref }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'repository_dispatch' || (github.event_name == 'workflow_run' && !github.event.workflow_run.pull_requests[0].number) }} | |
| # Scorecard Token-Permissions (alert #9): declare a least-privilege default at | |
| # the workflow level. The scan-pr-queue job that actually needs write access | |
| # declares its own elevated permissions block; every other job (and the default | |
| # token) stays read-only. | |
| permissions: | |
| contents: read | |
| jobs: | |
| cancel-closed-pr-runs: | |
| if: github.event_name == 'pull_request_target' && github.event.action == 'closed' | |
| runs-on: ubuntu-24.04 | |
| steps: | |
| - run: echo "PR closed; this run only cancels older runs through workflow concurrency." | |
| scan-pr-queue: | |
| # repository_dispatch review runs do not reliably carry pull_requests metadata. | |
| # Without this guard, one completed central review can wake a repo-wide scan. | |
| # The org-sweep cron and org_sweep dispatches are handled by org-queue-sweep | |
| # below; skipping them here avoids a duplicate same-repository scan. | |
| if: >- | |
| ( | |
| github.event_name != 'pull_request_target' || | |
| github.event.action != 'closed' | |
| ) && | |
| ( | |
| github.event_name != 'workflow_run' || | |
| ( | |
| github.event.workflow_run.conclusion != 'cancelled' && | |
| github.event.workflow_run.pull_requests[0].number | |
| ) | |
| ) && | |
| ( | |
| github.event_name != 'schedule' || | |
| github.event.schedule != '*/15 * * * *' | |
| ) && | |
| ( | |
| github.event_name != 'repository_dispatch' || | |
| github.event.client_payload.org_sweep != true | |
| ) | |
| runs-on: ubuntu-24.04 | |
| permissions: | |
| actions: write | |
| checks: read | |
| contents: write | |
| id-token: write | |
| pull-requests: write | |
| env: | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true | |
| GH_TOKEN: ${{ github.token }} | |
| DEFAULT_BRANCH: ${{ github.event.client_payload.base_branch || inputs.base_branch || github.event.repository.default_branch }} | |
| DRY_RUN: ${{ github.event.client_payload.dry_run == true || inputs.dry_run == true }} | |
| MAX_PRS: ${{ github.event.client_payload.max_prs || inputs.max_prs || '100' }} | |
| PROJECT_FLOW_INPUT: ${{ github.event.client_payload.project_flow || inputs.project_flow || vars.PROJECT_FLOW || '' }} | |
| PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number || github.event.workflow_run.pull_requests[0].number || github.event.client_payload.pr_number || inputs.pr_number || '' }} | |
| TRIGGER_REVIEWS: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_run' || github.event_name == 'push' || github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || (github.event_name == 'repository_dispatch' && github.event.client_payload.trigger_reviews != false) || inputs.trigger_reviews == true }} | |
| REVIEW_DISPATCH_LIMIT_INPUT: ${{ github.event.client_payload.review_dispatch_limit || inputs.review_dispatch_limit || vars.REVIEW_DISPATCH_LIMIT || '1' }} | |
| BRANCH_UPDATE_LIMIT_INPUT: ${{ github.event.client_payload.branch_update_limit || inputs.branch_update_limit || vars.BRANCH_UPDATE_LIMIT || '1' }} | |
| ENABLE_AUTO_MERGE: ${{ github.event_name == 'schedule' || github.event_name == 'push' || github.event_name == 'pull_request_target' || github.event_name == 'workflow_run' || (github.event_name == 'repository_dispatch' && github.event.client_payload.enable_auto_merge != false) || inputs.enable_auto_merge == true }} | |
| MERGE_MODE: ${{ github.event.client_payload.merge_mode || inputs.merge_mode || vars.PR_MERGE_MODE || 'direct_or_auto' }} | |
| UPDATE_BRANCHES: ${{ github.event_name == 'schedule' || github.event_name == 'push' || github.event_name == 'pull_request_target' || github.event_name == 'workflow_run' || (github.event_name == 'repository_dispatch' && github.event.client_payload.update_branches != false) || inputs.update_branches == true }} | |
| STALE_OPENCODE_MINUTES: ${{ github.event.client_payload.stale_opencode_minutes || inputs.stale_opencode_minutes || vars.STALE_OPENCODE_MINUTES || '90' }} | |
| steps: | |
| - name: Exchange OpenCode app token for scheduler mutations | |
| id: scheduler_app_token | |
| 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: 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:-<empty>}" "${TARGET_PR_NUMBER:-<empty>}" | |
| 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")" | |
| target_default_branch="$(gh api "repos/${TARGET_REPOSITORY_INPUT}" --jq '.default_branch // empty')" | |
| if [ "$live_number" != "$TARGET_PR_NUMBER" ] || | |
| [ "$live_state" != "open" ] || | |
| [ "$live_base_repository" != "$TARGET_REPOSITORY_INPUT" ] || | |
| ! [[ "$live_head_repository" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || | |
| [ -z "$live_base_branch" ] || | |
| ! [[ "$live_head_sha" =~ ^[0-9a-fA-F]{40}$ ]] || | |
| ! [[ "$target_default_branch" =~ ^[A-Za-z0-9._/-]+$ ]]; then | |
| printf '::error::Targeted scheduler dispatch rejected closed 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:-<missing>}" "${live_base_repository:-<missing>}" "${live_head_repository:-<missing>}" "${live_base_branch:-<missing>}" "${live_head_sha:-<missing>}" | |
| 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" | |
| # The PR base is validated above but must not be used as the | |
| # scheduler's base branch: a stacked PR would otherwise compare | |
| # equal to its own base and bypass the stacked-review dispatch | |
| # path. The target repository default branch is the classification | |
| # boundary for merge automation and project-flow selection. | |
| printf 'base_branch=%s\n' "$target_default_branch" | |
| printf 'head_sha=%s\n' "$live_head_sha" | |
| } >>"$GITHUB_OUTPUT" | |
| printf 'Validated exact targeted scheduler dispatch for %s#%s at %s (PR base %s; scheduler default branch %s).\n' "$TARGET_REPOSITORY_INPUT" "$TARGET_PR_NUMBER" "$live_head_sha" "$live_base_branch" "$target_default_branch" | |
| - name: Resolve trusted scheduler source ref | |
| id: trusted_source | |
| env: | |
| JOB_CONTEXT_JSON: ${{ toJSON(job) }} | |
| GITHUB_CONTEXT_JSON: ${{ toJSON(github) }} | |
| run: | | |
| set -euo pipefail | |
| python3 <<'PY' >>"$GITHUB_OUTPUT" | |
| import json | |
| import os | |
| import re | |
| import sys | |
| try: | |
| job_context = json.loads(os.environ.get("JOB_CONTEXT_JSON") or "{}") | |
| github_context = json.loads(os.environ.get("GITHUB_CONTEXT_JSON") or "{}") | |
| except json.JSONDecodeError as exc: | |
| print(f"::error::Could not parse GitHub workflow context JSON: {exc}", file=sys.stderr) | |
| raise SystemExit(1) | |
| trusted_repository = str( | |
| job_context.get("workflow_repository") or "ContextualWisdomLab/.github" | |
| ).strip() | |
| trusted_ref = str( | |
| job_context.get("workflow_sha") or github_context.get("workflow_sha") or "" | |
| ).strip() | |
| workflow_ref = str( | |
| job_context.get("workflow_ref") or github_context.get("workflow_ref") or "" | |
| ).strip() | |
| if not trusted_ref: | |
| trusted_ref = "main" | |
| prefix = "ContextualWisdomLab/.github/.github/workflows/pr-review-merge-scheduler.yml@" | |
| if workflow_ref.startswith(prefix): | |
| trusted_ref = workflow_ref.split("@", 1)[1] | |
| if trusted_repository != "ContextualWisdomLab/.github": | |
| print("::error::Trusted scheduler workflow repository resolved outside ContextualWisdomLab/.github.", file=sys.stderr) | |
| raise SystemExit(1) | |
| if not re.fullmatch(r"[0-9a-fA-F]{40}|refs/[^\s]+|[A-Za-z0-9._/-]+", trusted_ref): | |
| print("::error::Trusted scheduler workflow ref resolved to an invalid value.", file=sys.stderr) | |
| raise SystemExit(1) | |
| print(f"repository={trusted_repository}") | |
| print(f"ref={trusted_ref}") | |
| PY | |
| - name: Materialize trusted scheduler | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| TRUSTED_SOURCE_REF: ${{ steps.trusted_source.outputs.ref }} | |
| run: | | |
| set -euo pipefail | |
| if [[ ! "$TRUSTED_SOURCE_REF" =~ ^[0-9a-fA-F]{40}$ ]]; then | |
| echo "::error::Trusted scheduler source ref must resolve to the immutable workflow commit SHA before archive materialization." | |
| exit 1 | |
| fi | |
| trusted_archive="${RUNNER_TEMP}/trusted-scheduler-source.tar.gz" | |
| api_url="${GITHUB_API_URL:-https://api.github.com}" | |
| curl -fsSL \ | |
| -H "Authorization: Bearer ${GH_TOKEN}" \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -o "$trusted_archive" \ | |
| "${api_url}/repos/ContextualWisdomLab/.github/tarball/${TRUSTED_SOURCE_REF}" | |
| tar -xzf "$trusted_archive" -C "$GITHUB_WORKSPACE" --strip-components=1 | |
| test -f scripts/ci/pr_review_merge_scheduler.py | |
| - name: Self-test scheduler | |
| run: python3 scripts/ci/pr_review_merge_scheduler.py --self-test | |
| - name: Wait for approved OpenCode publication run to finish | |
| id: review_followup | |
| if: >- | |
| github.event_name == 'pull_request_review' | |
| && github.event.action == 'submitted' | |
| && github.event.review.state == 'approved' | |
| && ( | |
| github.event.review.user.login == 'opencode-agent' | |
| || github.event.review.user.login == 'opencode-agent[bot]' | |
| ) | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| REVIEW_HEAD_SHA: ${{ github.event.review.commit_id }} | |
| REVIEW_PR_NUMBER: ${{ github.event.pull_request.number }} | |
| run: | | |
| set -euo pipefail | |
| echo "proceed=true" >>"$GITHUB_OUTPUT" | |
| if [[ ! "${REVIEW_HEAD_SHA:-}" =~ ^[0-9a-fA-F]{40}$ ]]; then | |
| printf '::warning::Post-approval direct-merge follow-up skipped because the OpenCode App review did not carry a 40-character commit SHA. value=%s.\n' "${REVIEW_HEAD_SHA:-missing}" | |
| echo "proceed=false" >>"$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| pull_error_file="$(mktemp)" | |
| if ! pull_json="$( | |
| gh api "repos/${GITHUB_REPOSITORY}/pulls/${REVIEW_PR_NUMBER}" \ | |
| 2>"$pull_error_file" | |
| )"; then | |
| pull_reason="$(tail -n 1 "$pull_error_file" 2>/dev/null || true)" | |
| [ -n "$pull_reason" ] || pull_reason="GitHub pull-request lookup failed without an error body" | |
| rm -f "$pull_error_file" | |
| printf '::warning::Post-approval direct-merge follow-up skipped because the live pull request snapshot could not be read. PR=%s review_head=%s reason=%s.\n' "$REVIEW_PR_NUMBER" "$REVIEW_HEAD_SHA" "$pull_reason" | |
| echo "proceed=false" >>"$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| rm -f "$pull_error_file" | |
| live_state="$(jq -r '.state // "unknown"' <<<"$pull_json")" | |
| live_head="$(jq -r '.head.sha // empty' <<<"$pull_json")" | |
| if [ "$live_state" != "open" ] || [ "$live_head" != "$REVIEW_HEAD_SHA" ]; then | |
| printf '::notice::Post-approval direct-merge follow-up skipped because the pull request snapshot changed. PR=%s review_head=%s live_head=%s state=%s.\n' "$REVIEW_PR_NUMBER" "$REVIEW_HEAD_SHA" "${live_head:-missing}" "$live_state" | |
| echo "proceed=false" >>"$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| opencode_state="missing" | |
| opencode_reason="no opencode-review check run was visible for the approved head" | |
| for check_attempt in 1 2 3 4 5 6 7 8; do | |
| check_error_file="$(mktemp)" | |
| if checks_json="$( | |
| gh api --paginate --slurp \ | |
| "repos/${GITHUB_REPOSITORY}/commits/${REVIEW_HEAD_SHA}/check-runs?per_page=100" \ | |
| 2>"$check_error_file" | |
| )"; then | |
| opencode_state="$( | |
| jq -r ' | |
| [.[].check_runs[] | |
| | select(.name == "opencode-review") | |
| | select(.app.slug == "github-actions")] as $runs | |
| | if ($runs | length) == 0 then "missing" | |
| elif any($runs[]; .status != "completed") then "running" | |
| elif any($runs[]; .conclusion != "success") then | |
| "failed:" + ([$runs[] | (.conclusion // "missing")] | unique | join(",")) | |
| else "success" | |
| end | |
| ' <<<"$checks_json" | |
| )" | |
| case "$opencode_state" in | |
| success) | |
| printf 'Approved OpenCode publication run completed successfully for PR %s at %s after check attempt %s.\n' "$REVIEW_PR_NUMBER" "$REVIEW_HEAD_SHA" "$check_attempt" | |
| rm -f "$check_error_file" | |
| break | |
| ;; | |
| failed:*) | |
| opencode_reason="opencode-review completed without success (${opencode_state#failed:})" | |
| rm -f "$check_error_file" | |
| break | |
| ;; | |
| running) | |
| opencode_reason="opencode-review is still running for the approved head" | |
| ;; | |
| *) | |
| opencode_reason="no opencode-review check run was visible for the approved head" | |
| ;; | |
| esac | |
| else | |
| opencode_state="api-error" | |
| opencode_reason="$(tail -n 1 "$check_error_file" 2>/dev/null || true)" | |
| [ -n "$opencode_reason" ] || opencode_reason="GitHub check-runs lookup failed without an error body" | |
| fi | |
| rm -f "$check_error_file" | |
| if [ "$check_attempt" -lt 8 ]; then | |
| check_delay="$((check_attempt * 2))" | |
| printf 'Approved OpenCode publication run is not complete for PR %s at %s after check attempt %s: %s. Retrying in %ss.\n' "$REVIEW_PR_NUMBER" "$REVIEW_HEAD_SHA" "$check_attempt" "$opencode_reason" "$check_delay" | |
| sleep "$check_delay" | |
| fi | |
| done | |
| if [ "$opencode_state" != "success" ]; then | |
| printf '::warning::Post-approval direct-merge follow-up skipped because the approved OpenCode publication run did not complete successfully. PR=%s head=%s state=%s reason=%s. The scheduled organization sweep remains authoritative.\n' "$REVIEW_PR_NUMBER" "$REVIEW_HEAD_SHA" "$opencode_state" "$opencode_reason" | |
| echo "proceed=false" >>"$GITHUB_OUTPUT" | |
| fi | |
| - name: Inspect PR review and merge queue | |
| 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 }} | |
| 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 != '' && github.event.client_payload.target_repository != github.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.event_name == 'repository_dispatch' && github.event.client_payload.target_repository != '' && github.event.client_payload.target_repository != github.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' }} | |
| run: | | |
| set -euo pipefail | |
| case "$STALE_OPENCODE_MINUTES" in | |
| ''|*[!0-9]*) | |
| echo "::error::STALE_OPENCODE_MINUTES must contain only decimal digits" | |
| exit 1 | |
| ;; | |
| esac | |
| if [ "${#STALE_OPENCODE_MINUTES}" -gt 4 ]; then | |
| echo "::error::STALE_OPENCODE_MINUTES must be between 1 and 1440" | |
| exit 1 | |
| fi | |
| stale_opencode_minutes=$((10#$STALE_OPENCODE_MINUTES)) | |
| if [ "$stale_opencode_minutes" -lt 1 ] || [ "$stale_opencode_minutes" -gt 1440 ]; then | |
| echo "::error::STALE_OPENCODE_MINUTES must be between 1 and 1440" | |
| exit 1 | |
| fi | |
| STALE_OPENCODE_MINUTES="$stale_opencode_minutes" | |
| project_flow="$PROJECT_FLOW_INPUT" | |
| if [ -z "$project_flow" ]; then | |
| case "$TARGET_DEFAULT_BRANCH" in | |
| main|master) project_flow="github-flow" ;; | |
| develop) project_flow="git-flow" ;; | |
| *) project_flow="github-flow" ;; | |
| esac | |
| fi | |
| review_dispatch_limit="$REVIEW_DISPATCH_LIMIT_INPUT" | |
| if [ -z "$review_dispatch_limit" ]; then | |
| review_dispatch_limit="-1" | |
| fi | |
| branch_update_limit="$BRANCH_UPDATE_LIMIT_INPUT" | |
| if [ -z "$branch_update_limit" ]; then | |
| branch_update_limit="1" | |
| fi | |
| args=( | |
| --repo "$TARGET_REPOSITORY" | |
| --base-branch "$TARGET_DEFAULT_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") | |
| fi | |
| if [ "$DRY_RUN" = "true" ]; then | |
| args+=(--dry-run) | |
| fi | |
| if [ "$TRIGGER_REVIEWS" = "true" ]; then | |
| args+=(--trigger-reviews) | |
| else | |
| args+=(--no-trigger-reviews) | |
| fi | |
| if [ "$ENABLE_AUTO_MERGE" = "true" ]; then | |
| args+=(--enable-auto-merge) | |
| else | |
| args+=(--no-enable-auto-merge) | |
| fi | |
| args+=(--merge-mode "$MERGE_MODE") | |
| if [ "$UPDATE_BRANCHES" = "true" ]; then | |
| args+=(--update-branches) | |
| else | |
| args+=(--no-update-branches) | |
| fi | |
| python3 scripts/ci/pr_review_merge_scheduler.py "${args[@]}" | |
| org-queue-sweep: | |
| # Organization-wide approved-PR fallback sweep. Event-driven scheduler runs | |
| # in target repositories stop retrying once their triggering event is | |
| # consumed, so a PR that becomes mergeable AFTER its last event (approval | |
| # published after the scheduler pass, required merge-preview checks landing | |
| # late, a base-branch policy blocker clearing) stays approved-but-unmerged | |
| # with no later trigger. This job re-runs the same trusted scheduler against | |
| # every organization repository on an hourly heartbeat so each such PR is | |
| # merged, branch-updated, or leaves a concrete per-PR blocker reason in this | |
| # log. It never bypasses policy: all mutations go through the same guarded | |
| # scheduler contract as the per-repository runs. Stacked PRs have no | |
| # injected required workflow, so they receive a separate bounded OpenCode | |
| # dispatch budget and cannot be starved by the ordinary queue. | |
| if: >- | |
| github.repository == 'ContextualWisdomLab/.github' && | |
| ( | |
| (github.event_name == 'schedule' && github.event.schedule == '*/15 * * * *') || | |
| (github.event_name == 'repository_dispatch' && github.event.client_payload.org_sweep == true) | |
| ) | |
| runs-on: ubuntu-24.04 | |
| # 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 | |
| contents: write | |
| id-token: write | |
| pull-requests: write | |
| env: | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true | |
| GH_TOKEN: ${{ github.token }} | |
| DRY_RUN: ${{ github.event.client_payload.dry_run == true || inputs.dry_run == true }} | |
| ORG_SWEEP_OWNER: ContextualWisdomLab | |
| # Inspect the complete practical queue for every repository. The previous | |
| # default of 30 silently omitted older PRs whenever a repository had a | |
| # larger queue (BandScope had 34 during the incident that established | |
| # this contract). The scheduler paginates, so 1000 keeps the practical | |
| # GitHub queue ceiling while avoiding an arbitrary per-repository sample. | |
| 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_STACKED_REVIEW_DISPATCH_LIMIT: ${{ github.event.client_payload.stacked_review_dispatch_limit || vars.ORG_SWEEP_STACKED_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 == '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 == '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' }} | |
| # The review-dispatch, stacked-review, and branch-update budgets above are organization-wide | |
| # per sweep tick (sized to bound LLM review-provider cost/rate exposure, not | |
| # per-repository). Without rotation, `sweep_targets` is walked in a fixed | |
| # order every tick (the org repos API response order), so the same early | |
| # repositories always exhaust a queue's budget and every later repository | |
| # starves indefinitely even with zero-open-thread, all-green PRs | |
| # (ContextualWisdomLab/.github#1219). Left unset here so the sweep step | |
| # below derives it from a persistent per-execution counter (or, as a | |
| # fallback, wall-clock time) instead of `github.run_number`: run_number | |
| # increments on every trigger of this workflow (push, | |
| # pull_request_target, pull_request_review, workflow_run), not only the | |
| # sweep schedule, so it cannot give the "bounded by repository_count | |
| # ticks" guarantee a rotation is meant to provide. Wall-clock time alone | |
| # is also insufficient, since this single-flight/non-cancelling job can | |
| # run up to 60 minutes and a delayed real execution can let more than | |
| # one 900s window elapse, occasionally repeating a modulo offset | |
| # (ContextualWisdomLab/.github#1223 review finding). | |
| # A repository the sweep credential structurally cannot read (the OpenCode | |
| # app is not installed there / the PR_REVIEW_MERGE_TOKEN lacks it) returns | |
| # HTTP 403 "Resource not accessible by integration". That is an access-grant | |
| # fact the automation can never resolve, so it is reported as a skipped, | |
| # non-fatal "unavailable" repository rather than a hard sweep failure. This | |
| # ceiling keeps the sweep fail-closed against a credential-scope regression: | |
| # if MORE than this many repositories become unreachable at once, the whole | |
| # credential likely broke and the job fails loudly. | |
| ORG_SWEEP_MAX_UNAVAILABLE: ${{ vars.ORG_SWEEP_MAX_UNAVAILABLE || '5' }} | |
| STALE_OPENCODE_MINUTES: ${{ github.event.client_payload.stale_opencode_minutes || inputs.stale_opencode_minutes || vars.STALE_OPENCODE_MINUTES || '90' }} | |
| steps: | |
| - name: Exchange OpenCode app token for sweep mutations | |
| id: sweep_app_token | |
| 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: Resolve trusted scheduler source ref | |
| id: trusted_source | |
| env: | |
| JOB_CONTEXT_JSON: ${{ toJSON(job) }} | |
| GITHUB_CONTEXT_JSON: ${{ toJSON(github) }} | |
| run: | | |
| set -euo pipefail | |
| python3 <<'PY' >>"$GITHUB_OUTPUT" | |
| import json | |
| import os | |
| import re | |
| import sys | |
| try: | |
| job_context = json.loads(os.environ.get("JOB_CONTEXT_JSON") or "{}") | |
| github_context = json.loads(os.environ.get("GITHUB_CONTEXT_JSON") or "{}") | |
| except json.JSONDecodeError as exc: | |
| print(f"::error::Could not parse GitHub workflow context JSON: {exc}", file=sys.stderr) | |
| raise SystemExit(1) | |
| trusted_repository = str( | |
| job_context.get("workflow_repository") or "ContextualWisdomLab/.github" | |
| ).strip() | |
| trusted_ref = str( | |
| job_context.get("workflow_sha") or github_context.get("workflow_sha") or "" | |
| ).strip() | |
| workflow_ref = str( | |
| job_context.get("workflow_ref") or github_context.get("workflow_ref") or "" | |
| ).strip() | |
| if not trusted_ref: | |
| trusted_ref = "main" | |
| prefix = "ContextualWisdomLab/.github/.github/workflows/pr-review-merge-scheduler.yml@" | |
| if workflow_ref.startswith(prefix): | |
| trusted_ref = workflow_ref.split("@", 1)[1] | |
| if trusted_repository != "ContextualWisdomLab/.github": | |
| print("::error::Trusted scheduler workflow repository resolved outside ContextualWisdomLab/.github.", file=sys.stderr) | |
| raise SystemExit(1) | |
| if not re.fullmatch(r"[0-9a-fA-F]{40}|refs/[^\s]+|[A-Za-z0-9._/-]+", trusted_ref): | |
| print("::error::Trusted scheduler workflow ref resolved to an invalid value.", file=sys.stderr) | |
| raise SystemExit(1) | |
| print(f"repository={trusted_repository}") | |
| print(f"ref={trusted_ref}") | |
| PY | |
| - name: Materialize trusted scheduler | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| TRUSTED_SOURCE_REF: ${{ steps.trusted_source.outputs.ref }} | |
| run: | | |
| set -euo pipefail | |
| if [[ ! "$TRUSTED_SOURCE_REF" =~ ^[0-9a-fA-F]{40}$ ]]; then | |
| echo "::error::Trusted scheduler source ref must resolve to the immutable workflow commit SHA before archive materialization." | |
| exit 1 | |
| fi | |
| trusted_archive="${RUNNER_TEMP}/trusted-scheduler-source.tar.gz" | |
| api_url="${GITHUB_API_URL:-https://api.github.com}" | |
| curl -fsSL \ | |
| -H "Authorization: Bearer ${GH_TOKEN}" \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -o "$trusted_archive" \ | |
| "${api_url}/repos/ContextualWisdomLab/.github/tarball/${TRUSTED_SOURCE_REF}" | |
| tar -xzf "$trusted_archive" -C "$GITHUB_WORKSPACE" --strip-components=1 | |
| test -f scripts/ci/pr_review_merge_scheduler.py | |
| - name: Self-test scheduler | |
| run: python3 scripts/ci/pr_review_merge_scheduler.py --self-test | |
| - name: Sweep organization repository queues | |
| 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 (contents: 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 repository-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_ALLOW_CROSS_REPO_REPOSITORY_DISPATCH: ${{ (secrets.PR_REVIEW_MERGE_TOKEN != '' || secrets.OPENCODE_APPROVE_TOKEN != '') && 'true' || 'false' }} | |
| run: | | |
| set -euo pipefail | |
| case "$STALE_OPENCODE_MINUTES" in | |
| ''|*[!0-9]*) | |
| echo "::error::STALE_OPENCODE_MINUTES must contain only decimal digits" | |
| exit 1 | |
| ;; | |
| esac | |
| if [ "${#STALE_OPENCODE_MINUTES}" -gt 4 ]; then | |
| echo "::error::STALE_OPENCODE_MINUTES must be between 1 and 1440" | |
| exit 1 | |
| fi | |
| stale_opencode_minutes=$((10#$STALE_OPENCODE_MINUTES)) | |
| if [ "$stale_opencode_minutes" -lt 1 ] || [ "$stale_opencode_minutes" -gt 1440 ]; then | |
| echo "::error::STALE_OPENCODE_MINUTES must be between 1 and 1440" | |
| exit 1 | |
| fi | |
| STALE_OPENCODE_MINUTES="$stale_opencode_minutes" | |
| if [ "$SCHEDULER_MUTATION_TOKEN_SOURCE" = "github-token" ]; then | |
| # github.token is repository-scoped to .github and cannot mutate | |
| # sibling repositories; a sweep with it would silently do nothing. | |
| echo "::error::Organization queue sweep has no cross-repository mutation credential. Configure the PR_REVIEW_MERGE_TOKEN or OPENCODE_APPROVE_TOKEN secret (or keep the OpenCode app token exchange available) so approved PRs in target repositories can be merged or updated." | |
| exit 1 | |
| fi | |
| echo "Sweep mutation token source: $SCHEDULER_MUTATION_TOKEN_SOURCE" | |
| # Validate the fail-closed ceiling before it is used in a numeric test. | |
| # A non-integer would make "[ ... -gt ... ]" error out inside an if | |
| # condition, which set -e does not trap, silently skipping the | |
| # regression guard. Fail loudly instead so a misconfigured | |
| # ORG_SWEEP_MAX_UNAVAILABLE can never quietly disable fail-closed. | |
| if ! [[ "$ORG_SWEEP_MAX_UNAVAILABLE" =~ ^[0-9]+$ ]]; then | |
| echo "::error::ORG_SWEEP_MAX_UNAVAILABLE must be a non-negative integer; got '${ORG_SWEEP_MAX_UNAVAILABLE}'. Fix the ORG_SWEEP_MAX_UNAVAILABLE repository variable." | |
| exit 1 | |
| fi | |
| if ! [[ "$ORG_SWEEP_REVIEW_DISPATCH_LIMIT" =~ ^(-1|[0-9]+)$ ]]; then | |
| echo "::error::ORG_SWEEP_REVIEW_DISPATCH_LIMIT must be -1 or a non-negative integer; got '${ORG_SWEEP_REVIEW_DISPATCH_LIMIT}'. Fix the ORG_SWEEP_REVIEW_DISPATCH_LIMIT repository variable." | |
| exit 1 | |
| fi | |
| if ! [[ "$ORG_SWEEP_STACKED_REVIEW_DISPATCH_LIMIT" =~ ^(-1|[0-9]+)$ ]]; then | |
| echo "::error::ORG_SWEEP_STACKED_REVIEW_DISPATCH_LIMIT must be -1 or a non-negative integer; got '${ORG_SWEEP_STACKED_REVIEW_DISPATCH_LIMIT}'. Fix the ORG_SWEEP_STACKED_REVIEW_DISPATCH_LIMIT repository variable." | |
| exit 1 | |
| fi | |
| if ! [[ "$ORG_SWEEP_BRANCH_UPDATE_LIMIT" =~ ^(-1|[0-9]+)$ ]]; then | |
| echo "::error::ORG_SWEEP_BRANCH_UPDATE_LIMIT must be -1 or a non-negative integer; got '${ORG_SWEEP_BRANCH_UPDATE_LIMIT}'. Fix the ORG_SWEEP_BRANCH_UPDATE_LIMIT repository variable." | |
| exit 1 | |
| fi | |
| # Unset in production (see the env-block comment above). Primary | |
| # source: a persistent `ORG_SWEEP_ROTATION_COUNTER` repository | |
| # variable on this (.github) repository, incremented by exactly | |
| # one at the start of every actual org-queue-sweep execution. A | |
| # wall-clock tick (one per 900s) is *not* sufficient on its own: | |
| # this job is single-flight/non-cancelling with up to a 60-minute | |
| # timeout, so a delayed or backlogged execution can let more than | |
| # one 900s window elapse between two real sweep runs, and if that | |
| # gap happens to be an exact multiple of the repository count the | |
| # modulo offset repeats -- reintroducing the exact starvation | |
| # #1220 fixed (CodeRabbit review finding on #1223). A persistent | |
| # per-execution counter advances by exactly one every time the | |
| # sweep body actually runs, regardless of how much wall-clock time | |
| # a slow prior run consumed. Falls back to the wall-clock tick, | |
| # which still strictly improves on the pre-#1220 fixed order, only | |
| # if the counter read/write itself is unavailable (permissions, | |
| # transient API failure) -- a fairness mechanism must never fail | |
| # the sweep's much more important review-dispatch/merge work. | |
| # Tests inject ORG_SWEEP_ROTATION_INDEX directly for determinism, | |
| # which this only fills in when absent. | |
| # | |
| # Two known, accepted limitations of this counter (Devin review on | |
| # #1223), neither of which is fixed here: | |
| # - Read-modify-write is not atomic. A schedule-triggered run and a | |
| # manual `repository_dispatch` org_sweep run use different | |
| # concurrency groups and can therefore execute concurrently, in | |
| # which case both could read the same counter value and pick the | |
| # same rotation offset for that one pair of runs. The REST | |
| # Variables API has no compare-and-swap primitive to close this | |
| # without a broader concurrency-group redesign shared across | |
| # every trigger type this workflow serves; the consequence is | |
| # bounded and self-correcting (one occasionally-repeated offset, | |
| # not a stuck one), so it is accepted rather than redesigned. | |
| # - Whether the PATCH/POST below ever succeeds in production | |
| # depends on the resolved token actually holding repository | |
| # Variables-write scope, which is not independently verifiable | |
| # from inside this workflow. If it does not, every run silently | |
| # but safely degrades to the wall-clock fallback below (logged | |
| # via ::warning:: each time), which is still strictly better | |
| # than the pre-#1220 fixed order -- never a hard failure, and | |
| # observable in the run log for whoever holds that token. | |
| if [ -z "${ORG_SWEEP_ROTATION_INDEX:-}" ]; then | |
| counter_variable_name="ORG_SWEEP_ROTATION_COUNTER" | |
| # Distinguish a *successful* read (the variable exists; its | |
| # value, valid or not, is authoritative) from a *failed* read | |
| # (transient error, permissions, or the variable genuinely | |
| # doesn't exist yet -- indistinguishable from here). Only a | |
| # successful read may PATCH: a transient failure that silently | |
| # became "treat as 0" would let the PATCH below clobber an | |
| # already-accumulated counter value back down to 1, restarting | |
| # the rotation sequence instead of degrading to the wall-clock | |
| # fallback the design intends (Devin review finding on #1223). | |
| if counter_current="$( | |
| gh api "repos/${GITHUB_REPOSITORY}/actions/variables/${counter_variable_name}" \ | |
| --jq '.value' 2>/dev/null | |
| )"; then | |
| if ! [[ "$counter_current" =~ ^[0-9]+$ ]]; then | |
| counter_current=0 | |
| fi | |
| # Force base-10: a manually-seeded value with a leading zero | |
| # (e.g. "08") passes the digit-only check above but bash's | |
| # unprefixed arithmetic parses a leading-zero literal as | |
| # octal, and "08"/"09" are not valid octal digits -- errors | |
| # under set -e. $((10#...)) is the same guard already used | |
| # elsewhere in this file (STALE_OPENCODE_MINUTES). | |
| counter_next=$(( 10#$counter_current + 1 )) | |
| if gh api "repos/${GITHUB_REPOSITORY}/actions/variables/${counter_variable_name}" \ | |
| -X PATCH -f "value=${counter_next}" >/dev/null 2>&1; then | |
| ORG_SWEEP_ROTATION_INDEX="$counter_next" | |
| else | |
| echo "::warning::read ${counter_variable_name}=${counter_current} but could not PATCH it; falling back to a wall-clock rotation tick for this run only" | |
| ORG_SWEEP_ROTATION_INDEX=$(( $(date -u +%s) / 900 )) | |
| fi | |
| elif gh api "repos/${GITHUB_REPOSITORY}/actions/variables" \ | |
| -X POST -f "name=${counter_variable_name}" -f "value=1" >/dev/null 2>&1; then | |
| # The read failed, so this is only safe as a first-run | |
| # create: POST fails on its own if the variable actually | |
| # already exists (a real read outage rather than a genuinely | |
| # missing variable), which correctly falls through to the | |
| # wall-clock branch below instead of resetting a value this | |
| # run could not see. | |
| ORG_SWEEP_ROTATION_INDEX=1 | |
| else | |
| echo "::warning::could not read/write ${counter_variable_name}; falling back to a wall-clock rotation tick for this run only" | |
| ORG_SWEEP_ROTATION_INDEX=$(( $(date -u +%s) / 900 )) | |
| fi | |
| fi | |
| if ! [[ "$ORG_SWEEP_ROTATION_INDEX" =~ ^[0-9]+$ ]]; then | |
| echo "::error::ORG_SWEEP_ROTATION_INDEX must be a non-negative integer; got '${ORG_SWEEP_ROTATION_INDEX}'." | |
| exit 1 | |
| fi | |
| repositories_json="$( | |
| gh api \ | |
| -H "Accept: application/vnd.github+json" \ | |
| "/orgs/${ORG_SWEEP_OWNER}/repos?per_page=100&type=all" --paginate | |
| )" | |
| mapfile -t sweep_targets < <( | |
| jq -r ' | |
| .[] | |
| | select(.archived == false and .disabled == false) | |
| | select(.full_name != "ContextualWisdomLab/.github") | |
| | "\(.full_name)\t\(.default_branch)" | |
| ' <<<"$repositories_json" | |
| ) | |
| sweep_target_count=${#sweep_targets[@]} | |
| # Rotate the fixed walk order by ORG_SWEEP_ROTATION_INDEX (see | |
| # above: a persistent per-execution counter, falling back to a | |
| # wall-clock tick) so the same organization-wide review-dispatch | |
| # /branch-update budgets land on a different starting repository each | |
| # execution instead of always exhausting on the same early | |
| # repositories (#1219). The ordinary and stacked review budgets are | |
| # tracked independently so the latter cannot be starved by the former. | |
| rotation_offset=0 | |
| if [ "$sweep_target_count" -gt 0 ]; then | |
| rotation_offset=$(( ORG_SWEEP_ROTATION_INDEX % sweep_target_count )) | |
| if [ "$rotation_offset" -gt 0 ]; then | |
| sweep_targets=( | |
| "${sweep_targets[@]:rotation_offset}" | |
| "${sweep_targets[@]:0:rotation_offset}" | |
| ) | |
| fi | |
| fi | |
| echo "Sweeping ${sweep_target_count} repositories starting at rotation offset ${rotation_offset} (rotation tick ${ORG_SWEEP_ROTATION_INDEX})." | |
| failures=0 | |
| unavailable=0 | |
| unavailable_repos=() | |
| # These are organization-wide budgets. They must be consumed across | |
| # the repository loop, not reset for every target repository; resetting | |
| # them here can enqueue hundreds of long-running review jobs per sweep. | |
| org_review_dispatches_used=0 | |
| org_stacked_review_dispatches_used=0 | |
| org_branch_updates_used=0 | |
| for target in "${sweep_targets[@]}"; do | |
| repo_full_name="${target%%$'\t'*}" | |
| default_branch="${target##*$'\t'}" | |
| echo "::group::Sweep ${repo_full_name} (base ${default_branch})" | |
| open_pr_count="$( | |
| gh api \ | |
| -H "Accept: application/vnd.github+json" \ | |
| "/repos/${repo_full_name}/pulls?state=open&per_page=1" \ | |
| --jq 'length' || echo "unknown" | |
| )" | |
| if [ "$open_pr_count" = "0" ]; then | |
| echo "No open PRs (including stacked or non-default-base PRs); skipping." | |
| echo "::endgroup::" | |
| continue | |
| fi | |
| # The scheduler requires --project-flow. Derive it per target the | |
| # same way the single-repository job does: main/master default | |
| # branches are GitHub Flow, develop is Git Flow, anything else | |
| # defaults to GitHub Flow. | |
| case "$default_branch" in | |
| main|master) project_flow="github-flow" ;; | |
| develop) project_flow="git-flow" ;; | |
| *) project_flow="github-flow" ;; | |
| esac | |
| if [ "$ORG_SWEEP_REVIEW_DISPATCH_LIMIT" = "-1" ]; then | |
| review_dispatch_limit=-1 | |
| else | |
| review_dispatch_limit=$((ORG_SWEEP_REVIEW_DISPATCH_LIMIT - org_review_dispatches_used)) | |
| if (( review_dispatch_limit < 0 )); then | |
| review_dispatch_limit=0 | |
| fi | |
| fi | |
| if [ "$ORG_SWEEP_STACKED_REVIEW_DISPATCH_LIMIT" = "-1" ]; then | |
| stacked_review_dispatch_limit=-1 | |
| else | |
| stacked_review_dispatch_limit=$((ORG_SWEEP_STACKED_REVIEW_DISPATCH_LIMIT - org_stacked_review_dispatches_used)) | |
| if (( stacked_review_dispatch_limit < 0 )); then | |
| stacked_review_dispatch_limit=0 | |
| fi | |
| fi | |
| if [ "$ORG_SWEEP_BRANCH_UPDATE_LIMIT" = "-1" ]; then | |
| branch_update_limit=-1 | |
| else | |
| branch_update_limit=$((ORG_SWEEP_BRANCH_UPDATE_LIMIT - org_branch_updates_used)) | |
| if (( branch_update_limit < 0 )); then | |
| branch_update_limit=0 | |
| fi | |
| fi | |
| args=( | |
| --repo "$repo_full_name" | |
| --base-branch "$default_branch" | |
| --project-flow "$project_flow" | |
| --max-prs "$ORG_SWEEP_MAX_PRS" | |
| --review-workflow "Required OpenCode Review" | |
| --review-dispatch-limit "$review_dispatch_limit" | |
| --stacked-review-dispatch-limit "$stacked_review_dispatch_limit" | |
| --branch-update-limit "$branch_update_limit" | |
| --stale-opencode-minutes "$STALE_OPENCODE_MINUTES" | |
| --merge-mode "$ORG_SWEEP_MERGE_MODE" | |
| ) | |
| if [ "$ORG_SWEEP_TRIGGER_REVIEWS" = "true" ]; then | |
| args+=(--trigger-reviews) | |
| fi | |
| if [ "$ORG_SWEEP_ENABLE_AUTO_MERGE" = "true" ]; then | |
| args+=(--enable-auto-merge) | |
| fi | |
| if [ "$ORG_SWEEP_UPDATE_BRANCHES" = "true" ]; then | |
| args+=(--update-branches) | |
| fi | |
| if [ "$DRY_RUN" = "true" ]; then | |
| args+=(--dry-run) | |
| fi | |
| set +e | |
| sweep_output="$(python3 scripts/ci/pr_review_merge_scheduler.py "${args[@]}" 2>&1)" | |
| sweep_rc=$? | |
| set -e | |
| printf '%s\n' "$sweep_output" | |
| repo_stacked_review_dispatches="$(printf '%s\n' "$sweep_output" | grep -Ec '^PR #[0-9]+: review_dispatch: stacked PR onto' || true)" | |
| repo_review_dispatches_total="$(printf '%s\n' "$sweep_output" | grep -Ec '^PR #[0-9]+: (review_dispatch|security_dispatch):' || true)" | |
| repo_review_dispatches=$((repo_review_dispatches_total - repo_stacked_review_dispatches)) | |
| repo_branch_updates="$(printf '%s\n' "$sweep_output" | grep -Ec '^PR #[0-9]+: (update_branch|restamp_head):' || true)" | |
| org_review_dispatches_used=$((org_review_dispatches_used + repo_review_dispatches)) | |
| org_stacked_review_dispatches_used=$((org_stacked_review_dispatches_used + repo_stacked_review_dispatches)) | |
| org_branch_updates_used=$((org_branch_updates_used + repo_branch_updates)) | |
| echo "Org sweep budget consumed: review dispatches=${org_review_dispatches_used}/${ORG_SWEEP_REVIEW_DISPATCH_LIMIT}, stacked review dispatches=${org_stacked_review_dispatches_used}/${ORG_SWEEP_STACKED_REVIEW_DISPATCH_LIMIT}, branch updates=${org_branch_updates_used}/${ORG_SWEEP_BRANCH_UPDATE_LIMIT}." | |
| if [ "$sweep_rc" -ne 0 ]; then | |
| # A structural access denial ("Resource not accessible by | |
| # integration") means the sweep credential cannot read this | |
| # repository at all — the OpenCode app is not installed there or | |
| # PR_REVIEW_MERGE_TOKEN does not cover it. The automation can never | |
| # merge those PRs regardless, so this is a skipped, non-fatal | |
| # "unavailable" repository, not a failure the sweep can act on. Any | |
| # other non-zero exit is a genuine per-repository failure. | |
| if printf '%s' "$sweep_output" | grep -qF "Resource not accessible by integration"; then | |
| echo "::warning::Skipping ${repo_full_name}: the sweep credential lacks access (HTTP 403 Resource not accessible by integration). Install the OpenCode app on this repository or grant PR_REVIEW_MERGE_TOKEN access to include it in the sweep." | |
| unavailable=$((unavailable + 1)) | |
| unavailable_repos+=("$repo_full_name") | |
| else | |
| echo "::error::Queue sweep failed for ${repo_full_name}; see the decision log above for the concrete per-PR reason." | |
| failures=$((failures + 1)) | |
| fi | |
| fi | |
| # Queue hygiene, part 1: cancel every queued/in-progress PR run whose | |
| # head SHA no longer matches its open PR's Current HEAD, plus default- | |
| # branch push/schedule runs superseded by a newer default HEAD. PR | |
| # concurrency normally does this on synchronize/close events, but it | |
| # cannot repair runs left behind by an outage or a manual dispatch. | |
| # Compare live refs on every sweep instead of waiting for an age | |
| # threshold: previous-head checks are never useful merge evidence. | |
| queue_hygiene_ready=true | |
| if ! open_pr_heads_json="$( | |
| gh api \ | |
| -H "Accept: application/vnd.github+json" \ | |
| "/repos/${repo_full_name}/pulls?state=open&per_page=100" \ | |
| --paginate \ | |
| | jq -sc ' | |
| add | |
| | map( | |
| select( | |
| .head.repo.full_name != null and | |
| .head.ref != null and | |
| .head.sha != null | |
| ) | |
| | { | |
| key: "\(.head.repo.full_name):\(.head.ref)", | |
| value: .head.sha | |
| } | |
| ) | |
| | from_entries | |
| ' | |
| )"; then | |
| echo "::warning::Current-HEAD cancellation skipped for ${repo_full_name}: open PR head refs could not be read safely. No run will be cancelled from incomplete evidence." | |
| open_pr_heads_json="{}" | |
| queue_hygiene_ready=false | |
| fi | |
| if ! current_default_sha="$( | |
| gh api \ | |
| -H "Accept: application/vnd.github+json" \ | |
| "/repos/${repo_full_name}/commits/${default_branch}" \ | |
| --jq '.sha // empty' | |
| )"; then | |
| echo "::warning::Current-HEAD cancellation skipped for ${repo_full_name}: default-branch HEAD could not be read safely. No run will be cancelled from incomplete evidence." | |
| current_default_sha="" | |
| queue_hygiene_ready=false | |
| fi | |
| if ! active_runs_json="$( | |
| for active_status in queued in_progress; do | |
| gh api \ | |
| -H "Accept: application/vnd.github+json" \ | |
| "/repos/${repo_full_name}/actions/runs?status=${active_status}&per_page=100" \ | |
| --paginate | |
| done | jq -sc '[.[] | (.workflow_runs // [])[]]' | |
| )"; then | |
| echo "::warning::Current-HEAD cancellation skipped for ${repo_full_name}: queued/in-progress Actions runs could not be read. Grant the sweep credential Actions read access; no run will be cancelled from incomplete evidence." | |
| active_runs_json="[]" | |
| queue_hygiene_ready=false | |
| fi | |
| superseded_runs_json="[]" | |
| if [ "$queue_hygiene_ready" = "true" ]; then | |
| superseded_runs_json="$( | |
| jq \ | |
| --argjson current_pr_heads "$open_pr_heads_json" \ | |
| --arg default_branch "$default_branch" \ | |
| --arg current_default_sha "$current_default_sha" \ | |
| '[ | |
| .[] | |
| | ((.head_repository.full_name // "") + ":" + (.head_branch // "")) as $head_key | |
| | ($current_pr_heads[$head_key] // null) as $current_pr_head | |
| | select( | |
| if (.event == "pull_request" or .event == "pull_request_target") then | |
| ($current_pr_head == null or .head_sha != $current_pr_head) | |
| elif ( | |
| (.event == "push" or .event == "schedule") and | |
| .head_branch == $default_branch and | |
| $current_default_sha != "" | |
| ) then | |
| .head_sha != $current_default_sha | |
| else | |
| false | |
| end | |
| ) | |
| | { | |
| id, | |
| name, | |
| status, | |
| event, | |
| head_branch, | |
| run_head: .head_sha, | |
| current_head: ( | |
| if (.event == "pull_request" or .event == "pull_request_target") then | |
| $current_pr_head | |
| else | |
| $current_default_sha | |
| end | |
| ), | |
| created_at | |
| } | |
| ]' <<<"$active_runs_json" | |
| )" | |
| fi | |
| superseded_count="$(jq 'length' <<<"$superseded_runs_json")" | |
| if [ "$superseded_count" -gt 0 ]; then | |
| echo "Cancelling ${superseded_count} queued/in-progress run(s) that do not match an open PR or default-branch Current HEAD:" | |
| jq -r '.[] | " run \(.id) [\(.name)] status=\(.status) event=\(.event) branch=\(.head_branch) run_head=\(.run_head) current_head=\(.current_head // "closed-or-no-open-pr")"' <<<"$superseded_runs_json" | |
| if [ "$DRY_RUN" != "true" ]; then | |
| while IFS= read -r run_id; do | |
| if ! gh api -X POST "/repos/${repo_full_name}/actions/runs/${run_id}/cancel" >/dev/null; then | |
| echo "Could not cancel superseded run ${run_id} in ${repo_full_name}; it may have finished already." | |
| fi | |
| done < <(jq -r '.[].id' <<<"$superseded_runs_json") | |
| fi | |
| fi | |
| # Queue hygiene, part 2: retain the legacy age guard only for queued | |
| # runs that are not tied to a currently open PR head. This catches | |
| # orphaned manual/workflow-chain runs without cancelling a valid | |
| # current-head PR check merely because runner capacity was scarce. | |
| stale_runs_json="[]" | |
| if [ "$queue_hygiene_ready" = "true" ]; then | |
| stale_cutoff="$(date -u -d "${ORG_SWEEP_STALE_QUEUE_HOURS} hours ago" +%Y-%m-%dT%H:%M:%SZ)" | |
| stale_runs_json="$( | |
| jq \ | |
| --argjson current_pr_heads "$open_pr_heads_json" \ | |
| --argjson superseded "$superseded_runs_json" \ | |
| --arg stale_cutoff "$stale_cutoff" \ | |
| '[ | |
| .[] | |
| | .id as $run_id | |
| | ((.head_repository.full_name // "") + ":" + (.head_branch // "")) as $head_key | |
| | select(.status == "queued") | |
| | select(.created_at < $stale_cutoff) | |
| | select($current_pr_heads[$head_key] == null) | |
| | select(([ $superseded[].id ] | index($run_id)) == null) | |
| | {id, name, event, head_branch, head_sha, created_at} | |
| ]' <<<"$active_runs_json" | |
| )" | |
| fi | |
| stale_count="$(jq 'length' <<<"$stale_runs_json")" | |
| if [ "$stale_count" -gt 0 ]; then | |
| echo "Cancelling ${stale_count} queued run(s) older than ${ORG_SWEEP_STALE_QUEUE_HOURS}h:" | |
| jq -r '.[] | " run \(.id) [\(.name)] on \(.head_branch) queued since \(.created_at)"' <<<"$stale_runs_json" | |
| if [ "$DRY_RUN" != "true" ]; then | |
| while IFS= read -r run_id; do | |
| if ! gh api -X POST "/repos/${repo_full_name}/actions/runs/${run_id}/cancel" >/dev/null; then | |
| echo "Could not cancel run ${run_id} in ${repo_full_name}; it may have started or finished already." | |
| fi | |
| done < <(jq -r '.[].id' <<<"$stale_runs_json") | |
| fi | |
| fi | |
| echo "::endgroup::" | |
| done | |
| if [ "$unavailable" -gt 0 ]; then | |
| echo "::warning::${unavailable} repository(ies) were skipped as unreachable by the sweep credential (HTTP 403): ${unavailable_repos[*]}. These do not fail the sweep; install the OpenCode app or grant PR_REVIEW_MERGE_TOKEN access to include them." | |
| fi | |
| # Fail-closed guard: a handful of un-enrolled repositories is expected, | |
| # but if MORE than ORG_SWEEP_MAX_UNAVAILABLE repositories become | |
| # unreachable at once the sweep credential itself has regressed and the | |
| # job must fail loudly rather than silently sweeping nothing. | |
| if [ "$unavailable" -gt "$ORG_SWEEP_MAX_UNAVAILABLE" ]; then | |
| echo "::error::Sweep credential could not access ${unavailable} repositories (limit ${ORG_SWEEP_MAX_UNAVAILABLE}); this indicates a credential-scope regression, not a few un-enrolled repositories. Verify PR_REVIEW_MERGE_TOKEN / the OpenCode app installation." | |
| exit 1 | |
| fi | |
| if [ "$failures" -gt 0 ]; then | |
| echo "::error::Organization queue sweep completed with ${failures} repository failure(s); each failure's reason is printed in its repository group above." | |
| exit 1 | |
| fi | |
| echo "Organization queue sweep completed cleanly." |