⚡ Bolt: LCP 최적화를 위해 above-the-fold 이미지에서 decoding="async" 제거 #259
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_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_call' && inputs.pr_number != '' && format('pr-{0}', inputs.pr_number) || | |
| github.event_name == 'workflow_call' && inputs.base_branch != '' && format('call-{0}', inputs.base_branch) || | |
| github.event_name == 'repository_dispatch' && github.event.client_payload.target_repository != '' && github.event.client_payload.pr_number != '' && format('target-{0}-pr-{1}', github.event.client_payload.target_repository, github.event.client_payload.pr_number) || | |
| github.event_name == 'repository_dispatch' && github.event.client_payload.pr_number != '' && format('pr-{0}', github.event.client_payload.pr_number) || | |
| github.event_name == 'repository_dispatch' && github.run_id || | |
| github.ref }} | |
| 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: | |
| cancel-closed-pr-runs: | |
| if: github.event_name == 'pull_request_target' && github.event.action == 'closed' | |
| runs-on: ubuntu-latest | |
| 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-latest | |
| 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 == '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")" | |
| if [ "$live_number" != "$TARGET_PR_NUMBER" ] || | |
| [ "$live_state" != "open" ] || | |
| [ "$live_base_repository" != "$TARGET_REPOSITORY_INPUT" ] || | |
| [ "$live_head_repository" != "$TARGET_REPOSITORY_INPUT" ] || | |
| [ -z "$live_base_branch" ] || | |
| ! [[ "$live_head_sha" =~ ^[0-9a-fA-F]{40}$ ]]; then | |
| printf '::error::Targeted scheduler dispatch rejected closed, cross-repository, or malformed live PR metadata. target=%s pr=%s state=%s base_repository=%s head_repository=%s base_branch=%s head_sha=%s\n' "$TARGET_REPOSITORY_INPUT" "$TARGET_PR_NUMBER" "${live_state:-<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" | |
| printf 'base_branch=%s\n' "$live_base_branch" | |
| printf 'head_sha=%s\n' "$live_head_sha" | |
| } >>"$GITHUB_OUTPUT" | |
| printf 'Validated exact targeted scheduler dispatch for %s#%s at %s on base %s.\n' "$TARGET_REPOSITORY_INPUT" "$TARGET_PR_NUMBER" "$live_head_sha" "$live_base_branch" | |
| - name: Resolve trusted scheduler source ref | |
| id: trusted_source | |
| env: | |
| 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 != '' && (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 != '' && (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 | |
| 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. | |
| 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-latest | |
| 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 }} | |
| 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_BRANCH_UPDATE_LIMIT: ${{ github.event.client_payload.branch_update_limit || inputs.branch_update_limit || vars.ORG_SWEEP_BRANCH_UPDATE_LIMIT || '1' }} | |
| ORG_SWEEP_TRIGGER_REVIEWS: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.trigger_reviews != false || inputs.trigger_reviews == true }} | |
| ORG_SWEEP_ENABLE_AUTO_MERGE: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.enable_auto_merge != false || inputs.enable_auto_merge == true }} | |
| ORG_SWEEP_MERGE_MODE: ${{ github.event.client_payload.merge_mode || inputs.merge_mode || 'direct_or_auto' }} | |
| ORG_SWEEP_UPDATE_BRANCHES: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.update_branches != false || inputs.update_branches == true }} | |
| ORG_SWEEP_STALE_QUEUE_HOURS: ${{ vars.ORG_SWEEP_STALE_QUEUE_HOURS || '24' }} | |
| # A repository the sweep credential structurally cannot read (the OpenCode | |
| # app is not installed there / the PR_REVIEW_MERGE_TOKEN lacks it) returns | |
| # 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 | |
| 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 | |
| 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" | |
| ) | |
| echo "Sweeping ${#sweep_targets[@]} repositories." | |
| failures=0 | |
| unavailable=0 | |
| unavailable_repos=() | |
| 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 | |
| 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 "$ORG_SWEEP_REVIEW_DISPATCH_LIMIT" | |
| --branch-update-limit "$ORG_SWEEP_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" | |
| 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." |