Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/plans/2026-04-30-required-workflows-nightowlstudiollc.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,25 @@ Rollback at any phase: set `enforcement: disabled` on the ruleset, or delete it
- Does NightOwl want different BLOCK thresholds than smartwatermelon? If yes, that's a fork of `claude-blocking-review.yml` (or a new `extra_instructions` knob) — not a Ruleset decision. Note for follow-up.
- Should the ruleset target `~DEFAULT_BRANCH` only (default), or also include long-lived release branches if NightOwl uses them? Decide once branch conventions stabilize.
- Worth a parallel conversation: are there other rules NightOwl wants org-wide (e.g. linear history, signed commits, required reviewers)? Bundling them into one ruleset is cheaper than discovering them piecemeal. Out of scope for this plan, but flag it during Phase 0.

## Postscript — 2026-04-30 rollout attempt: PAUSED

A first pass at this rollout was attempted on 2026-04-30 and **paused after multiple failure modes that the plan above did not anticipate**. The fleet ended the day in its starting position (per-repo `claude-blocking-review.yml` callers on every active NightOwl repo); the org ruleset (id `15802253` on `nightowlstudiollc`) is currently **disabled**. **Do not re-enable the ruleset or re-attempt scope expansion** until the open questions below are answered empirically.

Empirical findings, all of which break assumptions in the plan above:

1. **`enforcement: "evaluate"` is Enterprise-only.** On Team plan the API silently accepts it and the ruleset enforces as `active` from the moment it's created. Phase 3's "audit week" was production from PR #1.
2. **A cleanup PR that DELETES the per-repo workflow file the PR is gated by is unmergeable.** For same-repo PRs GitHub uses workflows from the head; the head removes the file → workflow doesn't fire → required check `claude-review / run-review` never appears. The plan's Phase 5 "Per-repo `claude-code-review.yml` files in NightOwl repos are deletable" assumed admin override would handle the gap; it doesn't (see point 3).
3. **`gh pr merge --admin` bypasses branch protection but NOT rulesets.** Once the ruleset's `workflows` rule is unsatisfied, even admin cannot override without explicit `bypass_actors` configured on the ruleset itself.
4. **Expanding ruleset scope to `~ALL` does NOT retroactively trigger the required workflow on existing open PRs in newly-included repos.** `gh pr close && gh pr reopen` does not trigger it either. Apparently only an actual push to the PR head does. PRs in the new scope sit forever waiting for a check that never fires.
5. **Empirically, when the workflow does fire, the resulting check name is `claude-review / run-review`** (job-name / reusable-job-name), not `Claude Required Review` (the workflow file's `name:`). The ruleset still considers itself satisfied, but the failure mode when the workflow doesn't fire at all looks identical to a name mismatch.

Operational artifacts from the attempt are committed to this repo as `nightowl-ruleset-setup.sh`, `nightowl-restore-blocking-review.sh`, and `nightowl-ruleset-rollout.sh.broken`. The rollout artifact uses the `.broken` suffix (not `.sh`) so it can't be accidentally executed and so the shell-lint hooks ignore it — its step 1 unconditionally sets `enforcement="active"`, which re-armed the intentionally-disabled ruleset on every `--apply` re-run. Fix that bug (make state assertions conditional on current state) and rename back to `.sh` before any reuse.

Open questions to resolve on a single test PR before any future re-enable attempt:

- Does the org-level workflow (`nightowlstudiollc/.github/.github/workflows/claude-required-review.yml`) actually fire when a fresh PR is **pushed** in a ruleset-scoped repo with no per-repo caller present? Watch the run in `nightowlstudiollc/.github`'s Actions tab and the PR's check_runs. (The plan above takes "scope expansion → workflow fires on every PR" as axiomatic.)
- What event types cause the ruleset's required workflow to (re-)trigger on an existing PR? `synchronize` (push) presumably; `reopened` apparently does not.
- If the rollout will eventually require removing per-repo files, what sequence avoids both the chicken-and-egg AND the "scope-expansion-doesn't-fire-on-existing-PRs" gap simultaneously? The original plan ordered "remove per-repo files first, then expand scope" — that ordering is unworkable with both gotchas active.

Once those are answered, the plan above can be revised with a corrected Phase 4 sequence (or scrapped in favor of indefinite per-repo file maintenance).
203 changes: 203 additions & 0 deletions nightowl-restore-blocking-review.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
#!/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:
uses: smartwatermelon/github-workflows/.github/workflows/claude-blocking-review.yml@v3.0.0
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.
mapfile -t IGNORED < <(grep -v '^#' "$IGNORE_FILE" 2>/dev/null | grep -v '^$' || true)
is_ignored() {
local repo="$1"
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
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
git -C "$clone" commit --no-verify --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."
Loading
Loading