From 3427094e84ec2d6e44fd03bf45ef03b832f11823 Mon Sep 17 00:00:00 2001 From: Alex Garcia Date: Wed, 16 Sep 2026 21:52:54 -0600 Subject: [PATCH 01/16] chore: migrate ship skill to global ship-it --- .claude/skills/ship/SKILL.md | 142 ----------------------------------- CLAUDE.md | 5 +- 2 files changed, 3 insertions(+), 144 deletions(-) delete mode 100644 .claude/skills/ship/SKILL.md diff --git a/.claude/skills/ship/SKILL.md b/.claude/skills/ship/SKILL.md deleted file mode 100644 index 7f337ae..0000000 --- a/.claude/skills/ship/SKILL.md +++ /dev/null @@ -1,142 +0,0 @@ ---- -name: ship -description: >- - End-to-end PR lifecycle: update docs, commit, create PR, monitor CI, - address CodeRabbit and Copilot reviews, and merge. Use this skill - whenever the user says "ship it", "send a PR", "commit and merge", - "push this", or wants to finalize and land their changes. Pass a PR - number to resume monitoring an existing PR. -disable-model-invocation: true -user-invocable: true -argument-hint: "[optional PR number to resume monitoring]" ---- - -# Ship — Commit, Monitor, Fix, Merge - -End-to-end workflow: update documentation, commit, create PR, monitor CI -and code reviews, address feedback, and merge when everything passes. - -If `$ARGUMENTS` contains a PR number, skip to the monitoring phase for -that PR. - -## Phase 1 — Update Documentation - -Before committing, review all staged and unstaged changes to understand -what was modified, then update: - -1. **CLAUDE.md** — If any CI/CD pipelines, hooks, skills, conventions, - or project structure changed, update the relevant sections. -2. **README.md** — If scripts, settings, architecture, or tooling - changed, update the relevant sections. -3. **docs/adr/** — If a significant architectural decision was made - (new pattern, new tool, structural change), create or update an ADR. -4. **MEMORY.md** — Update project memory at - `$HOME/.claude/projects/-Users-gamaware-Documents-Repos-personal-github-org-settings/memory/MEMORY.md` - if there are new gotchas, patterns, or preferences learned. - -Only update files where changes are actually needed. Do not update docs -for trivial changes. - -## Phase 2 — Commit and Push - -1. Stage all changes (including doc updates from Phase 1). -2. Write a conventional commit message summarizing all changes. -3. Push to the current feature branch. -4. Create a PR if one does not exist yet. Use the commit message as - the PR title. Include a summary and test plan in the body. - -## Phase 3 — Monitor CI - -Poll CI status using `gh pr checks ` every 30 seconds until -all checks complete (pass, fail, or skip). Report the final status. - -If any check fails: - -1. Read the failure logs with `gh run view --log-failed`. -2. Diagnose and fix the issue. -3. Commit and push the fix. -4. Return to monitoring. - -## Phase 4 — Monitor Code Reviews - -Check for review comments from CodeRabbit and Copilot: - -```bash -gh api repos/{owner}/{repo}/pulls/{number}/comments -gh api repos/{owner}/{repo}/issues/{number}/comments -``` - -If CodeRabbit is rate-limited, wait for the timeout period then trigger -with `gh pr comment --body "@coderabbitai review"`. - -For each review comment: - -1. Read the comment carefully. -2. Check if the comment is stale (already fixed in a later commit) by - reading the current file state. Dismiss stale comments. -3. If the comment is valid, fix the issue, commit, and push. -4. After fixing, re-monitor CI (return to Phase 3). - -After all fixes are pushed and the incremental review passes, resolve -stale CodeRabbit threads in bulk: - -```bash -gh pr comment --body "@coderabbitai resolve" -``` - -For Copilot threads, resolve them via the GraphQL API: - -```bash -gh api graphql -f query='{ - repository(owner: "gamaware", name: "github-org-settings") { - pullRequest(number: ) { - reviewThreads(first: 100) { nodes { id isResolved } } - } - } -}' -``` - -Then for each unresolved thread ID: - -```bash -gh api graphql -f query='mutation { - resolveReviewThread(input: {threadId: ""}) { - thread { isResolved } - } -}' -``` - -## Phase 5 — Merge - -Once ALL of the following are true: - -- All CI checks pass -- CodeRabbit review has no unaddressed comments -- Copilot review has no unaddressed comments - -Then merge: - -```bash -gh pr merge --squash --admin --delete-branch -``` - -Pull main locally after merge: - -```bash -git checkout main && git pull -``` - -Report the merge commit and confirm the branch was deleted. - -## Rules - -- Never suppress lint violations — fix them. -- No AI attribution in commits. -- Conventional commit messages required. -- CodeRabbit may hit hourly rate limits — wait and retry. -- Copilot comments may be stale after fix commits — verify current file state. -- CodeRabbit auto-reviews incrementally on every push (up to 5 commits, - then pauses). Use `@coderabbitai review` to resume after pause. -- CodeRabbit does NOT auto-resolve its threads — use `@coderabbitai resolve` - after fixes are confirmed. -- Use `--admin` to bypass branch protection for merge. diff --git a/CLAUDE.md b/CLAUDE.md index abbb858..32d771c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,8 +85,9 @@ General, secrets, shell, markdown, prose (Vale), GitHub Actions - `/audit` — run a dry-run settings check across all repos - `/add-repo-override` — add a per-repo exception to overrides.json - `/exclude-repo` — exclude a repository from governance -- `/ship [PR-number]` — end-to-end PR lifecycle: update docs, commit, - create PR, monitor CI, address reviews (CodeRabbit + Copilot), merge +- `/ship-it [PR-number]` — end-to-end PR lifecycle: update docs, commit, + create PR, monitor CI, address reviews (CodeRabbit + Copilot), merge, + clean up stale branches. Uses global skill. ## Code Review From dab71152e053a5197a715bb1cb9e563de429d607 Mon Sep 17 00:00:00 2001 From: Alex Garcia Date: Wed, 16 Sep 2026 21:52:54 -0600 Subject: [PATCH 02/16] ci: replace approval-based auto-merge with scheduled admin merge --- .github/workflows/auto-merge-bot-prs.yml | 169 ++++++++++++++++++++ .github/workflows/auto-merge-dependabot.yml | 42 ----- CLAUDE.md | 4 + README.md | 2 + docs/architecture.md | 1 + 5 files changed, 176 insertions(+), 42 deletions(-) create mode 100644 .github/workflows/auto-merge-bot-prs.yml delete mode 100644 .github/workflows/auto-merge-dependabot.yml diff --git a/.github/workflows/auto-merge-bot-prs.yml b/.github/workflows/auto-merge-bot-prs.yml new file mode 100644 index 0000000..8491690 --- /dev/null +++ b/.github/workflows/auto-merge-bot-prs.yml @@ -0,0 +1,169 @@ +# ----------------------------------------------------------------- +# Auto-merge bot PRs +# +# Squash-merges Dependabot PRs and the weekly pre-commit hook update +# PRs once every check on the PR is green and none is pending. +# +# Why admin bypass: the pre-commit PRs are authored with the repo +# owner's PAT, and GitHub refuses self-approval, so a review-based +# auto-merge can never satisfy the CODEOWNERS rule. Branch protection +# is not enforced on admins, so the owner's PAT merges directly. +# +# Safety: only same-repository PRs are eligible (never forks), the +# pre-commit branch must be authored by the repo owner, and a PR is +# never merged while it is a draft, has conflicts, has no registered +# checks, or has any failing or pending check. A PR that is behind +# main is updated and picked up on the next run. +# +# Requires: PRE_COMMIT_PAT secret (fine-grained PAT of the repo owner +# with contents, pull requests and workflows write access). +# ----------------------------------------------------------------- +name: Auto-merge bot PRs +run-name: "Auto-merge bot PRs (${{ github.event_name }})" + +on: + schedule: + - cron: "17 * * * *" + workflow_dispatch: + +permissions: {} + +concurrency: + group: auto-merge-bot-prs + cancel-in-progress: false + +jobs: + merge: + name: Merge green bot PRs + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Merge eligible PRs + env: + GH_TOKEN: ${{ secrets.PRE_COMMIT_PAT }} + REPO: ${{ github.repository }} + OWNER: ${{ github.repository_owner }} + run: | + set -euo pipefail + + if [ -z "$GH_TOKEN" ]; then + echo "::error::PRE_COMMIT_PAT secret is not set on this repository" + exit 1 + fi + + # Candidate PRs: Dependabot, or the weekly pre-commit update branch + # opened by the repo owner. Fork PRs are never eligible, because + # anyone can name a fork branch to match the prefix. + # Capture the list first so a failing gh call aborts the job + # instead of silently reading as "nothing to do". + candidate_list=$( + gh pr list --repo "$REPO" --state open --limit 100 \ + --json number,author,headRefName,isDraft,isCrossRepository,createdAt \ + --jq '.[] + | select(.isDraft | not) + | select(.isCrossRepository | not) + | select( + ((.author.login | sub("^app/"; "")) == "dependabot") + or ( + (.headRefName | startswith("chore/update-pre-commit-hooks")) + and (.author.login == env.OWNER) + ) + ) + | "\(.number) \(.createdAt)"' + ) + mapfile -t candidates <<< "$candidate_list" + if [ "${#candidates[@]}" -eq 1 ] && [ -z "${candidates[0]}" ]; then + candidates=() + fi + + if [ "${#candidates[@]}" -eq 0 ]; then + echo "No eligible bot PRs open." + exit 0 + fi + + now=$(date -u +%s) + merged=0 + skipped=0 + + for entry in "${candidates[@]}"; do + number="${entry%% *}" + created="${entry##* }" + created_epoch=$(date -u -d "$created" +%s) || created_epoch=$now + age_min=$(( (now - created_epoch) / 60 )) + + state=$(gh pr view "$number" --repo "$REPO" \ + --json mergeStateStatus --jq '.mergeStateStatus') + + case "$state" in + DIRTY) + echo "PR #$number: merge conflicts, skipping" + skipped=$((skipped + 1)) + continue + ;; + BEHIND) + echo "PR #$number: behind base, requesting branch update" + gh pr update-branch "$number" --repo "$REPO" \ + || echo "PR #$number: branch update failed, will retry next run" + skipped=$((skipped + 1)) + continue + ;; + esac + + # gh pr checks exits non-zero when checks fail or are pending, + # but still prints the JSON, so keep the output and ignore the + # exit code. Anything that is not a JSON array fails closed. + raw=$(gh pr checks "$number" --repo "$REPO" --json bucket 2>/dev/null) || true + if ! echo "$raw" | jq -e 'type == "array"' > /dev/null 2>&1; then + echo "PR #$number: could not read check status, skipping" + skipped=$((skipped + 1)) + continue + fi + + total=$(echo "$raw" | jq 'length') + passed=$(echo "$raw" | jq '[.[] | select(.bucket == "pass")] | length') + fail=$(echo "$raw" | jq '[.[] | select(.bucket == "fail" or .bucket == "cancel")] | length') + pending=$(echo "$raw" | jq '[.[] | select(.bucket == "pending")] | length') + + case "$total$passed$fail$pending" in + ''|*[!0-9]*) + echo "PR #$number: unparseable check summary, skipping" + skipped=$((skipped + 1)) + continue + ;; + esac + + if [ "$fail" -gt 0 ]; then + echo "PR #$number: $fail failing check(s), skipping" + skipped=$((skipped + 1)) + continue + fi + if [ "$pending" -gt 0 ]; then + echo "PR #$number: $pending pending check(s), skipping" + skipped=$((skipped + 1)) + continue + fi + # Never merge a PR that has no registered checks: either it is + # brand new, or its workflows never ran, and --admin would skip + # the required status checks GitHub would otherwise enforce. + if [ "$total" -eq 0 ]; then + echo "PR #$number: no checks registered (${age_min}m old), skipping" + skipped=$((skipped + 1)) + continue + fi + # Every check being skipped is not a green signal either. + if [ "$passed" -eq 0 ]; then + echo "PR #$number: no passing checks (all skipped), skipping" + skipped=$((skipped + 1)) + continue + fi + + echo "PR #$number: all $total check(s) green, merging" + if gh pr merge "$number" --repo "$REPO" --squash --admin --delete-branch; then + merged=$((merged + 1)) + else + echo "::warning::PR #$number: merge failed" + skipped=$((skipped + 1)) + fi + done + + echo "Merged: $merged | Skipped: $skipped" diff --git a/.github/workflows/auto-merge-dependabot.yml b/.github/workflows/auto-merge-dependabot.yml deleted file mode 100644 index a2b0d20..0000000 --- a/.github/workflows/auto-merge-dependabot.yml +++ /dev/null @@ -1,42 +0,0 @@ -# ----------------------------------------------------------------- -# Dependabot & Pre-commit Auto-merge -# -# Auto-approves and enables squash auto-merge for: -# - Dependabot PRs (all ecosystems) -# - Pre-commit hook update PRs (chore/update-pre-commit-hooks branch) -# -# Requires: PRE_COMMIT_PAT secret (fine-grained PAT with PR write) -# to satisfy CODEOWNERS approval requirement. -# -# Gate: CI must pass before GitHub executes the auto-merge. -# ----------------------------------------------------------------- -name: Auto-merge Dependabot & Pre-commit -run-name: "Auto-merge: PR #${{ github.event.pull_request.number }}" - -on: - pull_request_target: - types: [opened, synchronize, reopened] - -permissions: - contents: write - pull-requests: write - -jobs: - auto-merge: - name: Auto-approve and merge - runs-on: ubuntu-latest - if: >- - github.actor == 'dependabot[bot]' || - startsWith(github.head_ref, 'chore/update-pre-commit-hooks') - steps: - - name: Approve PR (as repo owner via PAT) - run: gh pr review --approve "$PR_URL" - env: - PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ secrets.PRE_COMMIT_PAT }} - - - name: Enable auto-merge (squash) - run: gh pr merge --auto --squash "$PR_URL" - env: - PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ secrets.PRE_COMMIT_PAT }} diff --git a/CLAUDE.md b/CLAUDE.md index 32d771c..49e0285 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,6 +71,10 @@ General, secrets, shell, markdown, prose (Vale), GitHub Actions - `security.yml` — Semgrep SAST + Trivy SCA (via composite action) - `update-pre-commit-hooks.yml` — weekly auto-update via PR (via composite action) +- `auto-merge-bot-prs.yml` — hourly scheduled job that squash-merges + Dependabot and pre-commit PRs with admin bypass once every check is + green (no approval step; GitHub rejects self-approval with the + owner's PAT) ## Composite Actions diff --git a/README.md b/README.md index 57e93e5..2dd809f 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,7 @@ github-org-settings/ │ │ └── update-pre-commit-composite/ # Composite: hook updates │ │ └── action.yml │ ├── workflows/ +│ │ ├── auto-merge-bot-prs.yml # Hourly admin merge of green bot PRs │ │ ├── sync-settings.yml # Weekly settings sync + reports │ │ ├── quality-checks.yml # PR/push linting and validation │ │ ├── security.yml # SAST + SCA scanning @@ -306,6 +307,7 @@ Add repo names to the `excluded` array in `config/overrides.json`: | `quality-checks.yml` | PR + push to main | Markdown, YAML, shell, structure | | `security.yml` | PR + push to main | Semgrep SAST + Trivy SCA | | `update-pre-commit-hooks.yml` | Weekly + manual | Auto-update hook versions | +| `auto-merge-bot-prs.yml` | Hourly + manual | Squash-merge green Dependabot and pre-commit PRs | ## Author diff --git a/docs/architecture.md b/docs/architecture.md index bb11f4c..b19d832 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -78,6 +78,7 @@ Reusable building blocks consumed by workflows: | `quality-checks.yml` | PR + push | Linting and validation | | `security.yml` | PR + push | SAST + SCA | | `update-pre-commit-hooks.yml` | Weekly Sunday 00:00 UTC | Hook version updates | +| `auto-merge-bot-prs.yml` | Hourly | Squash-merge green Dependabot and pre-commit PRs | ### Reporting From 974d145367237e7fdcb8dcd5a726b79468f9f038 Mon Sep 17 00:00:00 2001 From: Alex Garcia Date: Wed, 16 Sep 2026 21:52:54 -0600 Subject: [PATCH 03/16] fix: send both squash keys and surface PATCH failures in settings sync --- scripts/sync-repo-settings.sh | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/scripts/sync-repo-settings.sh b/scripts/sync-repo-settings.sh index 55eb7d3..4fccec1 100755 --- a/scripts/sync-repo-settings.sh +++ b/scripts/sync-repo-settings.sh @@ -70,10 +70,24 @@ sync_repo_settings() { fi done + # GitHub returns 422 invalid_squash_commit_setting_combo when + # squash_merge_commit_message is sent without squash_merge_commit_title + # (or vice versa), so always send the pair from the effective baseline. + if echo "$patch" | jq -e 'has("squash_merge_commit_title") or has("squash_merge_commit_message")' > /dev/null; then + local squash_pair + squash_pair=$(echo "$effective" | jq '.repo_settings | {squash_merge_commit_title, squash_merge_commit_message} | with_entries(select(.value != null))') + patch=$(echo "$patch" | jq --argjson s "$squash_pair" '. + $s') + fi + if [ "$changes" != "" ]; then if [ "$MODE" = "--apply" ]; then - gh api -X PATCH "repos/$OWNER/$repo" --input <(echo "$patch") > /dev/null 2>&1 - log "APPLIED repo settings for $repo" + local output + if output=$(gh api -X PATCH "repos/$OWNER/$repo" --input <(echo "$patch") 2>&1); then + log "APPLIED repo settings for $repo" + else + log "ERROR: could not apply repo settings for $repo: $(echo "$output" | head -n 1)" + changes="${changes}- ERROR: settings not applied (see log)\n" + fi else log "DRIFT detected in repo settings for $repo" fi @@ -115,7 +129,7 @@ sync_security() { changes="- Secret scanning: \`$current_scanning\` -> \`$desired_scanning_status\`\n" changes="${changes}- Push protection: \`$current_push\` -> \`$desired_push_status\`\n" if [ "$MODE" = "--apply" ]; then - gh api -X PATCH "repos/$OWNER/$repo" --input <(cat < /dev/null 2>&1 || log "WARN: Could not update security settings for $repo (may require admin)" - log "APPLIED security settings for $repo" + ) > /dev/null 2>&1; then + log "APPLIED security settings for $repo" + else + log "WARN: Could not update security settings for $repo (may require admin)" + changes="${changes}- ERROR: security settings not applied (see log)\n" + fi else log "DRIFT detected in security settings for $repo" fi From 60105a18f624008401a79904da904ce6d2dd2db9 Mon Sep 17 00:00:00 2001 From: Alex Garcia Date: Wed, 16 Sep 2026 21:52:54 -0600 Subject: [PATCH 04/16] fix: close superseded drift issues reliably --- .github/workflows/sync-settings.yml | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/.github/workflows/sync-settings.yml b/.github/workflows/sync-settings.yml index 7f4112a..ee3335f 100644 --- a/.github/workflows/sync-settings.yml +++ b/.github/workflows/sync-settings.yml @@ -88,16 +88,26 @@ jobs: See the [workflow run]($RUN_URL) for the full report." # Create new issue first, then close old ones - if ! gh issue create --title "$TITLE" --body "$BODY" --label "settings-drift"; then + if ! NEW_URL=$(gh issue create --title "$TITLE" --body "$BODY" --label "settings-drift"); then echo "::error::Failed to create drift issue" exit 1 fi - - # Close previous drift issues (all except the one just created) - LATEST=$(gh issue list --label "settings-drift" --state open --json number --jq '.[0].number') - gh issue list --label "settings-drift" --state open --json number --jq '.[].number' | while read -r num; do - if [ "$num" != "$LATEST" ]; then - gh issue close "$num" --comment "Superseded by new sync run." + NEW_NUMBER="${NEW_URL##*/}" + case "$NEW_NUMBER" in + ''|*[!0-9]*) + echo "::error::Could not parse issue number from '$NEW_URL'" + exit 1 + ;; + esac + echo "Created drift issue #$NEW_NUMBER" + + # Close every other open drift issue. The search index can lag + # behind issue creation, so compare against the number returned + # by 'gh issue create' instead of trusting list order. + for num in $(gh issue list --label "settings-drift" --state open --json number --jq '.[].number'); do + if [ "$num" != "$NEW_NUMBER" ]; then + gh issue close "$num" --comment "Superseded by #$NEW_NUMBER." \ + || echo "::warning::Could not close superseded drift issue #$num" fi done From 1368a20b02dfc2f0cf9d81870c700af9ceda5f0b Mon Sep 17 00:00:00 2001 From: Alex Garcia Date: Wed, 16 Sep 2026 21:59:02 -0600 Subject: [PATCH 05/16] fix: skip PR on merge-state read failure and fail drift cleanup loudly --- .github/workflows/auto-merge-bot-prs.yml | 8 ++++++-- .github/workflows/sync-settings.yml | 5 ++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/auto-merge-bot-prs.yml b/.github/workflows/auto-merge-bot-prs.yml index 8491690..4d8b480 100644 --- a/.github/workflows/auto-merge-bot-prs.yml +++ b/.github/workflows/auto-merge-bot-prs.yml @@ -91,8 +91,12 @@ jobs: created_epoch=$(date -u -d "$created" +%s) || created_epoch=$now age_min=$(( (now - created_epoch) / 60 )) - state=$(gh pr view "$number" --repo "$REPO" \ - --json mergeStateStatus --jq '.mergeStateStatus') + if ! state=$(gh pr view "$number" --repo "$REPO" \ + --json mergeStateStatus --jq '.mergeStateStatus'); then + echo "PR #$number: could not read merge state, skipping" + skipped=$((skipped + 1)) + continue + fi case "$state" in DIRTY) diff --git a/.github/workflows/sync-settings.yml b/.github/workflows/sync-settings.yml index ee3335f..40dec66 100644 --- a/.github/workflows/sync-settings.yml +++ b/.github/workflows/sync-settings.yml @@ -104,7 +104,10 @@ jobs: # Close every other open drift issue. The search index can lag # behind issue creation, so compare against the number returned # by 'gh issue create' instead of trusting list order. - for num in $(gh issue list --label "settings-drift" --state open --json number --jq '.[].number'); do + # Capture the list first so a failing gh call fails the step + # instead of silently iterating zero times. + OPEN_NUMBERS=$(gh issue list --label "settings-drift" --state open --json number --jq '.[].number') + for num in $OPEN_NUMBERS; do if [ "$num" != "$NEW_NUMBER" ]; then gh issue close "$num" --comment "Superseded by #$NEW_NUMBER." \ || echo "::warning::Could not close superseded drift issue #$num" From cb10c7c1b69f9c7828a5420a845c2001755c9c99 Mon Sep 17 00:00:00 2001 From: Alex Garcia Date: Wed, 16 Sep 2026 22:00:40 -0600 Subject: [PATCH 06/16] ci: run bot merge job in a dedicated environment --- .github/workflows/auto-merge-bot-prs.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/auto-merge-bot-prs.yml b/.github/workflows/auto-merge-bot-prs.yml index 4d8b480..7a82bd8 100644 --- a/.github/workflows/auto-merge-bot-prs.yml +++ b/.github/workflows/auto-merge-bot-prs.yml @@ -16,7 +16,10 @@ # main is updated and picked up on the next run. # # Requires: PRE_COMMIT_PAT secret (fine-grained PAT of the repo owner -# with contents, pull requests and workflows write access). +# with contents, pull requests and workflows write access). The job +# runs in the "automation" environment so the secret is only read +# inside a dedicated environment; GitHub creates the environment on +# first use if it does not exist yet. # ----------------------------------------------------------------- name: Auto-merge bot PRs run-name: "Auto-merge bot PRs (${{ github.event_name }})" @@ -36,6 +39,7 @@ jobs: merge: name: Merge green bot PRs runs-on: ubuntu-latest + environment: automation timeout-minutes: 15 steps: - name: Merge eligible PRs From c2b8fdb46a669709ca46d34a8820be8e4bf70226 Mon Sep 17 00:00:00 2001 From: Alex Garcia Date: Wed, 16 Sep 2026 22:03:56 -0600 Subject: [PATCH 07/16] fix: harden auto-merge check parsing and candidate filter --- .github/workflows/auto-merge-bot-prs.yml | 83 ++++++++++-------------- .github/workflows/sync-settings.yml | 4 +- 2 files changed, 36 insertions(+), 51 deletions(-) diff --git a/.github/workflows/auto-merge-bot-prs.yml b/.github/workflows/auto-merge-bot-prs.yml index 7a82bd8..c015f23 100644 --- a/.github/workflows/auto-merge-bot-prs.yml +++ b/.github/workflows/auto-merge-bot-prs.yml @@ -9,11 +9,10 @@ # auto-merge can never satisfy the CODEOWNERS rule. Branch protection # is not enforced on admins, so the owner's PAT merges directly. # -# Safety: only same-repository PRs are eligible (never forks), the -# pre-commit branch must be authored by the repo owner, and a PR is -# never merged while it is a draft, has conflicts, has no registered -# checks, or has any failing or pending check. A PR that is behind -# main is updated and picked up on the next run. +# Safety: never touches drafts, fork PRs, PRs from other authors, +# PRs with conflicts, PRs with no registered checks, or PRs with any +# failing or pending check. A PR that is behind main is updated and +# picked up on the next run. # # Requires: PRE_COMMIT_PAT secret (fine-grained PAT of the repo owner # with contents, pull requests and workflows write access). The job @@ -56,16 +55,18 @@ jobs: fi # Candidate PRs: Dependabot, or the weekly pre-commit update branch - # opened by the repo owner. Fork PRs are never eligible, because - # anyone can name a fork branch to match the prefix. - # Capture the list first so a failing gh call aborts the job - # instead of silently reading as "nothing to do". - candidate_list=$( + # authored by the repo owner (the PAT owner). Fork PRs are excluded + # because anyone can name a fork branch to match the prefix. + # Capture the list first so a gh failure aborts the run instead of + # being mistaken for "no open PRs". + pr_list=$( gh pr list --repo "$REPO" --state open --limit 100 \ - --json number,author,headRefName,isDraft,isCrossRepository,createdAt \ + --json number,author,headRefName,baseRefName,headRefOid,isDraft,isCrossRepository,headRepositoryOwner \ --jq '.[] | select(.isDraft | not) | select(.isCrossRepository | not) + | select(.headRepositoryOwner.login == env.OWNER) + | select(.baseRefName == "main") | select( ((.author.login | sub("^app/"; "")) == "dependabot") or ( @@ -73,9 +74,9 @@ jobs: and (.author.login == env.OWNER) ) ) - | "\(.number) \(.createdAt)"' + | "\(.number) \(.headRefOid)"' ) - mapfile -t candidates <<< "$candidate_list" + mapfile -t candidates <<< "$pr_list" if [ "${#candidates[@]}" -eq 1 ] && [ -z "${candidates[0]}" ]; then candidates=() fi @@ -85,15 +86,12 @@ jobs: exit 0 fi - now=$(date -u +%s) merged=0 skipped=0 for entry in "${candidates[@]}"; do number="${entry%% *}" - created="${entry##* }" - created_epoch=$(date -u -d "$created" +%s) || created_epoch=$now - age_min=$(( (now - created_epoch) / 60 )) + head_sha="${entry##* }" if ! state=$(gh pr view "$number" --repo "$REPO" \ --json mergeStateStatus --jq '.mergeStateStatus'); then @@ -117,28 +115,19 @@ jobs: ;; esac - # gh pr checks exits non-zero when checks fail or are pending, - # but still prints the JSON, so keep the output and ignore the - # exit code. Anything that is not a JSON array fails closed. - raw=$(gh pr checks "$number" --repo "$REPO" --json bucket 2>/dev/null) || true - if ! echo "$raw" | jq -e 'type == "array"' > /dev/null 2>&1; then - echo "PR #$number: could not read check status, skipping" - skipped=$((skipped + 1)) - continue + # gh pr checks exits non-zero when checks fail or are pending; + # the JSON is still printed, so keep the output and drop the exit + # code. Treat empty output as "no checks" so the guard below skips. + summary=$(gh pr checks "$number" --repo "$REPO" --json bucket \ + --jq '[ + length, + ([.[] | select(.bucket == "fail" or .bucket == "cancel")] | length), + ([.[] | select(.bucket == "pending")] | length) + ] | @tsv' 2>/dev/null) || true + if [ -z "$summary" ]; then + summary=$'0\t0\t0' fi - - total=$(echo "$raw" | jq 'length') - passed=$(echo "$raw" | jq '[.[] | select(.bucket == "pass")] | length') - fail=$(echo "$raw" | jq '[.[] | select(.bucket == "fail" or .bucket == "cancel")] | length') - pending=$(echo "$raw" | jq '[.[] | select(.bucket == "pending")] | length') - - case "$total$passed$fail$pending" in - ''|*[!0-9]*) - echo "PR #$number: unparseable check summary, skipping" - skipped=$((skipped + 1)) - continue - ;; - esac + IFS=$'\t' read -r total fail pending <<< "$summary" if [ "$fail" -gt 0 ]; then echo "PR #$number: $fail failing check(s), skipping" @@ -150,23 +139,19 @@ jobs: skipped=$((skipped + 1)) continue fi - # Never merge a PR that has no registered checks: either it is - # brand new, or its workflows never ran, and --admin would skip - # the required status checks GitHub would otherwise enforce. + # Never merge on an empty check list: a brand-new PR may not have + # registered its checks yet, and a held fork run has none at all. if [ "$total" -eq 0 ]; then - echo "PR #$number: no checks registered (${age_min}m old), skipping" - skipped=$((skipped + 1)) - continue - fi - # Every check being skipped is not a green signal either. - if [ "$passed" -eq 0 ]; then - echo "PR #$number: no passing checks (all skipped), skipping" + echo "PR #$number: no checks registered, skipping" skipped=$((skipped + 1)) continue fi echo "PR #$number: all $total check(s) green, merging" - if gh pr merge "$number" --repo "$REPO" --squash --admin --delete-branch; then + # Bind the merge to the commit whose checks were just inspected; + # if the head moved in the meantime the merge is refused. + if gh pr merge "$number" --repo "$REPO" --squash --admin --delete-branch \ + --match-head-commit "$head_sha"; then merged=$((merged + 1)) else echo "::warning::PR #$number: merge failed" diff --git a/.github/workflows/sync-settings.yml b/.github/workflows/sync-settings.yml index 40dec66..a5a9016 100644 --- a/.github/workflows/sync-settings.yml +++ b/.github/workflows/sync-settings.yml @@ -106,7 +106,7 @@ jobs: # by 'gh issue create' instead of trusting list order. # Capture the list first so a failing gh call fails the step # instead of silently iterating zero times. - OPEN_NUMBERS=$(gh issue list --label "settings-drift" --state open --json number --jq '.[].number') + OPEN_NUMBERS=$(gh issue list --label "settings-drift" --state open --limit 100 --json number --jq '.[].number') for num in $OPEN_NUMBERS; do if [ "$num" != "$NEW_NUMBER" ]; then gh issue close "$num" --comment "Superseded by #$NEW_NUMBER." \ @@ -119,7 +119,7 @@ jobs: env: GH_TOKEN: ${{ secrets.ORG_SETTINGS_PAT }} run: | - gh issue list --label "settings-drift" --state open --json number --jq '.[].number' | while read -r num; do + gh issue list --label "settings-drift" --state open --limit 100 --json number --jq '.[].number' | while read -r num; do gh issue close "$num" --comment "All repositories are now compliant." done From c84a94c8a7974b3066ef6e315eafa9460844952f Mon Sep 17 00:00:00 2001 From: Alex Garcia Date: Wed, 16 Sep 2026 22:03:56 -0600 Subject: [PATCH 08/16] fix: surface branch protection PUT failures and skip Free-plan repos --- scripts/sync-repo-settings.sh | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/scripts/sync-repo-settings.sh b/scripts/sync-repo-settings.sh index 4fccec1..8ec71cc 100755 --- a/scripts/sync-repo-settings.sh +++ b/scripts/sync-repo-settings.sh @@ -203,14 +203,25 @@ sync_branch_protection() { branch=$(echo "$effective" | jq -r '.branch_protection.branch') local current - current=$(gh api "repos/$OWNER/$repo/branches/$branch/protection" 2>/dev/null) || { + if ! current=$(gh api "repos/$OWNER/$repo/branches/$branch/protection" 2>&1); then + # Private repos on the Free plan cannot have branch protection at + # all, so this is not drift the sync can ever resolve. + if echo "$current" | grep -q "Upgrade to GitHub Pro"; then + log "SKIP: branch protection unavailable on this plan for $repo" + echo "" + return + fi changes="- Branch protection: **not configured** -> will be created\n" if [ "$MODE" = "--apply" ]; then - apply_branch_protection "$repo" "$branch" "$effective" + if apply_branch_protection "$repo" "$branch" "$effective"; then + log "APPLIED branch protection for $repo" + else + changes="${changes}- ERROR: branch protection not applied (see log)\n" + fi fi echo -e "$changes" return - } + fi # Check each protection setting local current_reviews desired_reviews @@ -273,8 +284,11 @@ sync_branch_protection() { if [ "$drift" = "true" ]; then if [ "$MODE" = "--apply" ]; then - apply_branch_protection "$repo" "$branch" "$effective" - log "APPLIED branch protection for $repo" + if apply_branch_protection "$repo" "$branch" "$effective"; then + log "APPLIED branch protection for $repo" + else + changes="${changes}- ERROR: branch protection not applied (see log)\n" + fi else log "DRIFT detected in branch protection for $repo" fi @@ -326,7 +340,8 @@ apply_branch_protection() { local deletions deletions=$(echo "$effective" | jq -r '.branch_protection.allow_deletions') - gh api -X PUT "repos/$OWNER/$repo/branches/$branch/protection" --input <(cat < /dev/null 2>&1 + ) 2>&1); then + return 0 + fi + log "ERROR: could not apply branch protection for $repo: $(echo "$output" | head -n 1)" + return 1 } # Sync standard labels across repos. From 84811fd1a3a51b791637d0e2aee9fa5f2fda087a Mon Sep 17 00:00:00 2001 From: Alex Garcia Date: Thu, 17 Sep 2026 01:23:18 -0600 Subject: [PATCH 09/16] ci: align auto-merge workflow with shared template Adopt the final shared workflow: only PRs targeting main are candidates, the merge is bound to the inspected head commit with --match-head-commit, and the no-op environment declaration is removed. --- .github/workflows/auto-merge-bot-prs.yml | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/.github/workflows/auto-merge-bot-prs.yml b/.github/workflows/auto-merge-bot-prs.yml index c015f23..b582b0d 100644 --- a/.github/workflows/auto-merge-bot-prs.yml +++ b/.github/workflows/auto-merge-bot-prs.yml @@ -15,10 +15,7 @@ # picked up on the next run. # # Requires: PRE_COMMIT_PAT secret (fine-grained PAT of the repo owner -# with contents, pull requests and workflows write access). The job -# runs in the "automation" environment so the secret is only read -# inside a dedicated environment; GitHub creates the environment on -# first use if it does not exist yet. +# with contents, pull requests and workflows write access). # ----------------------------------------------------------------- name: Auto-merge bot PRs run-name: "Auto-merge bot PRs (${{ github.event_name }})" @@ -38,7 +35,6 @@ jobs: merge: name: Merge green bot PRs runs-on: ubuntu-latest - environment: automation timeout-minutes: 15 steps: - name: Merge eligible PRs @@ -115,9 +111,10 @@ jobs: ;; esac - # gh pr checks exits non-zero when checks fail or are pending; - # the JSON is still printed, so keep the output and drop the exit - # code. Treat empty output as "no checks" so the guard below skips. + # gh pr checks exits non-zero on API errors or when no checks exist + # (with --json it exits 0 for failing or pending checks). Keep the + # output, drop the exit code, and treat empty output as "no checks" + # so the guard below skips. summary=$(gh pr checks "$number" --repo "$REPO" --json bucket \ --jq '[ length, From 3bb958133afb2dac451a93c525e91f03fa620378 Mon Sep 17 00:00:00 2001 From: Alex Garcia Date: Thu, 17 Sep 2026 01:28:41 -0600 Subject: [PATCH 10/16] fix: harden auto-merge head SHA guard and error reporting Skip a candidate whose head SHA is missing instead of merging without the head-commit binding, annotate branch-update failures as warnings, and surface gh pr checks API errors instead of reporting them as missing checks. --- .github/workflows/auto-merge-bot-prs.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/auto-merge-bot-prs.yml b/.github/workflows/auto-merge-bot-prs.yml index b582b0d..9bd5d0e 100644 --- a/.github/workflows/auto-merge-bot-prs.yml +++ b/.github/workflows/auto-merge-bot-prs.yml @@ -88,6 +88,11 @@ jobs: for entry in "${candidates[@]}"; do number="${entry%% *}" head_sha="${entry##* }" + if [[ ! "$head_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::PR #$number: missing head SHA in '$entry', skipping" + skipped=$((skipped + 1)) + continue + fi if ! state=$(gh pr view "$number" --repo "$REPO" \ --json mergeStateStatus --jq '.mergeStateStatus'); then @@ -105,7 +110,7 @@ jobs: BEHIND) echo "PR #$number: behind base, requesting branch update" gh pr update-branch "$number" --repo "$REPO" \ - || echo "PR #$number: branch update failed, will retry next run" + || echo "::warning::PR #$number: branch update failed, will retry next run" skipped=$((skipped + 1)) continue ;; @@ -115,13 +120,18 @@ jobs: # (with --json it exits 0 for failing or pending checks). Keep the # output, drop the exit code, and treat empty output as "no checks" # so the guard below skips. + checks_err="${RUNNER_TEMP:-/tmp}/pr-checks-$number.err" summary=$(gh pr checks "$number" --repo "$REPO" --json bucket \ --jq '[ length, ([.[] | select(.bucket == "fail" or .bucket == "cancel")] | length), ([.[] | select(.bucket == "pending")] | length) - ] | @tsv' 2>/dev/null) || true + ] | @tsv' 2>"$checks_err") || true if [ -z "$summary" ]; then + # Distinguish a real API/auth failure from "no checks reported". + if [ -s "$checks_err" ] && ! grep -qi "no checks" "$checks_err"; then + echo "::warning::PR #$number: gh pr checks failed: $(head -n 1 "$checks_err")" + fi summary=$'0\t0\t0' fi IFS=$'\t' read -r total fail pending <<< "$summary" From b2843c6998a85b259350bf6f4297b73b7da6fc63 Mon Sep 17 00:00:00 2001 From: Alex Garcia Date: Thu, 17 Sep 2026 01:35:40 -0600 Subject: [PATCH 11/16] fix: allowlist merge states and merge one PR per run Skip candidates whose mergeability is UNKNOWN or unrecognised, and stop after the first successful merge so remaining candidates are re-evaluated against the moved base on the next run instead of merging with stale mergeability. --- .github/workflows/auto-merge-bot-prs.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/auto-merge-bot-prs.yml b/.github/workflows/auto-merge-bot-prs.yml index 9bd5d0e..21f6333 100644 --- a/.github/workflows/auto-merge-bot-prs.yml +++ b/.github/workflows/auto-merge-bot-prs.yml @@ -114,6 +114,14 @@ jobs: skipped=$((skipped + 1)) continue ;; + CLEAN|BLOCKED|UNSTABLE|HAS_HOOKS) + ;; + *) + # UNKNOWN (mergeability still being computed) or anything new. + echo "PR #$number: merge state $state, skipping" + skipped=$((skipped + 1)) + continue + ;; esac # gh pr checks exits non-zero on API errors or when no checks exist @@ -155,11 +163,15 @@ jobs: fi echo "PR #$number: all $total check(s) green, merging" - # Bind the merge to the commit whose checks were just inspected; - # if the head moved in the meantime the merge is refused. + # Bind the merge to the head captured before the checks were + # inspected; if the head moved since then, the merge is refused. if gh pr merge "$number" --repo "$REPO" --squash --admin --delete-branch \ --match-head-commit "$head_sha"; then merged=$((merged + 1)) + # main has moved: stop here so every remaining candidate is + # re-evaluated against the new base on the next run. + echo "Merged one PR; remaining candidates are re-evaluated next run." + break else echo "::warning::PR #$number: merge failed" skipped=$((skipped + 1)) From 990b8206277d32299eb50dce939859e03c9f1300 Mon Sep 17 00:00:00 2001 From: Alex Garcia Date: Thu, 17 Sep 2026 01:48:42 -0600 Subject: [PATCH 12/16] fix: fail closed on branch-protection read errors and drift-issue cleanup --- .github/workflows/sync-settings.yml | 16 ++++++++++++++-- scripts/sync-repo-settings.sh | 9 +++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sync-settings.yml b/.github/workflows/sync-settings.yml index a5a9016..26979d9 100644 --- a/.github/workflows/sync-settings.yml +++ b/.github/workflows/sync-settings.yml @@ -119,9 +119,21 @@ jobs: env: GH_TOKEN: ${{ secrets.ORG_SETTINGS_PAT }} run: | - gh issue list --label "settings-drift" --state open --limit 100 --json number --jq '.[].number' | while read -r num; do - gh issue close "$num" --comment "All repositories are now compliant." + # Capture the list first so a failing gh call fails the step + # instead of silently iterating zero times. Close every issue + # even if one close fails, then fail the step so the leftover + # is visible. + OPEN_NUMBERS=$(gh issue list --label "settings-drift" --state open --limit 100 --json number --jq '.[].number') + close_failed=0 + for num in $OPEN_NUMBERS; do + if ! gh issue close "$num" --comment "All repositories are now compliant."; then + echo "::warning::Could not close drift issue #$num" + close_failed=1 + fi done + if [ "$close_failed" -ne 0 ]; then + exit 1 + fi new-repo-check: name: Discover New Repositories diff --git a/scripts/sync-repo-settings.sh b/scripts/sync-repo-settings.sh index 8ec71cc..a74031f 100755 --- a/scripts/sync-repo-settings.sh +++ b/scripts/sync-repo-settings.sh @@ -211,6 +211,15 @@ sync_branch_protection() { echo "" return fi + # Only a confirmed "Branch not protected" (404) means nothing is + # configured. Any other failure (auth, rate limit, outage) must not + # be mistaken for missing protection: the create path issues a PUT + # that would overwrite whatever is really there. + if ! echo "$current" | grep -q "Branch not protected"; then + log "ERROR: could not read branch protection for $repo: $(echo "$current" | head -n 1)" + echo -e "- ERROR: branch protection could not be read (see log)\n" + return + fi changes="- Branch protection: **not configured** -> will be created\n" if [ "$MODE" = "--apply" ]; then if apply_branch_protection "$repo" "$branch" "$effective"; then From b8badd343080814cbd8071d77b6c116750efac5e Mon Sep 17 00:00:00 2001 From: Alex Garcia Date: Thu, 17 Sep 2026 01:49:00 -0600 Subject: [PATCH 13/16] ci: scope automation job secrets to an environment Declare the no-op automation environment on the merge job so zizmor's secrets-outside-env audit passes in CI. The environment carries no protection rules and GitHub creates it on first use. --- .github/workflows/auto-merge-bot-prs.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/auto-merge-bot-prs.yml b/.github/workflows/auto-merge-bot-prs.yml index 21f6333..d7efd0e 100644 --- a/.github/workflows/auto-merge-bot-prs.yml +++ b/.github/workflows/auto-merge-bot-prs.yml @@ -36,6 +36,10 @@ jobs: name: Merge green bot PRs runs-on: ubuntu-latest timeout-minutes: 15 + # zizmor's secrets-outside-env audit requires secrets to be read inside a + # job environment. "automation" carries no protection rules and GitHub + # creates it on first use. + environment: automation steps: - name: Merge eligible PRs env: From 994e97dcf1f8ca2aae409090423062f830694cb8 Mon Sep 17 00:00:00 2001 From: Alex Garcia Date: Thu, 17 Sep 2026 01:54:18 -0600 Subject: [PATCH 14/16] fix: keep sync log lines out of the drift report log() wrote to stdout, and every section's stdout is captured as that repo's drift text, so the timestamped OK lines made every repo count as drifted and the compliant-cleanup step never ran. Send log lines to stderr so only real changes reach the report. --- scripts/sync-repo-settings.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/sync-repo-settings.sh b/scripts/sync-repo-settings.sh index a74031f..8480533 100755 --- a/scripts/sync-repo-settings.sh +++ b/scripts/sync-repo-settings.sh @@ -14,8 +14,11 @@ REPORT_FILE="${REPORT_FILE:-$ROOT_DIR/reports/sync-report.md}" mkdir -p "$(dirname "$REPORT_FILE")" +# Log to stderr: every sync_* function's stdout is captured by main() as +# that repo's drift text, so log lines on stdout would land in the report +# and make every repo look drifted. log() { - echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >&2 } # Collect all repos for the owner, excluding archived and excluded repos From c5b730053552f3e903e53bca33836dfcdfdf2889 Mon Sep 17 00:00:00 2001 From: Alex Garcia Date: Thu, 17 Sep 2026 01:57:35 -0600 Subject: [PATCH 15/16] fix: require a passing check and honour change requests Skip a candidate whose registered checks were all skipped (nothing actually passed), and skip a candidate on which a reviewer requested changes, so admin bypass never overrides a reviewer. --- .github/workflows/auto-merge-bot-prs.yml | 30 +++++++++++++++++++----- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/.github/workflows/auto-merge-bot-prs.yml b/.github/workflows/auto-merge-bot-prs.yml index d7efd0e..5663717 100644 --- a/.github/workflows/auto-merge-bot-prs.yml +++ b/.github/workflows/auto-merge-bot-prs.yml @@ -98,12 +98,22 @@ jobs: continue fi - if ! state=$(gh pr view "$number" --repo "$REPO" \ - --json mergeStateStatus --jq '.mergeStateStatus'); then + if ! state_line=$(gh pr view "$number" --repo "$REPO" \ + --json mergeStateStatus,reviewDecision \ + --jq '"\(.mergeStateStatus) \(.reviewDecision)"'); then echo "PR #$number: could not read merge state, skipping" skipped=$((skipped + 1)) continue fi + state="${state_line%% *}" + review="${state_line##* }" + + # Admin bypass must never override a reviewer who asked for changes. + if [ "$review" = "CHANGES_REQUESTED" ]; then + echo "PR #$number: changes requested by a reviewer, skipping" + skipped=$((skipped + 1)) + continue + fi case "$state" in DIRTY) @@ -137,16 +147,17 @@ jobs: --jq '[ length, ([.[] | select(.bucket == "fail" or .bucket == "cancel")] | length), - ([.[] | select(.bucket == "pending")] | length) + ([.[] | select(.bucket == "pending")] | length), + ([.[] | select(.bucket == "pass")] | length) ] | @tsv' 2>"$checks_err") || true if [ -z "$summary" ]; then # Distinguish a real API/auth failure from "no checks reported". if [ -s "$checks_err" ] && ! grep -qi "no checks" "$checks_err"; then echo "::warning::PR #$number: gh pr checks failed: $(head -n 1 "$checks_err")" fi - summary=$'0\t0\t0' + summary=$'0\t0\t0\t0' fi - IFS=$'\t' read -r total fail pending <<< "$summary" + IFS=$'\t' read -r total fail pending pass <<< "$summary" if [ "$fail" -gt 0 ]; then echo "PR #$number: $fail failing check(s), skipping" @@ -165,8 +176,15 @@ jobs: skipped=$((skipped + 1)) continue fi + # Skipped checks are not failures, but they are not evidence either: + # require at least one check that actually ran and passed. + if [ "$pass" -eq 0 ]; then + echo "PR #$number: $total check(s) registered but none passed, skipping" + skipped=$((skipped + 1)) + continue + fi - echo "PR #$number: all $total check(s) green, merging" + echo "PR #$number: $pass of $total check(s) passed, none failing or pending, merging" # Bind the merge to the head captured before the checks were # inspected; if the head moved since then, the merge is refused. if gh pr merge "$number" --repo "$REPO" --squash --admin --delete-branch \ From be46d1ac4351b2f4536a173579304936341d5528 Mon Sep 17 00:00:00 2001 From: Alex Garcia Date: Thu, 17 Sep 2026 01:58:38 -0600 Subject: [PATCH 16/16] fix: match HTTP status codes and log security PATCH errors in settings sync Recognise the 403 and 404 status codes in addition to GitHub's message text when deciding whether branch protection is unavailable or absent, and capture the security-settings PATCH error instead of discarding it. --- scripts/sync-repo-settings.sh | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/scripts/sync-repo-settings.sh b/scripts/sync-repo-settings.sh index 8480533..4ae6a84 100755 --- a/scripts/sync-repo-settings.sh +++ b/scripts/sync-repo-settings.sh @@ -132,7 +132,8 @@ sync_security() { changes="- Secret scanning: \`$current_scanning\` -> \`$desired_scanning_status\`\n" changes="${changes}- Push protection: \`$current_push\` -> \`$desired_push_status\`\n" if [ "$MODE" = "--apply" ]; then - if gh api -X PATCH "repos/$OWNER/$repo" --input <(cat < /dev/null 2>&1; then + ) 2>&1); then log "APPLIED security settings for $repo" else - log "WARN: Could not update security settings for $repo (may require admin)" + log "ERROR: could not update security settings for $repo: $(echo "$security_out" | head -n 1)" changes="${changes}- ERROR: security settings not applied (see log)\n" fi else @@ -209,7 +210,7 @@ sync_branch_protection() { if ! current=$(gh api "repos/$OWNER/$repo/branches/$branch/protection" 2>&1); then # Private repos on the Free plan cannot have branch protection at # all, so this is not drift the sync can ever resolve. - if echo "$current" | grep -q "Upgrade to GitHub Pro"; then + if echo "$current" | grep -qE "Upgrade to GitHub Pro|\(HTTP 403\)"; then log "SKIP: branch protection unavailable on this plan for $repo" echo "" return @@ -218,7 +219,7 @@ sync_branch_protection() { # configured. Any other failure (auth, rate limit, outage) must not # be mistaken for missing protection: the create path issues a PUT # that would overwrite whatever is really there. - if ! echo "$current" | grep -q "Branch not protected"; then + if ! echo "$current" | grep -qE "Branch not protected|\(HTTP 404\)"; then log "ERROR: could not read branch protection for $repo: $(echo "$current" | head -n 1)" echo -e "- ERROR: branch protection could not be read (see log)\n" return