Designing branch protection rulesets for an org Jump to heading
Branch protection configured repository by repository is a policy that exists in eighty places and agrees in none of them. Someone creates a repository and forgets; someone relaxes a rule for a migration and never restores it; someone copies settings from a repository that was already wrong. An organisation ruleset expresses the policy once, applies it by pattern, and makes drift impossible for the repositories it covers. This recipe designs one and rolls it out without breaking anybodyβs Monday, within repository access control and policy.
When to use this approach Jump to heading
- More than a handful of repositories should follow the same rules.
- New repositories are created without protection and nobody notices.
- Settings differ between repositories for no recorded reason.
- An audit asked which repositories enforce signed commits.
- If you have three repositories and one team, per-repository settings are fine and simpler.
Step 1 β Measure the drift you already have Jump to heading
The variation is the argument for doing this, and it is worth quantifying.
# Protection settings across the organisation
gh repo list acme --limit 200 --json name --jq '.[].name' | while read -r r; do
p=$(gh api "repos/acme/$r/branches/main/protection" 2>/dev/null \
| jq -r '[(.required_pull_request_reviews.required_approving_review_count // 0),
(.required_signatures.enabled // false),
(.allow_force_pushes.enabled // false)] | @tsv' 2>/dev/null)
printf '%-40s %s\n' "$r" "${p:-NO PROTECTION}"
done | sort -k2 # Summarised: how many distinct configurations exist?
# (pipe the above into sort -k2 | uniq -c -f1) # Verification: the count of unprotected repositories is the headline number
gh repo list acme --limit 200 --json name --jq '.[].name' | while read -r r; do
gh api "repos/acme/$r/branches/main/protection" >/dev/null 2>&1 || echo "$r"
done | wc -l Step 2 β Write the policy once Jump to heading
{
"name": "org-default-branch",
"target": "branch",
"enforcement": "evaluate",
"conditions": {
"ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] },
"repository_name": { "include": ["~ALL"], "exclude": ["acme/sandbox-*"] }
},
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{ "type": "required_signatures" },
{ "type": "pull_request",
"parameters": {
"required_approving_review_count": 1,
"dismiss_stale_reviews_on_push": true,
"require_code_owner_review": true,
"required_review_thread_resolution": true
}
}
],
"bypass_actors": []
} gh api -X POST orgs/acme/rulesets --input ruleset.json --jq '.id' # Verification: the ruleset exists and is in evaluate mode
gh api orgs/acme/rulesets --jq '.[] | "\(.name)\t\(.enforcement)"' enforcement: evaluate is what makes the rollout safe: the rules are checked and reported, and nothing is blocked. Starting in active mode across eighty repositories is how a rollout becomes an incident.
Step 3 β Watch what evaluate mode reports Jump to heading
# Which pushes would have been refused?
gh api orgs/acme/rulesets/$ID/history --jq '.[] | "\(.created_at) \(.actor.login)"' | head # Per repository, over a fortnight
gh api "orgs/acme/rulesets/rule-suites?time_period=week&rule_suite_result=fail" \
--jq '.[] | "\(.repository_name)\t\(.actor_name)\t\(.result)"' | sort | uniq -c | sort -rn | head # Verification: the failures should be explicable, not surprising Step 4 β Handle the legitimate exceptions explicitly Jump to heading
Some repositories genuinely cannot follow the policy. Say which, and why.
{
"conditions": {
"repository_name": {
"include": ["~ALL"],
"exclude": ["acme/sandbox-*", "acme/legacy-vendor-mirror"]
}
}
} # Record why each exclusion exists, where it will be reviewed
cat >> access-exceptions.md <<'DOC'
## acme/legacy-vendor-mirror excluded from org-default-branch
Reason: Mirrors an upstream repository; commits are not ours to sign.
Expires: reviewed annually; remove if the mirror is retired.
Owner: platform team.
DOC # Verification: every exclusion pattern matches something that still exists
gh api orgs/acme/rulesets/$ID --jq '.conditions.repository_name.exclude[]' | while read -r p; do
gh repo list acme --limit 200 --json name --jq '.[].name' | grep -q "${p#acme/}" \
|| echo "DEAD EXCLUSION: $p"
done SAFETY WARNING β an exclusion pattern with a wildcard is a permanent hole that grows:
acme/sandbox-*means anyone who names a repository with that prefix is outside the policy, including by accident. Prefer naming repositories explicitly, review the list on a schedule, and treat a request to add a wildcard as a request to weaken the policy organisation-wide.
Step 5 β Switch to active, and prove the coverage Jump to heading
gh api -X PUT "orgs/acme/rulesets/$ID" --input <(jq '.enforcement = "active"' ruleset.json) # Which repositories does it actually apply to?
gh repo list acme --limit 200 --json name --jq '.[].name' | while read -r r; do
n=$(gh api "repos/acme/$r/rules/branches/main" --jq 'length' 2>/dev/null || echo 0)
[ "$n" -eq 0 ] && echo "NOT COVERED: $r"
done # Verification: an unsigned push to a covered repository is refused
git commit --allow-empty --no-gpg-sign -m probe && git push origin HEAD:main 2>&1 | tail -2
git reset --hard HEAD~1 The coverage check in Step 5 is worth keeping as a scheduled job: a ruleset that silently stops matching a repository β because it was renamed, transferred or moved into an excluded pattern β is exactly the failure this whole exercise was meant to prevent.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
What happens to existing per-repository protection? Jump to heading
It continues to apply, and the rules combine β the strictest of the applicable rules wins. That makes the transition safe and means the old settings need cleaning up afterwards, or you will have two sources of truth and a confusing answer the next time someone asks why a push was refused.
Can a repository owner opt out? Jump to heading
Not of an organisation ruleset, which is the point. What they can do is request an exclusion, which goes through whoever owns the policy and lands in the documented list. Making that the only route is what keeps the exception list reviewable.
How do we handle repositories with a non-standard default branch? Jump to heading
~DEFAULT_BRANCH matches whatever each repositoryβs default is, which handles the variation automatically. Naming main explicitly would silently miss any repository still on a different name β a common and quiet gap when rolling out to an older organisation.
Related Jump to heading
- Repository Access Control & Policy β the parent topic and the four routes to a branch.
- Auditing Who Can Push to Protected Branches β checking that the ruleset is what it appears to be.
- Enforcing Signed Commits With Branch Protection β the rule that depends most on this being right.