From b2d7e4dfda5d14a9f87378d08b631000e7926644 Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Thu, 30 Apr 2026 10:01:44 -0700 Subject: [PATCH 1/5] feat: add bulk-install-claude-review.sh for smartwatermelon fleet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the workflow-templates gap for the smartwatermelon user account. Since GitHub's `workflow-templates/` picker is organization-only, repos under `smartwatermelon` (a user account) never see the org-default stub in their Actions UI. The script opens install/refresh PRs to roll the canonical caller stub across the fleet. Behavior: - Dry-run by default; --apply to actually open PRs. - Classifies each repo: CURRENT / STALE / MISSING / CUSTOMIZED / LOCAL. CUSTOMIZED and LOCAL are skipped automatically. - Idempotent: re-running on a clean fleet produces no PRs. - Target version is derived from the @v… pin in smartwatermelon/.github/workflow-templates/claude-blocking-review.yml, so bumping that file is the single fleet-wide trigger. - PRs carry the [skip-claude-review: bulk-install] tag so the blocking-review workflow doesn't gate its own install/bump PR. Tested via dry-run against the live fleet: - 14 CURRENT, 4 STALE (mix of @v3 floating and @v2.0.2), 1 LOCAL (github-workflows itself), 1 MISSING (.github). Plan: docs/plans/2026-04-30-bulk-install-smartwatermelon-fleet.md Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 41 ++++ bulk-install-claude-review.sh | 419 ++++++++++++++++++++++++++++++++++ 2 files changed, 460 insertions(+) create mode 100755 bulk-install-claude-review.sh diff --git a/README.md b/README.md index b1cdf90..4ffff3a 100644 --- a/README.md +++ b/README.md @@ -201,3 +201,44 @@ Requires: `gh` CLI (authenticated), `jq`, `bash` 4.0+. 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`, etc.). Skipped — 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 for new repos, and (planned) Repository Rulesets +for org-wide enforcement. + +Plan: `docs/plans/2026-04-30-bulk-install-smartwatermelon-fleet.md`. diff --git a/bulk-install-claude-review.sh b/bulk-install-claude-review.sh new file mode 100755 index 0000000..41c6004 --- /dev/null +++ b/bulk-install-claude-review.sh @@ -0,0 +1,419 @@ +#!/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:-}" + ;; + --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 + +TARGET_VERSION="$(echo "${CANONICAL_CONTENT}" \ + | grep -m1 -oE 'claude-blocking-review\.yml@[^[:space:]]+' \ + | 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() { echo "${1}" | grep -v '^[[:space:]]*#'; } + +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) +extract_pin() { + strip_comments "${1}" | grep -m1 -oE 'claude-blocking-review\.yml@[^[:space:]]+' \ + | 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 '^\s*(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 + local b64_content + b64_content="$(printf "%s" "${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" From 85e048f358fa640f52a66a2b8bdc7b54c8b0a529 Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Thu, 30 Apr 2026 10:05:51 -0700 Subject: [PATCH 2/5] fix: address code-reviewer findings on bulk-install script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five issues raised by code-reviewer on the prior commit: 1. Validate fetch_file output for the per-repo caller file (mirrors the canonical-fetch validation that already existed). Empty content now classifies as ERROR instead of falling through and misclassifying as "no @version pin found". 2. Reorder customization check before pin comparison. Previously, a repo with caller-side customizations on a stale pin would get its pin sed-bumped without flagging the customizations. New behavior matches what the README claimed: any customized caller is skipped for human review regardless of pin status. 3. Tighten extract_pin regex from `[^[:space:]]+` to `[A-Za-z0-9._/-]+` so trailing punctuation (commas, quotes from quoted YAML values) doesn't get captured into the pin. Same fix applied to the canonical-version extraction. 4. Validate --only argument: error when missing or starts with `--`. Previously `./script --only --apply` would silently iterate every repo because ONLY="" fell through to the list-all-repos branch. 5. Exit non-zero when REPOS_ERROR is non-empty. Surfaces fetch failures to CI/cron/automation callers. CUSTOMIZED is intentionally not an error — it's a human-review signal. README CUSTOMIZED row updated to reflect "regardless of pin" behavior. All verified via shellcheck -S info clean and live --dry-run against the smartwatermelon fleet. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- bulk-install-claude-review.sh | 45 +++++++++++++++++++++++++++-------- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 4ffff3a..d978902 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,7 @@ The script classifies each repo: | `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`, etc.). Skipped — flag for human review. | +| `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 diff --git a/bulk-install-claude-review.sh b/bulk-install-claude-review.sh index 41c6004..4b7c274 100755 --- a/bulk-install-claude-review.sh +++ b/bulk-install-claude-review.sh @@ -56,6 +56,10 @@ while [[ "$#" -gt 0 ]]; do --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) @@ -102,7 +106,7 @@ if [[ -z "${CANONICAL_CONTENT}" ]]; then fi TARGET_VERSION="$(echo "${CANONICAL_CONTENT}" \ - | grep -m1 -oE 'claude-blocking-review\.yml@[^[:space:]]+' \ + | grep -m1 -oE 'claude-blocking-review\.yml@[A-Za-z0-9._/-]+' \ | sed 's/^.*@//')" if [[ -z "${TARGET_VERSION}" ]]; then @@ -127,9 +131,11 @@ 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) +# 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@[^[:space:]]+' \ + strip_comments "${1}" | grep -m1 -oE 'claude-blocking-review\.yml@[A-Za-z0-9._/-]+' \ | sed 's/^.*@//' } @@ -299,12 +305,29 @@ process_repo() { local current_content current_content="$(fetch_file "${full}" "${file_path}")" + if [[ -z "${current_content}" ]]; then + warn "ERROR — could not fetch ${file_path} (transient gh API failure?)" + REPOS_ERROR+=("${full}") + return + fi + if uses_local_path "${current_content}"; then ok "LOCAL — caller uses a local path reference (no remote pin to bump)" REPOS_LOCAL+=("${full}") return fi + # Check customization BEFORE pin comparison: any caller-side customization + # is reason to skip automated bumping, even if the pin is stale. The sed + # that bumps a stale pin is safe by itself, but a customized caller may + # have intent we shouldn't second-guess (paths-ignore semantics, custom + # extra_instructions, etc.) — flag for human review instead. + if has_customization "${current_content}"; then + warn "CUSTOMIZED — caller has paths-ignore/extra_instructions/etc; skipping (human review)" + REPOS_CUSTOMIZED+=("${full}") + return + fi + local current_pin current_pin="$(extract_pin "${current_content}")" @@ -315,13 +338,8 @@ process_repo() { fi if [[ "${current_pin}" == "${TARGET_VERSION}" ]]; then - if has_customization "${current_content}"; then - warn "CUSTOMIZED — on target (${current_pin}) but has caller-side customizations; skipping" - REPOS_CUSTOMIZED+=("${full}") - else - ok "CURRENT — already on ${TARGET_VERSION}" - REPOS_CURRENT+=("${full}") - fi + ok "CURRENT — already on ${TARGET_VERSION}" + REPOS_CURRENT+=("${full}") return fi @@ -417,3 +435,10 @@ if ! ${APPLY} && [[ "${#REPOS_MISSING[@]}" -gt 0 || "${#REPOS_STALE[@]}" -gt 0 ] 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 From 5879c85433462113a1adf13868e8f12503163350 Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Thu, 30 Apr 2026 10:12:45 -0700 Subject: [PATCH 3/5] style: use [[:space:]] for consistency with rest of codebase Closes #68. The `has_customization` regex used `\s` while the rest of the script and `claude-review-audit.sh` consistently use POSIX `[[:space:]]`. Replaced for portability and to match the convention. Classification still produces identical results on the live fleet. Co-Authored-By: Claude Opus 4.7 (1M context) --- bulk-install-claude-review.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bulk-install-claude-review.sh b/bulk-install-claude-review.sh index 4b7c274..1fdbedf 100755 --- a/bulk-install-claude-review.sh +++ b/bulk-install-claude-review.sh @@ -146,7 +146,7 @@ uses_local_path() { # Detect customization: caller has any of these non-trivial extras has_customization() { - echo "${1}" | grep -qE '^\s*(paths-ignore|paths|extra_instructions|model|timeout_minutes|env):' \ + echo "${1}" | grep -qE '^[[:space:]]*(paths-ignore|paths|extra_instructions|model|timeout_minutes|env):' \ || echo "${1}" | grep -q '\[skip-claude-review:' } From b741a56a9c15e754a888ca40fe58d713e6ba9476 Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Thu, 30 Apr 2026 10:19:43 -0700 Subject: [PATCH 4/5] fix: strip_comments before canonical version extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #66. TARGET_VERSION extraction skipped strip_comments while extract_pin used it — inconsistent. A commented-out @version in the canonical stub could be picked up as the target. No real-world hit (the canonical file has no such comment), but the inconsistency is a footgun. Hoists strip_comments to before the canonical-fetch block and reuses it. Removes the duplicate definition from the helpers section. Co-Authored-By: Claude Opus 4.7 (1M context) --- bulk-install-claude-review.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/bulk-install-claude-review.sh b/bulk-install-claude-review.sh index 1fdbedf..90152ac 100755 --- a/bulk-install-claude-review.sh +++ b/bulk-install-claude-review.sh @@ -105,7 +105,12 @@ if [[ -z "${CANONICAL_CONTENT}" ]]; then exit 3 fi -TARGET_VERSION="$(echo "${CANONICAL_CONTENT}" \ +# 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/^.*@//')" @@ -125,7 +130,7 @@ declare -a REPOS_ERROR=() declare -a PR_URLS=() # ── helpers ───────────────────────────────────────────────────────────────────── -strip_comments() { echo "${1}" | grep -v '^[[:space:]]*#'; } +# strip_comments is defined earlier (before TARGET_VERSION extraction) uses_blocking_review() { strip_comments "${1}" | grep -q "claude-blocking-review\.yml" From 0969d2d16ab638afd678cd2aca6964edc705072c Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Thu, 30 Apr 2026 10:24:10 -0700 Subject: [PATCH 5/5] fix: preserve trailing newline + version-suffix install branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes from pre-push review: Closes #71: command substitution \$() strips trailing newlines from captured file content. Both CANONICAL_CONTENT and current_content lose their trailing \n, so the PUT-uploaded content was missing a final newline — every install/bump PR would produce a file failing yamllint's no-new-line-at-end-of-file rule. Same issue I just fixed in smartwatermelon/.github PR #7. Fixed by switching the upload encoder from `printf "%s"` to `printf "%s\n"`. Closes #70: install branch was named `claude/install-blocking- review` with no version suffix. If a re-run encountered an already-open install PR, the git ref creation would fail. Renamed to `claude/install-blocking-review-\${TARGET_VERSION}` to match the bump-branch convention and let re-runs slide past existing same-version PRs. Issues #67, #72, #73 remain open as tracked tech-debt — they are real but lower-priority edge cases that can be addressed in follow-up PRs (mixed local/remote actions caller, transient gh-API-failure misclassification, .github meta-repo opt-out). Co-Authored-By: Claude Opus 4.7 (1M context) --- bulk-install-claude-review.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/bulk-install-claude-review.sh b/bulk-install-claude-review.sh index 90152ac..47a9ff5 100755 --- a/bulk-install-claude-review.sh +++ b/bulk-install-claude-review.sh @@ -206,9 +206,13 @@ open_install_pr() { return 1 fi - # PUT file contents on the new branch + # 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" "${file_content}" | base64 | tr -d '\n')" + b64_content="$(printf "%s\n" "${file_content}" | base64 | tr -d '\n')" local put_payload if [[ -n "${existing_sha}" ]]; then put_payload="$(jq -n \ @@ -292,7 +296,7 @@ process_repo() { local body body="$(pr_body_template "Install missing caller workflow")" open_install_pr "${full}" \ - "claude/install-blocking-review" \ + "claude/install-blocking-review-${TARGET_VERSION}" \ "ci: install claude-blocking-review caller (${TARGET_VERSION})" \ "ci: install claude-blocking-review caller (${TARGET_VERSION})" \ "${body}" \