GitHub Branch Policy
Overview
Run a repeatable audit for GitHub branch policy safety and Actions workflow hygiene. Validate ruleset enforcement, required checks, workflow registration integrity, and branch cleanup behavior that commonly break CI and auto-merge.
This skill explicitly checks for real-world failure modes where:
- auto-merge is enabled and checks are green, but PRs remain
BEHIND/BLOCKED, and - auto-merge enablement fails transiently (for example GitHub API 502), leaving PRs stranded.
Use This Skill When
Apply this skill for requests like:
- "Audit branch protection/rulesets on this repo."
- "Check whether auto-merge and branch cleanup are configured correctly."
- "Find ghost workflows or stale branches causing Actions failures."
- "Why are we getting
Cannot update this protected ref?" - "Why is auto-merge enabled but nothing merges?"
- "Make sure branch policy matches solo-developer expectations."
Prerequisites
ghauthenticated for the target repo (repo+workflowscopes).jqavailable.- Target repository known as
OWNER/REPO, or current directory is a checked-out repo with a GitHub remote.
Quick Setup
OWNER_REPO="${OWNER_REPO:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}"
OWNER="${OWNER_REPO%/*}"
REPO="${OWNER_REPO#*/}"
DEFAULT_BRANCH="$(gh repo view "$OWNER_REPO" --json defaultBranchRef -q .defaultBranchRef.name)"
echo "Auditing $OWNER_REPO (default: $DEFAULT_BRANCH)"
Known Good Baseline (Solo-Maintainer Friendly)
- Repository has
allow_auto_merge: true. - Active default-branch ruleset includes
pull_requestandrequired_status_checks. - Required status check contexts exactly match live check names.
- Default-branch ruleset has no bypass actors (
bypass_actors: []). - Required status check contexts are limited to merge-critical gates; non-critical provider checks (for example
Vercel Preview Comments) may not be present and may unintentionally break auto-merge. updaterule is optional:- If enabled, bypass actors must be intentionally configured.
- If not needed for your workflow, remove it to prevent unnecessary
BLOCKEDstates.
- Branch updater workflow is not
push-only (has at least one fallback trigger such aspull_request_target,schedule, orworkflow_dispatch). - Branch updater verifies required CI checks are present on the latest PR head SHA after any update-branch operation.
- Auto-merge workflow tolerates transient API failures (retry/backoff).
delete_branch_on_merge: true(or equivalent cleanup automation).
Audit Checklist
1. Repository merge settings are compatible with policy
Verification:
gh api "repos/$OWNER/$REPO" \
--jq '{allow_auto_merge,allow_squash_merge,allow_merge_commit,allow_rebase_merge,delete_branch_on_merge,default_branch}'
Pass criteria:
allow_auto_merge: truewhen auto-merge is expected.- Merge methods match branch rules (for example, squash-only policy means squash is enabled).
delete_branch_on_merge: trueunless intentionally disabled.
Remediation:
- Enable auto-merge at repo level.
- Align repo merge methods with ruleset
allowed_merge_methods. - Enable delete-on-merge, or document why not.
2. Active ruleset applies to default branch and enforces PR + required checks
Verification:
gh api "repos/$OWNER/$REPO/rulesets" \
--jq '.[] | {id,name,enforcement,target,include:(.conditions.ref_name.include // []),rules:[.rules[].type]}'
gh api "repos/$OWNER/$REPO/rulesets" \
--jq '.[] | select(.enforcement=="active") | .rules[] | select(.type=="pull_request" or .type=="required_status_checks" or .type=="update")'
Pass criteria:
- At least one active branch ruleset applies to
~DEFAULT_BRANCH(or equivalent explicit default branch include). - Ruleset includes
pull_requestandrequired_status_checks. updatemay be present or absent, but must be intentional.
Remediation:
- Enable or create a default-branch ruleset.
- Add missing
pull_requestandrequired_status_checksrules. - Remove accidental
updateif it is not part of your branch update strategy.
3. Required check contexts match real check names
Verification:
gh api "repos/$OWNER/$REPO/rulesets" \
--jq '.[] | .rules[] | select(.type=="required_status_checks") | .parameters.required_status_checks[].context'
gh pr list --state all --limit 20 --json number \
--jq '.[0].number' | xargs -I{} gh pr view {} --json statusCheckRollup
Pass criteria:
- Required contexts exactly match real checks reported on PRs (case-sensitive), for example
ci,Vercel,Vercel Preview Comments.
Remediation:
- Update required check contexts in the ruleset to match actual check names.
4. update rule and bypass actors are compatible with your auto-merge flow
Verification:
gh api "repos/$OWNER/$REPO/rulesets" \
--jq '.[] | select(.enforcement=="active" and .target=="branch") |
{id,name,has_update:([.rules[].type] | index("update") != null),
bypass_actors:((.bypass_actors // []) | map({actor_type,actor_id,bypass_mode}))}'
Pass criteria:
- If
has_updateisfalse, this is acceptable when no branch-update automation is required. - If
has_updateistrue, bypass actors and modes are intentionally set so trusted maintainers/automation can still complete merges.
Remediation:
- For solo-maintainer repos, prefer removing unneeded
updaterequirements. - Remove bypass actors from the default-branch ruleset.
- If emergency bypass is temporarily required, keep scope minimal, time-box it, document owner approval, and remove it immediately after incident resolution.
- Re-test with a smoke PR after policy changes.
5. Solo-dev compatibility: CODEOWNERS review is not forced unless intentional
Verification:
gh api "repos/$OWNER/$REPO/rulesets" \
--jq '.[] | {name, pull_request_rules:[.rules[] | select(.type=="pull_request") | .parameters.require_code_owner_review]}'
Pass criteria:
- For solo-maintainer repos,
require_code_owner_reviewisfalseunless intentionally required.
Remediation:
- Set
require_code_owner_review: falsewhere solo-dev flow is desired.
6. Actions policy allows the workflow dependencies you actually use
Verification:
gh api "repos/$OWNER/$REPO/actions/permissions" \
--jq '{enabled,allowed_actions,sha_pinning_required}'
gh api "repos/$OWNER/$REPO/actions/permissions/selected-actions" \
--jq '{github_owned_allowed,verified_allowed,patterns_allowed}'
Pass criteria:
- If
allowed_actions: selected, all actions used by workflows are explicitly allowed (or covered by allowed classes). - If SHA pinning is required, actions are pinned.
Remediation:
- Add missing action patterns to selected-actions policy.
- Pin unpinned actions.
- Prefer minimal/no third-party actions for sensitive auto-merge workflows.
7. Auto-merge workflow registration is clean (no ghost duplicates)
Verification:
gh api "repos/$OWNER/$REPO/actions/workflows" \
--jq '.workflows[] | [.id,.name,.path,.state] | @tsv' | sort
gh workflow list --all
Pass criteria:
- Exactly one active registration for the canonical auto-merge workflow path.
- Legacy/stale registrations are disabled or removed.
Remediation:
- Disable stale workflow IDs:
gh workflow disable <workflow_id>
- Keep a single canonical filename on default branch.
8. Auto-merge workflow trigger and runtime behavior are reliable
Verification:
AUTO_WF="Enable PR Auto-Merge" # adjust if needed
gh workflow view "$AUTO_WF" --yaml | sed -n '1,260p'
gh run list --workflow "$AUTO_WF" --limit 20 \
--json databaseId,event,status,conclusion,headBranch,createdAt,url
Deep check for suspicious runs:
RUN_ID="<id>"
gh run view "$RUN_ID" --json event,conclusion,jobs
# Log text is critical for transient API failures (502/503/etc.)
gh run view "$RUN_ID" --log-failed | sed -n '1,260p'
Pass criteria:
- Trigger is intentionally chosen (
pull_request_targetis often safer for base-branch-controlled orchestration;pull_requestis valid when branch consistency is guaranteed). - Recent PR-event runs execute real jobs.
- Workflow handles transient GitHub failures (retry/backoff or equivalent).
- No repeating failures with
pushevent + zero jobs + missing logs (ghost workflow symptom).
Remediation:
- Move to a stable default-branch workflow file.
- Use
pull_request_targetwhen orchestration must run from trusted base branch context. - Add retry/backoff around
gh pr merge --auto ...to avoid one-off 5xx failures stranding PRs. - Disable stale workflow registrations.
9. Branch updater trigger coverage and run recency are reliable
Verification:
UPDATE_WF="Auto Update PR Branches"
gh workflow view "$UPDATE_WF" --yaml | sed -n '1,320p'
gh run list --workflow "$UPDATE_WF" --limit 30 \
--json databaseId,event,status,conclusion,headBranch,headSha,createdAt,url
Correlate recent default-branch commits with updater runs:
git fetch origin "$DEFAULT_BRANCH"
git log --oneline -n 15 "origin/$DEFAULT_BRANCH"
# Check whether updater has runs for recent headSha values
Pass criteria:
- Updater is not
push-only in environments where merges are performed by automation/apps. - At least one fallback trigger exists (
pull_request_target,schedule, orworkflow_dispatch). - Updater runs continue to appear after recent merges to default branch.
Remediation:
- Add fallback triggers to updater workflow (
pull_request_target,schedule,workflow_dispatch). - Add
concurrencyguard to avoid overlapping updater runs. - Keep updater green when one PR cannot update, so other PRs still progress.
10. PR-level auto-merge state is actually mergeable (not just enabled)
Verification:
gh pr list --state open --limit 30 \
--json number,title,isDraft,mergeStateStatus,autoMergeRequest \
--jq '.[] | {number,title,isDraft,mergeStateStatus,autoMergeEnabled:(.autoMergeRequest != null)}'
Deep check:
PR_NUMBER="<pr_number>"
gh pr view "$PR_NUMBER" \
--json autoMergeRequest,mergeStateStatus,isDraft,reviewDecision,statusCheckRollup
Pass criteria:
- PRs intended to auto-merge show
autoMergeEnabled: true. - Once checks and reviews are satisfied,
mergeStateStatusis no longerBLOCKED/BEHINDfor long periods.
Remediation:
- If PRs stay
BLOCKEDwith green checks, verify ruleset gates first:- required status checks exact name match
updaterule/bypass compatibility- review requirements
- If PRs stay
BEHIND, verify branch updater trigger coverage and run recency. - Adjust ruleset/workflows, then re-test with a throwaway PR.
10a. Required checks are attached to the latest PR head SHA
This catches the incident pattern where a branch updater creates a new PR head commit, but ci only exists on the previous SHA, leaving auto-merge BLOCKED.
Verification:
PR_NUMBER="<pr_number>"
gh pr view "$PR_NUMBER" \
--json headRefName,headRefOid,mergeStateStatus,statusCheckRollup
HEAD_SHA="$(gh pr view "$PR_NUMBER" --json headRefOid -q .headRefOid)"
gh api "repos/$OWNER/$REPO/commits/$HEAD_SHA/check-runs" \
--jq '[.check_runs[] | {name,status,conclusion}]'
Pass criteria:
- Every required status check context appears on the PR's current
headRefOid. - If updater automation changed the head SHA, required checks (especially
ci) are present or queued on that new SHA.
Remediation:
- Immediate unstick:
HEAD_REF="$(gh pr view "$PR_NUMBER" --json headRefName -q .headRefName)"
gh workflow run CI --ref "$HEAD_REF"
- Permanent fix:
- Re-resolve
headRefName+headRefOidafter updater actions. - Verify required checks on that exact SHA.
- Dispatch CI when required checks are missing.
- Keep non-critical checks out of required-status contexts.
- Re-resolve
11. Cannot update this protected ref diagnostics for branch-update workflows
Verification:
gh workflow list --all
gh run list --workflow "Auto Update PR Branches" --limit 20 \
--json databaseId,status,conclusion,event,headBranch,createdAt,url
Pass criteria:
- Workflow updates eligible PR branches successfully.
- Protected or fork branches are skipped/handled without failing the entire run.
Remediation:
- In update-branch loops, continue on protected/fork failures.
- Skip PR heads that are protected or not writable.
- Treat this as per-PR conditional failure, not a global workflow failure.
12. Branch cleanup strategy is in place
Verification:
gh api "repos/$OWNER/$REPO" --jq '{delete_branch_on_merge}'
gh api "repos/$OWNER/$REPO/actions/workflows" \
--jq '.workflows[] | {name,path,state}'
Pass criteria:
delete_branch_on_merge: true, or equivalent cleanup workflow exists and is active.
Remediation:
- Enable delete-on-merge.
- Add/repair cleanup workflow if additional cleanup behavior is required.
13. No stale branches from merged/closed PRs
Verification:
gh api "repos/$OWNER/$REPO/branches" --paginate --jq '.[].name' | sort -u > /tmp/live-branches.txt
gh pr list --state merged --limit 500 --json headRefName --jq '.[].headRefName' | sort -u > /tmp/merged-pr-branches.txt
gh pr list --state closed --limit 500 --json headRefName,mergedAt \
--jq '.[] | select(.mergedAt==null) | .headRefName' | sort -u > /tmp/closed-pr-branches.txt
cat /tmp/merged-pr-branches.txt /tmp/closed-pr-branches.txt | sort -u > /tmp/candidate-stale-branches.txt
comm -12 /tmp/live-branches.txt /tmp/candidate-stale-branches.txt
Pass criteria:
- Intersection output is empty, excluding intentional long-lived branches.
Remediation:
- Delete confirmed stale branches:
git push origin --delete "<branch>"
14. Rulesets are primary policy and legacy protection is not conflicting
Verification:
gh api "repos/$OWNER/$REPO/rulesets" --jq 'length'
gh api "repos/$OWNER/$REPO/branches/$DEFAULT_BRANCH/protection" 2>/dev/null | jq '.'
Pass criteria:
- Rulesets are the primary mechanism.
- Legacy branch protection is absent or intentionally non-overlapping.
- If branch protection endpoint returns 404 while rulesets exist, that is expected.
Remediation:
- Consolidate policy into rulesets.
- Remove redundant legacy branch protection after parity validation.
Optional Smoke Test (Recommended for Auto-Merge Incidents)
- Create a temporary PR from a throwaway branch.
- Enable auto-merge on the PR.
- Confirm auto-merge is enabled:
gh pr view <pr_number> --json autoMergeRequest,mergeStateStatus
- Confirm auto-merge workflow ran and executed jobs:
gh run list --workflow "Enable PR Auto-Merge" --limit 5
- Confirm branch updater can clear
BEHINDwithout manual intervention. - Confirm PR actually merges after checks pass (not just auto-merge enabled).
- Close/delete throwaway branch if still open.
Fast Unstick Playbook (Operational)
Use this only for incident response, then fix root cause in workflow/ruleset config.
# Re-enable auto-merge if missing
gh pr merge <pr_number> --auto --squash --repo "$OWNER_REPO"
# Force update a behind branch
gh api --method PUT "repos/$OWNER/$REPO/pulls/<pr_number>/update-branch" -f update_method=merge
# Ensure required CI exists on current head
HEAD_REF="$(gh pr view <pr_number> --json headRefName -q .headRefName)"
gh workflow run CI --ref "$HEAD_REF"
# Re-check status
gh pr view <pr_number> --repo "$OWNER_REPO" \
--json headRefOid,mergeStateStatus,autoMergeRequest,statusCheckRollup
Tooling Notes (CLI Gotchas)
gh apidoes not support--repo; use full endpoint paths likerepos/$OWNER/$REPO/....- In zsh, quote
gh apiendpoints that include?query strings:gh api 'repos/$OWNER/$REPO/actions/workflows/<id>/runs?per_page=20'
Report Format
## Branch Policy Audit Report
- Repository: OWNER/REPO
- Default branch: <branch>
- Timestamp (UTC): <iso8601>
- Overall status: PASS | NEEDS_ACTION | BLOCKED
### Findings
1. [SEV-<1-3>] <check name> - <pass/fail summary>
Evidence: <key command output summary>
Remediation: <next action>
### Actions Taken
1. <action performed or "none">
### Follow-up
1. <required human decision or "none">
Guardrails
- Do not delete branches until confirmed stale and unprotected.
- Do not disable workflows blindly; verify canonical registration first.
- Default policy is no bypass actors on default-branch rulesets unless there is a documented, time-boxed incident exception.
- Treat
push + 0 jobs + no logson workflow runs as likely ghost/stale registration evidence. - Do not treat
autoMergeRequest != nullas success by itself. VerifymergeStateStatusand actual merge outcome. - Prefer deterministic
gh apievidence over assumptions. - If permissions are insufficient, report missing scope/permission and continue with remaining checks.