Skip to main content
ClaudeWave
Skill6 repo starsupdated today

branch-protection

This skill manages GitHub branch protection for the `main` branch by syncing a checked-in JSON specification with GitHub's ruleset system, preventing accidental force-pushes, deletions, and unreviewed merges while allowing repository admins to bypass rules when necessary. Use it when you need reproducible, code-reviewed control over branch protection rules that can be audited, restored, and applied consistently without manual GitHub UI edits.

Install in Claude Code
Copy
git clone --depth 1 https://github.com/piprail/piprail /tmp/branch-protection && cp -r /tmp/branch-protection/.claude/skills/branch-protection ~/.claude/skills/branch-protection
Then start a new Claude Code session; the skill loads automatically.

SKILL.md

# Branch protection — protect `main`, as code

**The single source of truth for how `main` is locked down, and the only safe way to change it.**
The live config is a GitHub **ruleset** (the modern replacement for classic branch protection).
This skill mirrors it into a checked-in JSON spec so the protection is reviewable, reproducible,
and restorable — and every command here is **back-up-first, verify-after**.

> **Golden rule: never click-edit the ruleset in the GitHub UI for a real change.** Edit
> [`protect-main.json`](protect-main.json), run **Apply**, let it verify. The UI is for one-off
> inspection only; the JSON is the truth.

---

## 0. Mental model — what this does and does NOT defend against

Read this before touching anything; it stops you from "fixing" a non-problem.

- **A fork cannot touch your `main`.** A fork is a separate copy under someone else's account.
  An **issue** is a comment thread. Neither changes a byte of your repo. The only way outside
  code lands is **you merging their pull request** by hand. GitHub's "unprotected branch" nudge
  is generic best-practice, not an alarm about a specific person.
- **Outsiders already can't push or merge** — that requires collaborator write access, which
  no one but the owner has. Branch protection does **not** grant "only I can merge"; that's
  already true. What it adds is discipline against the *real* risks:
  1. **You** force-pushing or committing straight to `main` (history-corruption footgun — see
     [[git-shallow-clone-amend-danger]]).
  2. A **future collaborator** doing the same.
  3. `main` being **deleted**.
  4. Code merging with **red CI** (only if you opt into required status checks — see §6 caveat).

So the protection here is mostly "protect the maintainer from their own accidents," and that is
exactly what a solo-maintainer OSS repo wants.

---

## 1. The facts (PipRail, current)

| Thing | Value |
|---|---|
| Repo | `piprail/piprail` (org-owned, public) |
| Ruleset name | `protect-main` |
| Ruleset id | `17756558` |
| Target | `~DEFAULT_BRANCH` (follows `main` even if renamed) |
| Enforcement | `active` |
| Rules | `deletion` · `non_fast_forward` (block force-push) · `pull_request` (0 approvals, all merge methods) · `required_signatures` |
| Bypass | **Repository admin role** (`actor_id 5`, `RepositoryRole`), mode `always` — this is what keeps you from locking yourself out |
| Classic branch protection | **none** (intentional — the ruleset is the only source of truth; don't add classic on top, they stack confusingly) |
| `gh` account needed | admin on `piprail/piprail` (currently `John-Weeks-Dev`) |

If the ruleset id ever changes (deleted + recreated), rediscover it:

```bash
gh api repos/piprail/piprail/rulesets --jq '.[] | select(.name=="protect-main") | .id'
```

---

## 2. Read-only — always safe, run these freely

### Status (human-readable)

```bash
gh api repos/piprail/piprail/rulesets/17756558 --jq '"name: \(.name)
enforcement: \(.enforcement)
targets: \(.conditions.ref_name.include)
rules: \([.rules[].type]|join(", "))
bypass: \([.bypass_actors[]|"role#\(.actor_id) (\(.bypass_mode))"]|join(", "))
can I bypass?: \(.current_user_can_bypass)"'
```

### Audit — does live match the committed spec? (drift detection)

```bash
SPEC=.claude/skills/branch-protection/protect-main.json
norm() { jq -S '{name,target,enforcement,conditions:.conditions,rules:(.rules|sort_by(.type)),bypass:(.bypass_actors|sort_by(.actor_id))}'; }
diff -u <(norm < "$SPEC") <(gh api repos/piprail/piprail/rulesets/17756558 | norm) \
  && echo "✓ IN SYNC — live protection matches the committed spec." \
  || echo "⚠ DRIFT — live differs from the spec (review the diff above)."
```

A clean `✓ IN SYNC` is the green light. Any diff means someone changed the ruleset in the UI
(or the spec was edited but not applied) — reconcile before doing anything else.

### Backup — snapshot the live ruleset to a timestamped file

```bash
mkdir -p .claude/skills/branch-protection/backups
gh api repos/piprail/piprail/rulesets/17756558 \
  > ".claude/skills/branch-protection/backups/protect-main.$(date +%Y%m%d-%H%M%S).json"
```

> `backups/` is for local safety snapshots — keep it gitignored or prune it; it is not the
> source of truth (`protect-main.json` is).

---

## 3. Apply — the ONLY safe way to change protection

Edit [`protect-main.json`](protect-main.json) first, then run this. It **(a)** refuses to proceed
unless the spec still keeps your admin bypass and active enforcement, **(b)** backs up the live
state, **(c)** applies, **(d)** verifies the result matches the spec, and **(e)** auto-restores
from the backup if anything diverges. This is proven idempotent — re-applying the current spec
changes nothing.

```bash
set -euo pipefail
RS=17756558; REPO=piprail/piprail
SPEC=.claude/skills/branch-protection/protect-main.json

# (a) SAFETY GUARDS — abort rather than risk a lockout / silent disable
jq -e '.bypass_actors[]? | select(.actor_id==5 and .actor_type=="RepositoryRole")' "$SPEC" >/dev/null \
  || { echo "✗ ABORT: spec drops the admin bypass — you could lock yourself out. Add it back."; exit 1; }
[ "$(jq -r .enforcement "$SPEC")" = "active" ] \
  || { echo "✗ ABORT: spec enforcement is not 'active' — protection would be off. Intentional? Edit by hand."; exit 1; }

# (b) backup
mkdir -p .claude/skills/branch-protection/backups
BK=".claude/skills/branch-protection/backups/protect-main.$(date +%Y%m%d-%H%M%S).json"
gh api "repos/$REPO/rulesets/$RS" > "$BK"; echo "✓ backed up → $BK"

# (c) apply
gh api --method PUT "repos/$REPO/rulesets/$RS" --input "$SPEC" >/dev/null && echo "✓ applied"

# (d) verify
norm() { jq -S '{name,target,enforcement,conditions:.conditions,rules:(.rules|sort_by(.type)),bypass:(.bypass_actors|sort_by(.actor_id))}'; }
if diff -u <(norm < "$SPEC") <(gh api "repos/$REPO/rulesets/$RS" | norm); then
  echo "✓✓✓ VERIFIED — live now matches the spec."
else
  # (e) auto-restore
  echo "‼️ MISMATCH — restoring from backup…"
  gh api