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
Configurations found across eighty repositoriesA policy that everyone believes is uniform turns out to have a dozen variants, and a meaningful number of repositories have no protection at all β€” usually the ones created most recently, by whoever did not know the checklist existed.repositories by protection configurationmatches the intended policy31missing signed commits22review count lowered14no protection at all13the red bar is why a ruleset applied by pattern beats a checklist

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
Rolling out a ruleset without breaking MondayMeasure the existing variation, write the policy in evaluate mode, watch a fortnight of reported failures, fix the legitimate ones by changing workflows rather than the rule, and only then switch to active enforcement.Measurewhat exists nowEvaluate modereports, blocks nothingFix the causesworkflows, not the ruleActiveonce failures are rarethe third box is where a rollout either succeeds or acquires its first permanent exception

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
Per-repository settings against an organisation rulesetPer-repository protection must be applied to each new repository and drifts as people adjust it. A ruleset applies by pattern, covers repositories created after it was written, and shows its exceptions in one place.Per repositoryOrganisation rulesetnew repositories coveredonly if rememberedautomaticallydrift possibleyes, silentlyonly via exclusionsexceptions visiblescatteredone listreview efforteighty placesonethe first row is the one that decides whether the policy is real

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.