๐จ Palette: HTML ๋ฆฌํฌํธ Skip-link ๊ฐ๋ ์ฑ ๋ฐ ์๊ฐ์ ๋ฐ์ ๊ฐ์ #7630
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, converted_to_draft, 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.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. | |
| 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), so it stays well below GitHub's platform default. | |
| 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 | |
| test -f scripts/ci/current_head_run_coalescer.py | |
| - name: Retire redundant queued exact-head runs | |
| if: >- | |
| github.repository == 'ContextualWisdomLab/.github' | |
| && github.event_name == 'pull_request_target' | |
| env: | |
| COALESCE_REPO: ${{ github.repository }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| EXPECTED_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} | |
| EXPECTED_HEAD_REF: ${{ github.event.pull_request.head.ref }} | |
| EXPECTED_HEAD: ${{ github.event.pull_request.head.sha }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| python3 scripts/ci/current_head_run_coalescer.py \ | |
| --repo "$COALESCE_REPO" \ | |
| --pr-number "$PR_NUMBER" \ | |
| --expected-head-repo "$EXPECTED_HEAD_REPO" \ | |
| --expected-head-ref "$EXPECTED_HEAD_REF" \ | |
| --expected-head "$EXPECTED_HEAD" | |
| - 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[@]}" |