fix(fitstats): expose local dependence indices #7267
Workflow file for this run
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_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: | |
| # Daily missed-event recovery for this repository. Native PR/review events | |
| # own the normal path; auto-merge handles required-check completion. | |
| - cron: "47 3 * * *" | |
| 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_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' }} | |
| # 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: | |
| 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. | |
| # Explicit 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 != 'repository_dispatch' || | |
| github.event.client_payload.org_sweep != true | |
| ) | |
| runs-on: ubuntu-24.04 | |
| # Bound scan-pr-queue to a wall-clock ceiling well short of GitHub's | |
| # 360-minute platform default. This is a single-repository queue scan | |
| # (paginated GraphQL reads plus at most one review dispatch and one | |
| # branch update per run) -- much lighter than org-queue-sweep's full | |
| # organization walk below, so it gets a shorter bound than that job's | |
| # timeout-minutes: 60. | |
| timeout-minutes: 30 | |
| 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.client_payload.pr_number || inputs.pr_number || '' }} | |
| TRIGGER_REVIEWS: ${{ github.event_name == 'schedule' || 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' }} | |
| REVIEW_ADMISSION_DISPATCH_BUDGET: ${{ vars.REVIEW_ADMISSION_DISPATCH_BUDGET || '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 == '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 == '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. Native events and the explicit org-sweep recovery remain 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" | |
| --admission-state-path "${RUNNER_TEMP}/review-admission/state.json" | |
| --admission-dispatch-budget "$REVIEW_ADMISSION_DISPATCH_BUDGET" | |
| --admission-sequence "$GITHUB_RUN_ID" | |
| --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 recovery 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 | |
| # repositories that can contain open PRs on a daily 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 == '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 daily 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' }} | |
| # #1823 moved ordinary PR OpenCode dispatch into the required workflow. | |
| # Keep only the separate stacked-PR fallback budget enabled by default. | |
| ORG_SWEEP_REVIEW_DISPATCH_LIMIT: ${{ github.event.client_payload.review_dispatch_limit || inputs.review_dispatch_limit || vars.ORG_SWEEP_REVIEW_DISPATCH_LIMIT || '0' }} | |
| REVIEW_ADMISSION_DISPATCH_BUDGET: ${{ vars.REVIEW_ADMISSION_DISPATCH_BUDGET || '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 }} | |
| # 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), 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 daily 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 day) 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 daily 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) / 86400 )) | |
| 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) / 86400 )) | |
| 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((.open_issues_count // 1) > 0) | |
| | 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=() | |
| rate_limited=0 | |
| rate_limited_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" | |
| --admission-state-path "${RUNNER_TEMP}/review-admission/state.json" | |
| --admission-dispatch-budget "$REVIEW_ADMISSION_DISPATCH_BUDGET" | |
| --admission-sequence "$GITHUB_RUN_ID" | |
| --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. | |
| # | |
| # "API rate limit exceeded" means the shared GitHub App | |
| # installation-token bucket (5,000-12,500 requests/hour, pooled | |
| # across at least eight other central workflows that mint tokens | |
| # for the same installation) is exhausted for this hourly window. | |
| # That is routine cross-workflow contention, not a defect in this | |
| # repository, and it self-heals on GitHub's own reset schedule; | |
| # treating it as a hard failure previously turned one exhausted | |
| # bucket into a permanently red cron run on essentially every | |
| # scheduled tick for as long as the contention lasted. Because | |
| # the installation bucket is shared by every remaining | |
| # repository, the current rotation stops after | |
| # recording the first exhausted request instead of repeating the | |
| # same bounded scheduler retries for every target. | |
| # Deferred work is picked up on a later rotation after reset. | |
| # | |
| # 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") | |
| elif printf '%s' "$sweep_output" | grep -qiF "API rate limit exceeded"; then | |
| echo "::warning::Deferring ${repo_full_name} and stopping this rotation: the shared GitHub App installation-token rate limit is exhausted (HTTP 403 API rate limit exceeded). Deferred repositories are retried automatically on the next sweep rotation once the bucket resets." | |
| rate_limited=$((rate_limited + 1)) | |
| rate_limited_repos+=("$repo_full_name") | |
| echo "::endgroup::" | |
| break | |
| 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 | |
| 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 | |
| if [ "$rate_limited" -gt 0 ]; then | |
| # No fail-closed ceiling here, unlike ORG_SWEEP_MAX_UNAVAILABLE below: | |
| # one exhausted shared installation-token bucket affects every | |
| # remaining repository, so the rotation stops after the first | |
| # observed exhaustion instead of multiplying retries and API calls. | |
| echo "::warning::The organization sweep stopped after ${rate_limited} observed rate-limit exhaustion(s): ${rate_limited_repos[*]}. Deferred work does not fail this sweep and is retried automatically once the shared bucket resets." | |
| 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." |