Skip to content

⚡ Bolt: LCP 최적화를 위해 above-the-fold 이미지에서 decoding="async" 제거 #259

⚡ Bolt: LCP 최적화를 위해 above-the-fold 이미지에서 decoding="async" 제거

⚡ Bolt: LCP 최적화를 위해 above-the-fold 이미지에서 decoding="async" 제거 #259

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."