diff --git a/.claude-review-ignore b/.claude-review-ignore index a5dfbb1..b8f5997 100644 --- a/.claude-review-ignore +++ b/.claude-review-ignore @@ -2,8 +2,8 @@ # One per line: owner/repo # Lines starting with # are comments. Blank lines are ignored. # -# The audit script (claude-review-audit.sh) skips these repos entirely. -# Use this for repos that should never have the review installed. +# Historical: the audit script that read this file was retired in #154. +# Only the broken nightowl-ruleset-rollout.sh.broken still names it. nightowlstudiollc/networth-agent smartwatermelon/headroom diff --git a/.github/workflows/claude-blocking-review.yml b/.github/workflows/claude-blocking-review.yml index 7027e37..f1afb24 100644 --- a/.github/workflows/claude-blocking-review.yml +++ b/.github/workflows/claude-blocking-review.yml @@ -1,5 +1,11 @@ name: Claude Blocking Review +# DEPRECATED (smartwatermelon/github-workflows#154). Do not delete while any caller exists. + +# Callers left: smartwatermelon/crazy-larry, nightowlstudiollc/networth-agent. + +# Both still require its check. The kebab-tax repos may also call it. + # Reusable workflow: blocks PR merges when Claude finds bugs, reliability # regressions, security issues, or data-loss risks. # diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 82e30dc..03e1886 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -32,8 +32,7 @@ name: Dependabot Auto-Merge # out PR code here would expose those secrets to malicious dependency # PRs (see the Ultralytics, nx, and tj-actions incidents). This # invariant is also enforced by a CI guardrail — see self-review.yml's -# `guard-no-checkout` job in this repo, and the read-only fleet-wide -# check in claude-review-audit.sh. Closes #64. +# `guard-no-checkout` job in this repo. Closes #64. # # Callers MUST NOT use `secrets: inherit`. This workflow needs no # secrets beyond the ambient `GITHUB_TOKEN` it mints itself — it does diff --git a/.github/workflows/self-review.yml b/.github/workflows/self-review.yml index 202b863..c5f9350 100644 --- a/.github/workflows/self-review.yml +++ b/.github/workflows/self-review.yml @@ -1,49 +1,17 @@ name: Self-Review -# Self-applying caller: runs this repo's reusable Claude Blocking Review -# workflow on its own PRs. Produces the `claude-review / run-review` status -# check that branch protection requires on main. -# -# Uses a local path (./.github/workflows/claude-blocking-review.yml) rather -# than a tag, so PR branches dogfood the proposed changes to the reusable -# workflow against themselves before release. -# -# Replaces the .github/workflows/claude-code-review.yml caller that was -# deleted in commit 52e688b during the v1 rename. +# Repo-local guardrails run on this repo's own PRs. + +# The self-applying Claude review caller was removed in #154. on: pull_request: types: [opened, synchronize, ready_for_review, reopened] jobs: - claude-review: - permissions: - contents: read - pull-requests: write - issues: write - id-token: write - uses: ./.github/workflows/claude-blocking-review.yml - with: - pr_number: ${{ github.event.pull_request.number }} - extra_instructions: | - This repository hosts the reusable `claude-blocking-review.yml` - workflow itself. Pay particular attention to: - - Shell-injection risk in any step that interpolates PR data - - Changes to the verdict file / comment parsing contract that - consumer repos depend on - - Changes to allowed-tools that could broaden what Claude can run - - Grep/regex changes in the escape-hatch path (see #38 history) - secrets: - claude_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - guard-no-checkout: - # Plain-shell job, no LLM call involved — deliberately NOT a step - # inside claude-review above (that job is `uses: - # ./.github/workflows/claude-blocking-review.yml`; a job is either a - # reusable-workflow call or a normal job with steps, not both) and - # deliberately not folded into the Claude review prompt (that job - # skips workflow-self-modification PRs, which is exactly the PR - # category this guardrail exists to check). Closes #64. + # Plain-shell job, no LLM call involved. Guards the + # dependabot-auto-merge.yml no-checkout invariant. Closes #64. runs-on: ubuntu-latest permissions: contents: read diff --git a/README.md b/README.md index 2ad33d6..5ccd705 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,13 @@ Reusable GitHub Actions workflows. -## `claude-blocking-review` +## `claude-blocking-review` (DEPRECATED) + +> **Deprecated (#154).** Do not add this to new repos. Use +> `standards-check.yml` instead. The file stays only because +> `smartwatermelon/crazy-larry` and `nightowlstudiollc/networth-agent` still +> require its check, as may the kebab-tax repos. Do not delete it while any +> caller exists. The bulk-install and audit scripts are retired. Runs a Claude Code Review on every PR and **blocks merges** when Claude finds bugs, reliability regressions, security vulnerabilities, or data-loss risks. @@ -355,16 +361,11 @@ incidents (Ultralytics, nx, tj-actions) when combined with a checkout of PR-controlled code. This workflow is safe today because it never executes PR code: the only actions are API calls (`dependabot/fetch-metadata`, `gh pr review`, `gh pr merge`). **`actions/checkout` must never be added -to this file.** Two guardrails enforce this (closes #64): +to this file.** The guardrail below enforces this (closes #64): - `self-review.yml`'s `guard-no-checkout` job greps this repo's own copy of `dependabot-auto-merge.yml` and fails the PR if `actions/checkout` appears. -- `claude-review-audit.sh` performs a read-only, fleet-wide check: any - caller stub referencing `dependabot-auto-merge.yml` that contains - `actions/checkout` or `secrets: inherit` is flagged in the audit - report. This check does not block or gate anything — it's audit-only, - same as the rest of that script. ### Versioning @@ -672,76 +673,15 @@ are not compatible with this reusable workflow. --- -## Audit script - -`claude-review-audit.sh` audits Claude Review configuration across all -non-archived repos under `smartwatermelon` and `nightowlstudiollc`. Read-only — -reports gaps but makes no changes. - -```bash -./claude-review-audit.sh [--verbose] -``` - -Requires: `gh` CLI (authenticated), `jq`, `bash` 4.0+. - -### Excluding repos - -Add repos to `.claude-review-ignore` (one `owner/repo` per line) to skip them -in audits. Useful for repos that should never have the review installed. - ---- - -## Bulk-install script (`smartwatermelon` only) - -`bulk-install-claude-review.sh` installs (or refreshes) the -`claude-blocking-review` caller workflow across all non-archived repos under -`smartwatermelon`. Workaround for the fact that GitHub's workflow-templates -picker is **organization-only** — `smartwatermelon` is a user account, so -the templates in `smartwatermelon/.github/workflow-templates/` never appear -in the "New workflow" picker for `smartwatermelon/*` repos. - -```bash -./bulk-install-claude-review.sh # dry-run (default) -./bulk-install-claude-review.sh --apply # open PRs -./bulk-install-claude-review.sh --only smartwatermelon/foo --apply -``` - -The script classifies each repo: - -| Class | Action | -| ------- | -------- | -| `CURRENT` | Already on the target version. No-op. | -| `STALE` | Different pin or floating tag. Opens a PR bumping the pin. | -| `MISSING` | No caller workflow at all. Opens a PR adding the canonical stub. | -| `CUSTOMIZED` | Has caller-side modifications (`paths-ignore`, `extra_instructions`, custom `model`/`timeout_minutes`, etc.). Skipped regardless of pin — flag for human review. | -| `LOCAL` | Uses a local-path reference (`./...`). Not bumpable; e.g. the `github-workflows` repo's own self-review. | - -Target version is derived dynamically from the `@v…` pin in -`smartwatermelon/.github/workflow-templates/claude-blocking-review.yml`, -so a PR bumping that template is the single trigger to roll a new version -across the fleet. - -PRs include `[skip-claude-review: bulk-install]` in the body so the -blocking-review workflow doesn't gate its own install/bump PR. - -For `nightowlstudiollc`, this script is intentionally not used — that org gets -the workflow-templates picker via [`nightowlstudiollc/.github`](https://github.com/nightowlstudiollc/.github) -(mirrors `smartwatermelon/.github` workflow-templates; verified appearing under -"By Night Owl Studio" in the Actions → New workflow UI). Repository Rulesets -for org-wide enforcement were attempted once and rolled back (see -`docs/plans/2026-04-30-required-workflows-nightowlstudiollc.md`); a -re-attempt is deliberately not planned here and would need its own review -given that history. - ## New-repo bootstrap script (`smartwatermelon` only) -`new-smartwatermelon-repo.sh` is the creation-time counterpart to the -bulk-install script above — for a genuinely *new* `smartwatermelon` repo, -rather than retrofitting an existing one. `smartwatermelon` being a User +`new-smartwatermelon-repo.sh` is the creation-time script for a +genuinely *new* `smartwatermelon` repo (the old fleet bulk-install script +is retired). `smartwatermelon` being a User account means it can't use GitHub's org-only workflow-templates picker *or* Repository Rulesets to auto-attach anything on repo creation, so this script is the closest available approximation: one command instead of -"create repo, then remember to run the bulk-install script, then remember +"create repo, then remember to install the workflows, then remember the one repo setting no template can seed." ```bash diff --git a/bulk-install-claude-review.sh b/bulk-install-claude-review.sh deleted file mode 100755 index 47a9ff5..0000000 --- a/bulk-install-claude-review.sh +++ /dev/null @@ -1,453 +0,0 @@ -#!/usr/bin/env bash -# bulk-install-claude-review.sh -# -# Bulk-installs (or refreshes) the claude-blocking-review caller workflow -# across all eligible repos under the smartwatermelon user account. -# -# Workaround for the fact that GitHub's workflow-templates picker is -# org-only — smartwatermelon is a user account, so workflow-templates -# in smartwatermelon/.github never appear in the picker UI for -# smartwatermelon/* repos. This script closes that gap by opening -# install/refresh PRs. -# -# Behavior: -# - DRY-RUN BY DEFAULT. Use --apply to actually open PRs. -# - Classifies each repo as MISSING / STALE / CURRENT / CUSTOMIZED. -# - Opens at most one PR per repo per invocation. -# - Idempotent: re-running with no changes produces no PRs. -# -# Source of truth for the canonical caller stub: -# smartwatermelon/.github/workflow-templates/claude-blocking-review.yml -# at HEAD. The script extracts the @vX.Y.Z pin from that file and uses -# it as the target version. -# -# Requirements: gh CLI (authenticated, repo + workflow scopes), jq, -# base64, bash 4.0+, GNU grep/sed via PATH (works fine on macOS with -# stock /usr/bin/grep). -# -# Usage: -# ./bulk-install-claude-review.sh [--dry-run|--apply] [--only owner/repo] [--verbose] - -set -uo pipefail - -if [[ "${BASH_VERSINFO[0]}" -lt 4 ]]; then - printf "Error: bash 4.0+ required (found %s). Run as: ./%s\n" \ - "${BASH_VERSION}" "${0##*/}" >&2 - exit 1 -fi - -# ── config ───────────────────────────────────────────────────────────────────── -TARGET_OWNER="smartwatermelon" -CANONICAL_REPO="smartwatermelon/.github" -CANONICAL_PATH="workflow-templates/claude-blocking-review.yml" -INSTALL_PATH=".github/workflows/claude-code-review.yml" -IGNORE_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.claude-review-ignore" -PLAN_LINK="https://github.com/smartwatermelon/github-workflows/blob/main/docs/plans/2026-04-30-bulk-install-smartwatermelon-fleet.md" - -# ── flag parsing ──────────────────────────────────────────────────────────────── -APPLY=false -ONLY="" -VERBOSE=false - -while [[ "$#" -gt 0 ]]; do - case "${1}" in - --dry-run) APPLY=false ;; - --apply) APPLY=true ;; - --only) - shift - ONLY="${1:-}" - if [[ -z "${ONLY}" || "${ONLY}" == --* ]]; then - printf "Error: --only requires a non-empty owner/repo argument\n" >&2 - exit 2 - fi - ;; - --verbose) VERBOSE=true ;; - -h | --help) - sed -n '2,/^$/p' "${BASH_SOURCE[0]}" | sed 's/^# \?//' - exit 0 - ;; - *) - printf "Error: unknown argument '%s'\n" "${1}" >&2 - exit 2 - ;; - esac - shift -done - -# ── formatting ────────────────────────────────────────────────────────────────── -ok() { printf " ✅ %s\n" "${*}"; } -fail() { printf " ❌ %s\n" "${*}"; } -warn() { printf " ⚠️ %s\n" "${*}"; } -info() { ${VERBOSE} && printf " ℹ %s\n" "${*}" || true; } -note() { printf " → %s\n" "${*}"; } - -# ── load ignore list ──────────────────────────────────────────────────────────── -declare -A IGNORED_REPOS=() -if [[ -f "${IGNORE_FILE}" ]]; then - while IFS= read -r line; do - line="${line%%#*}" - line="${line// /}" - [[ -z "${line}" ]] && continue - IGNORED_REPOS["${line}"]=1 - done <"${IGNORE_FILE}" -fi - -# ── fetch canonical stub & extract target version ───────────────────────────── -fetch_file() { - local full="${1}" path="${2}" - gh api "repos/${full}/contents/${path}" --jq '.content' 2>/dev/null \ - | tr -d '\n' | base64 -d 2>/dev/null -} - -CANONICAL_CONTENT="$(fetch_file "${CANONICAL_REPO}" "${CANONICAL_PATH}")" -if [[ -z "${CANONICAL_CONTENT}" ]]; then - fail "Could not fetch canonical stub from ${CANONICAL_REPO}/${CANONICAL_PATH}" - exit 3 -fi - -# strip_comments before grep so a commented-out @version (e.g. an -# example block) can't be picked up as the canonical pin. Same defense -# extract_pin uses for consumer files. -strip_comments() { echo "${1}" | grep -v '^[[:space:]]*#'; } - -TARGET_VERSION="$(strip_comments "${CANONICAL_CONTENT}" \ - | grep -m1 -oE 'claude-blocking-review\.yml@[A-Za-z0-9._/-]+' \ - | sed 's/^.*@//')" - -if [[ -z "${TARGET_VERSION}" ]]; then - fail "Canonical stub does not contain a @version pin — aborting" - exit 3 -fi - -# ── accumulators ───────────────────────────────────────────────────────────────── -declare -a REPOS_MISSING=() -declare -a REPOS_STALE=() -declare -a REPOS_CURRENT=() -declare -a REPOS_CUSTOMIZED=() -declare -a REPOS_LOCAL=() -declare -a REPOS_SKIPPED=() -declare -a REPOS_ERROR=() -declare -a PR_URLS=() - -# ── helpers ───────────────────────────────────────────────────────────────────── -# strip_comments is defined earlier (before TARGET_VERSION extraction) - -uses_blocking_review() { - strip_comments "${1}" | grep -q "claude-blocking-review\.yml" -} - -# Extract the @version pin from a workflow file (first match in non-comment lines). -# Restricted to characters valid in git refs/SHAs to avoid capturing trailing -# punctuation (commas, quotes) from YAML. -extract_pin() { - strip_comments "${1}" | grep -m1 -oE 'claude-blocking-review\.yml@[A-Za-z0-9._/-]+' \ - | sed 's/^.*@//' -} - -# Detect local-path caller (uses ./ rather than a tagged reference) -uses_local_path() { - strip_comments "${1}" | grep -qE 'uses:[[:space:]]*\./' -} - -# Detect customization: caller has any of these non-trivial extras -has_customization() { - echo "${1}" | grep -qE '^[[:space:]]*(paths-ignore|paths|extra_instructions|model|timeout_minutes|env):' \ - || echo "${1}" | grep -q '\[skip-claude-review:' -} - -# Find the workflow file referencing the blocking review (returns "path|sha") -find_caller_file() { - local full="${1}" - local files - files="$(gh api "repos/${full}/contents/.github/workflows" \ - --jq '.[] | "\(.name)|\(.sha)"' 2>/dev/null || echo "")" - while IFS='|' read -r name sha; do - [[ -z "${name}" ]] && continue - local content - content="$(fetch_file "${full}" ".github/workflows/${name}")" - if uses_blocking_review "${content}"; then - printf "%s|%s\n" ".github/workflows/${name}" "${sha}" - return 0 - fi - done <<<"${files}" - return 1 -} - -# ── PR-creation primitives ────────────────────────────────────────────────────── -# Open a PR adding/updating the install file. Args: full_repo, branch_name, -# commit_title, pr_title, pr_body, file_path, file_content, [existing_sha] -open_install_pr() { - local full="${1}" branch="${2}" commit_title="${3}" pr_title="${4}" pr_body="${5}" - local file_path="${6}" file_content="${7}" existing_sha="${8:-}" - - if ! ${APPLY}; then - note "[dry-run] Would open PR: ${full} | branch=${branch} | file=${file_path}" - return 0 - fi - - # Determine base branch - local default_branch - default_branch="$(gh api "repos/${full}" --jq '.default_branch' 2>/dev/null || echo "main")" - - # Get base SHA - local base_sha - base_sha="$(gh api "repos/${full}/git/refs/heads/${default_branch}" \ - --jq '.object.sha' 2>/dev/null)" - if [[ -z "${base_sha}" ]]; then - fail "Could not resolve base SHA for ${full}@${default_branch}" - return 1 - fi - - # Create branch - if ! gh api -X POST "repos/${full}/git/refs" \ - -f ref="refs/heads/${branch}" -f sha="${base_sha}" >/dev/null 2>&1; then - # Already exists? Fail loudly so we don't accidentally re-push. - fail "Branch ${branch} already exists on ${full} — aborting this repo" - return 1 - fi - - # PUT file contents on the new branch. - # `printf "%s\n"` re-adds the trailing newline that command substitution - # (the $() that captured CANONICAL_CONTENT / current_content earlier) - # strips from text files. Without this, every install/bump PR would - # produce a file failing yamllint's "no newline at end of file" rule. - local b64_content - b64_content="$(printf "%s\n" "${file_content}" | base64 | tr -d '\n')" - local put_payload - if [[ -n "${existing_sha}" ]]; then - put_payload="$(jq -n \ - --arg msg "${commit_title}" \ - --arg branch "${branch}" \ - --arg content "${b64_content}" \ - --arg sha "${existing_sha}" \ - '{message:$msg, branch:$branch, content:$content, sha:$sha}')" - else - put_payload="$(jq -n \ - --arg msg "${commit_title}" \ - --arg branch "${branch}" \ - --arg content "${b64_content}" \ - '{message:$msg, branch:$branch, content:$content}')" - fi - - if ! gh api -X PUT "repos/${full}/contents/${file_path}" \ - --input - <<<"${put_payload}" >/dev/null 2>&1; then - fail "Failed to PUT ${file_path} on ${full}@${branch}" - return 1 - fi - - # Open PR - local pr_url - pr_url="$(gh pr create --repo "${full}" \ - --base "${default_branch}" \ - --head "${branch}" \ - --title "${pr_title}" \ - --body "${pr_body}" 2>/dev/null)" - if [[ -z "${pr_url}" ]]; then - fail "Failed to open PR on ${full} (${branch} created and file pushed; PR creation failed)" - return 1 - fi - ok "Opened ${pr_url}" - PR_URLS+=("${pr_url}") -} - -pr_body_template() { - local action="${1}" - cat <; got '${ONLY}'" - exit 2 - fi - process_repo "${ONLY#"${TARGET_OWNER}/"}" -else - repos="$(gh repo list "${TARGET_OWNER}" --no-archived --json name --limit 300 \ - --jq '.[].name' 2>/dev/null || echo "")" - if [[ -z "${repos}" ]]; then - fail "Could not list repos for ${TARGET_OWNER}" - exit 3 - fi - while IFS= read -r repo; do - [[ -z "${repo}" ]] && continue - process_repo "${repo}" - done <<<"${repos}" -fi - -# ── final summary ─────────────────────────────────────────────────────────────── -printf "\n\n══════════════════════════════════════════════════════════\n" -printf " SUMMARY (%s)\n" "${mode}" -printf "══════════════════════════════════════════════════════════\n" -printf " CURRENT (no action): %d\n" "${#REPOS_CURRENT[@]}" -printf " CUSTOMIZED (skipped): %d\n" "${#REPOS_CUSTOMIZED[@]}" -printf " LOCAL (no pin): %d\n" "${#REPOS_LOCAL[@]}" -printf " SKIPPED (ignore): %d\n" "${#REPOS_SKIPPED[@]}" -printf " STALE (will bump): %d\n" "${#REPOS_STALE[@]}" -printf " MISSING (will add): %d\n" "${#REPOS_MISSING[@]}" -printf " ERROR: %d\n" "${#REPOS_ERROR[@]}" - -list_section() { - local title="${1}" - shift - local -a items=("${@}") - [[ "${#items[@]}" -eq 0 ]] && return - printf "\n %s:\n" "${title}" - for r in "${items[@]}"; do printf " - %s\n" "${r}"; done -} - -list_section "MISSING" "${REPOS_MISSING[@]+"${REPOS_MISSING[@]}"}" -list_section "STALE" "${REPOS_STALE[@]+"${REPOS_STALE[@]}"}" -list_section "CUSTOMIZED" "${REPOS_CUSTOMIZED[@]+"${REPOS_CUSTOMIZED[@]}"}" -list_section "ERROR" "${REPOS_ERROR[@]+"${REPOS_ERROR[@]}"}" - -if [[ "${#PR_URLS[@]}" -gt 0 ]]; then - printf "\n PRs opened:\n" - for u in "${PR_URLS[@]}"; do printf " %s\n" "${u}"; done -fi - -if ! ${APPLY} && [[ "${#REPOS_MISSING[@]}" -gt 0 || "${#REPOS_STALE[@]}" -gt 0 ]]; then - printf "\n Re-run with --apply to actually open PRs.\n" -fi - -printf "\n══════════════════════════════════════════════════════════\n\n" - -# Surface ERROR class to callers (CI, cron, automation). Non-zero exit -# ensures fetch failures don't go silent. CUSTOMIZED is intentionally -# not an error — it's a human-review signal. -if [[ "${#REPOS_ERROR[@]}" -gt 0 ]]; then - exit 1 -fi diff --git a/claude-review-audit.sh b/claude-review-audit.sh deleted file mode 100755 index ff14863..0000000 --- a/claude-review-audit.sh +++ /dev/null @@ -1,455 +0,0 @@ -#!/usr/bin/env bash -# claude-review-audit.sh -# -# Audits Claude Review configuration across all non-archived repos under -# smartwatermelon (User) and nightowlstudiollc (Organization). -# -# READ-ONLY: reports necessary changes but makes none. -# -# Requirements: gh CLI (authenticated), jq, base64, bash 4.0+ -# Usage: ./claude-review-audit.sh [--verbose] - -set -uo pipefail - -# Requires bash 4.0+ for associative arrays (declare -A). -# macOS ships /bin/bash 3.2; run via the shebang (./script.sh) to get bash 5+. -if [[ "${BASH_VERSINFO[0]}" -lt 4 ]]; then - printf "Error: bash 4.0+ required (found %s). Run as: ./%s\n" \ - "${BASH_VERSION}" "${0##*/}" >&2 - exit 1 -fi - -# ── config ───────────────────────────────────────────────────────────────────── -declare -A OWNER_TYPES=( - ["smartwatermelon"]="User" - ["nightowlstudiollc"]="Organization" -) -OWNERS=("smartwatermelon" "nightowlstudiollc") -TARGET_SECRET="CLAUDE_CODE_OAUTH_TOKEN" -IGNORE_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.claude-review-ignore" -VERBOSE="${1:-}" -if [[ -n "${VERBOSE}" && "${VERBOSE}" != "--verbose" ]]; then - printf "Warning: unrecognized argument '%s'. Usage: ./%s [--verbose]\n" \ - "${VERBOSE}" "${0##*/}" >&2 -fi - -# ── formatting ───────────────────────────────────────────────────────────────── -ok() { printf " ✅ %s\n" "${*}"; } -fail() { printf " ❌ %s\n" "${*}"; } -warn() { printf " ⚠️ %s\n" "${*}"; } -info() { [[ "${VERBOSE}" == "--verbose" ]] && printf " ℹ %s\n" "${*}" || true; } - -# ── load ignore list ─────────────────────────────────────────────────────────── -declare -A IGNORED_REPOS=() -if [[ -f "${IGNORE_FILE}" ]]; then - while IFS= read -r line; do - line="${line%%#*}" # strip inline comments - line="${line// /}" # strip whitespace - [[ -z "${line}" ]] && continue - IGNORED_REPOS["${line}"]=1 - done <"${IGNORE_FILE}" -fi - -# ── global accumulators ───────────────────────────────────────────────────────── -declare -a REPOS_WITH_ISSUES=() -declare -a ISSUE_LINES=() -declare -a SKIPPED_REPOS=() -TOTAL_REPOS=0 -TOTAL_ISSUES=0 - -# ── helpers ───────────────────────────────────────────────────────────────────── - -# Extract a top-level 'name:' field from YAML content (first match only) -yaml_name() { - echo "${1}" | grep -m1 '^name:' | sed "s/^name:[[:space:]]*//" | tr -d "'\"" -} - -# Strip YAML comment lines before classification to avoid matching comment-only references -# (e.g., usage examples in the reusable workflow file itself). -strip_comments() { - echo "${1}" | grep -v '^[[:space:]]*#' -} - -# Check if a workflow file's content references the blocking review workflow -uses_blocking_review() { - local content - content=$(strip_comments "${1}") - echo "${content}" | grep -q "claude-blocking-review\.yml" -} - -# Check if a workflow file's content references the dependabot-auto-merge -# reusable workflow (caller stub, in any repo in the fleet) -uses_dependabot_auto_merge() { - local content - content=$(strip_comments "${1}") - echo "${content}" | grep -q "dependabot-auto-merge\.yml" -} - -# Check if a workflow file is the Claude assistant (responds to @claude) -is_claude_assistant() { - local content - content=$(strip_comments "${1}") - echo "${content}" | grep -q "anthropics/claude-code-action" \ - && echo "${content}" | grep -qE "issue_comment|pull_request_review|issues:" -} - -# Check if a workflow file is a Claude code review (runs on PRs, not assistant-style) -is_claude_code_review() { - local content - content=$(strip_comments "${1}") - echo "${content}" | grep -q "anthropics/claude-code-action" \ - && echo "${content}" | grep -q "pull_request" \ - && ! echo "${content}" | grep -qE "issue_comment|pull_request_review" -} - -# Fetch and base64-decode a file from a repo via the GitHub Contents API -fetch_file() { - local full="${1}" path="${2}" - gh api "repos/${full}/contents/${path}" --jq '.content' 2>/dev/null \ - | tr -d '\n' | base64 -d 2>/dev/null -} - -# ── per-repo check ────────────────────────────────────────────────────────────── -check_repo() { - local owner="${1}" repo="${2}" - local full="${owner}/${repo}" - local -a issues=() - - printf "\n── %s\n" "${full}" - TOTAL_REPOS=$((TOTAL_REPOS + 1)) - - # ── metadata ────────────────────────────────────────────────────────────── - local meta - meta=$(gh api "repos/${full}" --jq '{default_branch,visibility,has_issues}' 2>/dev/null) || { - fail "Cannot access repo — skipping" - REPOS_WITH_ISSUES+=("${full}") - ISSUE_LINES+=("${full}: repo access error") - TOTAL_ISSUES=$((TOTAL_ISSUES + 1)) - return - } - - local default_branch visibility - default_branch=$(echo "${meta}" | jq -r .default_branch) - visibility=$(echo "${meta}" | jq -r .visibility) - info "branch=${default_branch} visibility=${visibility}" - - # ── 1. Workflow files ────────────────────────────────────────────────────── - local wf_list - wf_list=$(gh api "repos/${full}/contents/.github/workflows" \ - --jq '.[].name' 2>/dev/null || echo "") - - local has_blocking_caller=false - local has_claude_assistant=false - local has_claude_code_review=false - local caller_workflow_name="" - local caller_job_name="" - - if [[ -z "${wf_list}" ]]; then - fail "No .github/workflows directory found" - issues+=("Add .github/workflows with Claude workflow(s)") - else - while IFS= read -r wf; do - [[ -z "${wf}" ]] && continue - - local raw - raw=$(fetch_file "${full}" ".github/workflows/${wf}") - [[ -z "${raw}" ]] && { - warn "Could not fetch ${wf} — skipped" - continue - } - - if uses_blocking_review "${raw}"; then - has_blocking_caller=true - # Best-effort: extract workflow name and calling job name for status-check hint - caller_workflow_name=$(yaml_name "${raw}") - # Find the job key whose 'uses:' line references claude-blocking-review.yml - local in_job=false current_job="" - while IFS= read -r line; do - if echo "${line}" | grep -qE '^ [a-zA-Z0-9_-]+:'; then - current_job=$(echo "${line}" | sed 's/:[[:space:]]*//' | tr -d ' ') - in_job=true - fi - if ${in_job} && echo "${line}" | grep -q "claude-blocking-review\.yml"; then - caller_job_name="${current_job}" - break - fi - done <<<"${raw}" - local job_suffix="" - [[ -n "${caller_job_name}" ]] && job_suffix=" (job: ${caller_job_name})" - ok "Blocking review caller: ${wf}${job_suffix}" - - # Check that the caller passes the OAuth token secret to the reusable workflow - if echo "${raw}" | grep -q "claude_oauth_token"; then - ok "Caller passes claude_oauth_token secret to blocking review" - else - fail "Caller does not appear to pass claude_oauth_token to blocking review" - issues+=("In ${wf}, add: secrets: claude_oauth_token: \${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}") - fi - fi - - if uses_dependabot_auto_merge "${raw}"; then - # READ-ONLY audit check for #64's guardrail extended to caller stubs - # fleet-wide (the reusable's own no-checkout invariant is enforced - # by self-review.yml's guard-no-checkout job, but that only ever - # sees this repo's copy of the file — it never sees the 26+ caller - # stubs living in other repos, which is exactly where - # `actions/checkout` or `secrets: inherit` would actually be added). - # This does not block or gate anything — it only surfaces in the - # audit report, matching the rest of this script's behavior. - # - # Operate on comment-stripped content and anchor to the `uses:` - # form, matching self-review.yml's guard-no-checkout job (#97) — - # a raw/unanchored grep would false-positive on a caller stub that - # merely mentions "actions/checkout" or "secrets: inherit" in a - # comment (e.g. a warning not to add them). - local stripped - stripped=$(strip_comments "${raw}") - local has_checkout=false has_secrets_inherit=false - echo "${stripped}" | grep -qE 'uses:[[:space:]]*actions/checkout' && has_checkout=true - echo "${stripped}" | grep -qE 'secrets:[[:space:]]*inherit' && has_secrets_inherit=true - - if [[ "${has_checkout}" == true ]]; then - fail "Caller stub ${wf} references dependabot-auto-merge.yml AND contains actions/checkout" - issues+=("SECURITY: remove actions/checkout from ${wf} — dependabot-auto-merge.yml uses pull_request_target and must never check out PR code (see #64)") - fi - if [[ "${has_secrets_inherit}" == true ]]; then - fail "Caller stub ${wf} references dependabot-auto-merge.yml AND uses secrets: inherit" - issues+=("SECURITY: remove 'secrets: inherit' from ${wf} — dependabot-auto-merge.yml needs no repo secrets; inherit hands every repo secret to a pull_request_target job evaluating external PR content") - fi - if [[ "${has_checkout}" == false && "${has_secrets_inherit}" == false ]]; then - ok "Dependabot auto-merge caller: ${wf} (no checkout, no secrets: inherit)" - fi - fi - - if is_claude_assistant "${raw}"; then - has_claude_assistant=true - ok "Claude assistant workflow: ${wf}" - fi - - if is_claude_code_review "${raw}"; then - has_claude_code_review=true - ok "Claude code review workflow: ${wf}" - fi - done <<<"${wf_list}" - - if [[ "${has_blocking_caller}" == false && "${has_claude_assistant}" == false && "${has_claude_code_review}" == false ]]; then - local wf_count - wf_count=$(printf "%s\n" "${wf_list}" | grep -c '.' || echo 0) - fail "No Claude workflows found (${wf_count} workflow files scanned)" - issues+=("Add workflow calling claude-blocking-review.yml and/or claude-assistant (claude.yml)") - fi - fi - - # ── 2. Secret: CLAUDE_CODE_OAUTH_TOKEN ──────────────────────────────────── - local secret_found=false - - # Repo-level secrets (also includes org secrets selected for this repo in some API versions) - local secrets_json - secrets_json=$(gh api "repos/${full}/actions/secrets" 2>/dev/null || echo "") - - if [[ -z "${secrets_json}" ]]; then - warn "Could not read repo secrets (token scope may be insufficient)" - issues+=("Verify ${TARGET_SECRET} secret exists — could not check") - elif echo "${secrets_json}" | jq -e --arg s "${TARGET_SECRET}" \ - '[.secrets[].name] | any(. == $s)' >/dev/null 2>&1; then - secret_found=true - ok "${TARGET_SECRET} found in repo secrets" - fi - - # Org-level secrets (only applicable for Organization owners) - if [[ "${secret_found}" == false && "${OWNER_TYPES[${owner}]}" == "Organization" ]]; then - # First check the secret's visibility: 'all'/'private' means every repo in the org has access; - # 'selected' means only explicitly listed repos do. The /repositories endpoint only returns - # a populated list when visibility=selected, so we must check visibility first. - local org_secret_visibility - org_secret_visibility=$(gh api "orgs/${owner}/actions/secrets/${TARGET_SECRET}" \ - --jq '.visibility' 2>/dev/null || echo "") - if [[ "${org_secret_visibility}" == "all" || "${org_secret_visibility}" == "private" ]]; then - secret_found=true - ok "${TARGET_SECRET} available via org-level secret (visibility=${org_secret_visibility})" - elif [[ "${org_secret_visibility}" == "selected" ]]; then - local org_secret_repos - org_secret_repos=$(gh api "orgs/${owner}/actions/secrets/${TARGET_SECRET}/repositories" \ - --jq "[.repositories[].name] | any(. == \"${repo}\")" 2>/dev/null || echo "false") - if [[ "${org_secret_repos}" == "true" ]]; then - secret_found=true - ok "${TARGET_SECRET} available via org-level secret (selected for this repo)" - fi - fi - fi - - if [[ "${secret_found}" == false && -n "${secrets_json}" ]]; then - fail "${TARGET_SECRET} not found at repo or org level" - if [[ "${OWNER_TYPES[${owner}]}" == "Organization" ]]; then - issues+=("Add ${TARGET_SECRET} at repo level, or configure org-level secret to include this repo") - else - issues+=("Add ${TARGET_SECRET} secret to this repo") - fi - fi - - # ── 3. Branch protection & required status checks ────────────────────────── - # Only meaningful when a blocking review caller is configured - if [[ "${has_blocking_caller}" == true ]]; then - local protection - protection=$(gh api "repos/${full}/branches/${default_branch}/protection" 2>/dev/null || echo "") - - if [[ -z "${protection}" ]]; then - fail "No branch protection on '${default_branch}'" - issues+=("Enable branch protection on '${default_branch}' with Claude review as required status check") - else - # Collect required status checks from both the legacy 'contexts' and newer 'checks' arrays - local req_checks req_checks_apps all_checks - req_checks=$(echo "${protection}" | jq -r '.required_status_checks.contexts[]? // empty' 2>/dev/null || echo "") - req_checks_apps=$(echo "${protection}" | jq -r '.required_status_checks.checks[]?.context? // empty' 2>/dev/null || echo "") - all_checks=$(printf "%s\n%s" "${req_checks}" "${req_checks_apps}" | sort -u | grep -v '^$' || echo "") - - if echo "${all_checks}" | grep -qi "claude"; then - ok "Claude review is a required status check on '${default_branch}'" - echo "${all_checks}" | grep -i "claude" | while IFS= read -r chk; do - printf " check name: %s\n" "${chk}" - done - else - fail "Claude review is NOT in required status checks on '${default_branch}'" - # Compute expected check name to guide the user - local expected_check="" - if [[ -n "${caller_workflow_name}" && -n "${caller_job_name}" ]]; then - expected_check="${caller_workflow_name} / ${caller_job_name}" - elif [[ -n "${caller_job_name}" ]]; then - expected_check=" / ${caller_job_name}" - fi - local hint="" - [[ -n "${expected_check}" ]] && hint=" (expected: \"${expected_check}\")" - issues+=("Add Claude review to required status checks on '${default_branch}'${hint}") - - if [[ -n "${all_checks}" ]]; then - info "Current required checks:" - echo "${all_checks}" | while IFS= read -r chk; do info " - ${chk}"; done - else - info "No required status checks configured at all" - fi - fi - - # Enforce admins (admins can bypass required checks if false) - local enforce_admins - enforce_admins=$(echo "${protection}" | jq -r '.enforce_admins.enabled // false') - if [[ "${enforce_admins}" == "false" ]]; then - warn "enforce_admins=false: admins can merge without passing required checks" - fi - - # Strict status checks (branch must be up-to-date before merging) - local strict - strict=$(echo "${protection}" | jq -r '.required_status_checks.strict // false') - if [[ "${strict}" == "false" ]]; then - warn "strict=false: branch doesn't need to be up-to-date before merging" - fi - fi - fi - - # ── 4. GitHub Actions enabled? ──────────────────────────────────────────── - local actions_allowed - actions_allowed=$(gh api "repos/${full}/actions/permissions" --jq '.enabled' 2>/dev/null || echo "unknown") - if [[ "${actions_allowed}" == "false" ]]; then - fail "GitHub Actions are DISABLED for this repo" - issues+=("Enable GitHub Actions in repo settings") - elif [[ "${actions_allowed}" == "true" ]]; then - info "GitHub Actions: enabled" - fi - - # ── summary for this repo ────────────────────────────────────────────────── - if [[ "${#issues[@]}" -gt 0 ]]; then - TOTAL_ISSUES=$((TOTAL_ISSUES + ${#issues[@]})) - REPOS_WITH_ISSUES+=("${full}") - printf "\n CHANGES NEEDED (%d):\n" "${#issues[@]}" - for iss in "${issues[@]}"; do - printf " → %s\n" "${iss}" - ISSUE_LINES+=("${full}: ${iss}") - done - else - ok "Configuration looks complete" - fi -} - -# ── main ──────────────────────────────────────────────────────────────────────── -current_date=$(date) -token_status=$(gh auth status 2>&1 | grep 'Logged in' | head -1 | xargs || echo 'see gh auth status') -printf "\n══════════════════════════════════════════════════════════\n" -printf " Claude Review Configuration Audit\n" -printf " %s\n" "${current_date}" -printf " Token: %s\n" "${token_status}" -printf "══════════════════════════════════════════════════════════\n" -printf "\nChecking the following owners:\n" -for owner in "${OWNERS[@]}"; do - printf " • %s (%s)\n" "${owner}" "${OWNER_TYPES[${owner}]}" -done -printf "\nWhat this script checks per repo:\n" -printf " 1. Claude workflow files (.github/workflows/*.yml)\n" -printf " 2. Caller passes claude_oauth_token secret to reusable workflow\n" -printf " 3. Secret: CLAUDE_CODE_OAUTH_TOKEN (repo + org level)\n" -printf " 4. Branch protection & required status checks (for blocking review)\n" -printf " 5. GitHub Actions enabled\n" -printf " 6. Dependabot auto-merge caller stubs: no actions/checkout, no secrets: inherit (audit-only, #64)\n" -printf "\nNOTE: This script is read-only — it reports issues but makes no changes.\n" - -for owner in "${OWNERS[@]}"; do - printf "\n\n══════════════════════════════\n" - printf " %s (%s)\n" "${owner}" "${OWNER_TYPES[${owner}]}" - printf "══════════════════════════════\n" - - repos=$(gh repo list "${owner}" --no-archived --json name --limit 300 \ - --jq '.[].name' 2>/dev/null || echo "") - - if [[ -z "${repos}" ]]; then - warn "No repos found for ${owner} (no access or empty)" - continue - fi - - repo_count=$(printf "%s\n" "${repos}" | grep -c '.' || echo 0) - if [[ "${repo_count}" -ge 300 ]]; then - warn "Hit the 300-repo limit for ${owner} — increase --limit if more repos exist" - fi - printf " Scanning %d non-archived repos…\n" "${repo_count}" - - while IFS= read -r repo; do - [[ -z "${repo}" ]] && continue - if [[ -n "${IGNORED_REPOS["${owner}/${repo}"]:-}" ]]; then - SKIPPED_REPOS+=("${owner}/${repo}") - [[ "${VERBOSE}" == "--verbose" ]] && printf " ⏭ %s/%s (in .claude-review-ignore)\n" "${owner}" "${repo}" - continue - fi - check_repo "${owner}" "${repo}" - done <<<"${repos}" -done - -# ── final summary ──────────────────────────────────────────────────────────── -printf "\n\n══════════════════════════════════════════════════════════\n" -printf " FINAL SUMMARY\n" -printf "══════════════════════════════════════════════════════════\n" -printf " Repos scanned : %d\n" "${TOTAL_REPOS}" -printf " Repos ignored : %d\n" "${#SKIPPED_REPOS[@]}" -printf " Repos with issues: %d\n" "${#REPOS_WITH_ISSUES[@]}" -printf " Total issues : %d\n" "${TOTAL_ISSUES}" - -if [[ "${#REPOS_WITH_ISSUES[@]}" -eq 0 ]]; then - printf "\n ✅ All repos appear correctly configured — no changes needed.\n" -else - printf "\n ❌ Repos requiring attention:\n" - for r in "${REPOS_WITH_ISSUES[@]}"; do - printf " → %s\n" "${r}" - done - - printf "\n All issues by repo:\n" - for line in "${ISSUE_LINES[@]}"; do - printf " • %s\n" "${line}" - done -fi - -if [[ "${#SKIPPED_REPOS[@]}" -gt 0 ]]; then - printf "\n ⏭ Ignored repos (.claude-review-ignore):\n" - for r in "${SKIPPED_REPOS[@]}"; do - printf " - %s\n" "${r}" - done -fi - -printf "\n══════════════════════════════════════════════════════════\n" -printf " Run with --verbose for additional informational details.\n" -printf "══════════════════════════════════════════════════════════\n\n" diff --git a/docs/plans/2026-10-01-retire-claude-blocking-review.md b/docs/plans/2026-10-01-retire-claude-blocking-review.md index 25500cf..d561fa1 100644 --- a/docs/plans/2026-10-01-retire-claude-blocking-review.md +++ b/docs/plans/2026-10-01-retire-claude-blocking-review.md @@ -1,7 +1,9 @@ # Retire the CI Claude reviewer (#154, Phase 5) -Status: PLAN, 2026-10-01. Phases 1–4 of #154 are done: `standards-check.yml` -exists (#162, #164, #165) and W3 flipped the required check fleet-wide. +Status: PLAN, 2026-10-01. Step 1 done (smartwatermelon/repo-template#11, +smartwatermelon/.github#19). Step 2 done (25 caller PRs merged 2026-10-02). +Phases 1–4 of #154 are done: `standards-check.yml` exists (#162, #164, #165) +and W3 flipped the required check fleet-wide. ## Decisions already made @@ -123,12 +125,10 @@ Comment with the survey before/after, the holdouts and why, and a pointer to this plan. Close it. Leave a note that the reusable file can be deleted once the last caller is gone. -## Open question for Andrew +## Decided -**`crazy-larry`:** flip it to `standards-check` now and retire its caller in -the sweep, or leave it on `claude-review` like `networth-agent`? Its -`standards-check.yml` exists but has never run, so the flip needs one PR to -prove it green first. +`crazy-larry` stays on `claude-review`, like `networth-agent` (Andrew, +2026-10-01). ## Cost and size diff --git a/nightowl-restore-blocking-review.sh b/nightowl-restore-blocking-review.sh deleted file mode 100755 index 94077e6..0000000 --- a/nightowl-restore-blocking-review.sh +++ /dev/null @@ -1,247 +0,0 @@ -#!/usr/bin/env bash -# EMERGENCY RESTORATION: re-install per-repo claude-blocking-review.yml on -# every active NightOwl repo. This undoes today's removal-side cleanup so that -# Claude blocking review fires on PRs again — independent of the (still-broken) -# org ruleset, which remains disabled. -# -# Strategy: local clone + branch + push + PR + auto-merge for each repo. The -# workflow file is added in the PR head, GitHub runs it on the PR (same-repo -# PRs use head workflows), the resulting `claude-review / run-review` check -# satisfies the per-repo branch protection, auto-merge fires. - -set -euo pipefail - -ORG="nightowlstudiollc" -BRANCH="chore/restore-claude-blocking-review" -WORK_DIR="/tmp/restore-blocking-review" -IGNORE_FILE="/Volumes/extra-vieille/Workspaces/github-workflows/.claude-review-ignore" - -# Canonical workflow content (matches nightowlstudiollc/.github/workflow-templates/claude-blocking-review.yml) -read -r -d '' WORKFLOW_CONTENT <<'YML' || true -name: Claude Blocking Review - -on: - pull_request: - types: [opened, synchronize, ready_for_review, reopened] - -permissions: - contents: read - pull-requests: write - issues: write - id-token: write - -jobs: - claude-review: - # Floating @v3, not an exact @v3.x.y pin. Exact pins are immutable, so a - # caller pinned to one silently opts out of every security fix published - # afterwards — that is how ~19 smartwatermelon repos kept resolving to a - # claude-code-action vulnerable to GHSA-8q5r-mmjf-575q. This template - # previously carried @v3.0.0 and would have deployed that same defect - # across every NightOwl repo it touched. - uses: smartwatermelon/github-workflows/.github/workflows/claude-blocking-review.yml@v3 - with: - pr_number: ${{ github.event.pull_request.number }} - secrets: - claude_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} -YML - -mkdir -p "$WORK_DIR" - -# Enumerate active NightOwl repos dynamically (excludes archived, .github, -# and any repo in .claude-review-ignore). Using dynamic discovery so a repo -# added in the last hours isn't silently skipped. -# Declare before mapfile so the array exists unconditionally, matching the -# ALL_REPOS handling below. mapfile does declare an empty array even when its -# input is empty (verified), so this is belt-and-braces rather than a fix for -# a live unbound-variable path -- but it makes the invariant independent of -# that bash subtlety, which is what the loop below relies on under `set -u`. -IGNORED=() -mapfile -t IGNORED < <(grep -v '^#' "$IGNORE_FILE" 2>/dev/null | grep -v '^$' || true) -is_ignored() { - local repo="$1" - # Return early rather than looping over "${IGNORED[@]:-}": on an empty array - # that expands to a single empty string, not to nothing, so the loop ran one - # spurious iteration with i="". Never a false positive (no repo is named "") - # but wrong, and the `:-` obscured that the array is always set. See #139. - if [[ ${#IGNORED[@]} -eq 0 ]]; then - return 1 - fi - for i in "${IGNORED[@]}"; do - [[ "$i" == "${ORG}/${repo}" ]] && return 0 - done - return 1 -} - -REPO_LIST=$(gh repo list "$ORG" --limit 1000 --json name,isArchived --jq '.[] | select(.isArchived | not) | .name') -[[ -z "$REPO_LIST" ]] && { - echo "ERROR: gh repo list returned no repos for ${ORG}" - exit 1 -} -mapfile -t ALL_REPOS <<<"$REPO_LIST" -[[ ${#ALL_REPOS[@]} -eq 1 && -z "${ALL_REPOS[0]}" ]] && ALL_REPOS=() - -REPOS=() -for r in "${ALL_REPOS[@]}"; do - [[ "$r" == ".github" ]] && continue - is_ignored "$r" && continue - REPOS+=("$r") -done -echo "Active candidate repos (${#REPOS[@]}): ${REPOS[*]}" - -OPENED=() -SKIPPED=() -RECOVERED=() -FAILED=() - -# Per-repo restoration as a function — runs with `set -e` (inherited); -# failures inside cause the function to return non-zero, and the for-loop's -# `if !` keeps the script alive across repos. -restore_repo() { - local repo="$1" - local default_branch existing_pr clone - - default_branch=$(gh repo view "${ORG}/${repo}" --json defaultBranchRef --jq '.defaultBranchRef.name') - - # Idempotency check #1: file already on default branch - if gh api "repos/${ORG}/${repo}/contents/.github/workflows/claude-blocking-review.yml?ref=${default_branch}" --jq '.path' >/dev/null 2>&1; then - echo " already restored on ${default_branch} (skip)" - SKIPPED+=("${ORG}/${repo}") - return 0 - fi - - # Idempotency check #2: PR already open from a prior partial run - existing_pr=$(gh pr list --repo "${ORG}/${repo}" --head "$BRANCH" --state open --json url --jq '.[0].url' 2>/dev/null || true) - if [[ -n "$existing_pr" ]]; then - echo " PR already exists: ${existing_pr} — re-attempting auto-merge" - if ! merge_err=$(command gh pr merge --auto --squash --delete-branch "$existing_pr" 2>&1); then - if [[ "$merge_err" == *"already has auto-merge enabled"* ]]; then - echo " auto-merge already enabled (idempotent)" - else - echo " auto-merge re-attempt FAILED: ${merge_err}" - FAILED+=("${ORG}/${repo} (auto-merge-retry)") - return 1 - fi - fi - RECOVERED+=("$existing_pr") - return 0 - fi - - # Idempotency check #3: branch exists on remote but no open PR (orphan from - # a previous failed run). Delete it so the fresh-clone path below works. - if gh api "repos/${ORG}/${repo}/git/ref/heads/${BRANCH}" --jq '.object.sha' >/dev/null 2>&1; then - echo " orphan remote branch detected — deleting before fresh attempt" - gh api -X DELETE "repos/${ORG}/${repo}/git/refs/heads/${BRANCH}" >/dev/null - fi - - # Fresh path: clone, branch, write file, commit, push, PR, auto-merge - # - # Guard before the rm: `set -u` catches an *unset* variable but not an - # *empty* one, and ALL_REPOS is built from command output (see the - # empty-element check at the top). An empty $repo would make clone - # "${WORK_DIR}/" and this rm would wipe the whole work dir instead of one - # clone. Refuse rather than delete a path we did not intend to build. - if [[ -z "${WORK_DIR:-}" || -z "${repo:-}" ]]; then - echo " FATAL: refusing to remove clone dir — WORK_DIR or repo is empty" >&2 - return 1 - fi - # Reject any repo name that is not a single plain path segment. A textual - # "starts with $WORK_DIR/" check is not enough: "../escape" satisfies it - # while resolving outside the work dir entirely. GitHub repo names are - # limited to [A-Za-z0-9._-], so anything else is either a bug upstream or - # an attempt to traverse. - if [[ ! "$repo" =~ ^[A-Za-z0-9._-]+$ || "$repo" == "." || "$repo" == ".." ]]; then - echo " FATAL: refusing to remove clone dir — unsafe repo name '${repo}'" >&2 - return 1 - fi - clone="${WORK_DIR}/${repo}" - rm -rf -- "$clone" - git clone --depth=1 "git@github.com:${ORG}/${repo}.git" "$clone" --quiet - git -C "$clone" checkout -b "$BRANCH" --quiet - mkdir -p "${clone}/.github/workflows" - printf '%s\n' "$WORKFLOW_CONTENT" >"${clone}/.github/workflows/claude-blocking-review.yml" - git -C "$clone" add .github/workflows/claude-blocking-review.yml - # Commit normally — hooks are NOT bypassed here. This previously passed a - # verify-skipping flag, which the repo's own policy forbids and which - # silently disabled any commit hooks the target repo installs. The content - # is a fixed template, so there is nothing a hook would legitimately need - # to reject; if one does reject it, that is signal worth seeing rather - # than suppressing. - git -C "$clone" commit --quiet -m "chore: restore claude-blocking-review caller - -Restoring per-repo blocking-review caller after the org ruleset rollout was -paused. Until the ruleset's workflow firing behavior is validated, the per-repo -file is the reliable mechanism for ensuring Claude reviews PRs." - git -C "$clone" push -u origin "$BRANCH" --quiet - - # gh pr create does NOT support --json; capture combined stdout+stderr - # and grep out the URL line. On success the URL appears on its own line. - local pr_url create_out - if ! create_out=$(gh pr create --repo "${ORG}/${repo}" --base "$default_branch" --head "$BRANCH" \ - --title "chore: restore claude-blocking-review caller" \ - --body "Restoring per-repo Claude blocking review after today's org-ruleset rollout was paused. The org ruleset (id 15802253) is currently disabled pending investigation; until then, the per-repo caller is the reliable gate." \ - 2>&1); then - echo " gh pr create FAILED: ${create_out}" - FAILED+=("${ORG}/${repo} (pr-create)") - return 1 - fi - pr_url=$(printf '%s\n' "$create_out" | grep -oE "https://github\\.com/${ORG}/${repo}/pull/[0-9]+" | tail -1) - if [[ -z "$pr_url" ]]; then - echo " gh pr create succeeded but no URL found in output:" - echo "${create_out}" | sed 's/^/ /' - FAILED+=("${ORG}/${repo} (pr-url-not-found)") - return 1 - fi - echo " PR: ${pr_url}" - OPENED+=("$pr_url") - - if ! merge_err=$(command gh pr merge --auto --squash --delete-branch "$pr_url" 2>&1); then - if [[ "$merge_err" == *"already has auto-merge enabled"* ]]; then - : - else - echo " auto-merge FAILED: ${merge_err}" - FAILED+=("${ORG}/${repo} (auto-merge)") - return 1 - fi - fi -} - -FIRST_REPO_DONE=0 -for repo in "${REPOS[@]}"; do - echo - echo "=== ${ORG}/${repo} ===" - if ! restore_repo "$repo"; then - if [[ $FIRST_REPO_DONE -eq 0 ]]; then - echo - echo "ABORT: first repo failed — likely a systemic bug, not a per-repo issue." - echo "Investigate before re-running. Subsequent repos NOT attempted." - break - fi - echo " → continuing to next repo" - fi - FIRST_REPO_DONE=1 - # Pace writes to avoid GitHub secondary rate limit on content mutations - sleep 2 -done - -echo -echo "=== Done ===" -echo "PRs opened (${#OPENED[@]}):" -printf ' %s\n' "${OPENED[@]:-(none)}" -if [[ ${#RECOVERED[@]} -gt 0 ]]; then - echo - echo "Pre-existing PRs recovered (${#RECOVERED[@]}):" - printf ' %s\n' "${RECOVERED[@]}" -fi -if [[ ${#SKIPPED[@]} -gt 0 ]]; then - echo - echo "Already restored, skipped (${#SKIPPED[@]}):" - printf ' %s\n' "${SKIPPED[@]}" -fi -if [[ ${#FAILED[@]} -gt 0 ]]; then - echo - echo "FAILED (${#FAILED[@]}) — investigate before re-running:" - printf ' %s\n' "${FAILED[@]}" -fi -echo -echo "Auto-merge will fire on each PR once its claude-review / run-review check passes." -echo "Workspace: ${WORK_DIR} — safe to delete after runs."