Summary
GitHub is steering repositories away from classic branch protection rules towards branch rulesets (layering, per-ruleset bypass lists, enforcement statuses, org/enterprise rules). This issue evaluates whether we should switch this repository across.
Two disabled rulesets have been created so we can inspect them in the UI without any enforcement taking effect:
Current state: two classic branch protection rules
| Setting |
develop |
main |
| Require a pull request before merging |
✅ (1 approval) |
✅ (1 approval) |
| Dismiss stale approvals |
off |
off |
| Require review from Code Owners |
off |
off |
| Require approval of most recent push |
off |
off |
| Require conversation resolution |
off |
off |
| Require status checks (same 29 checks) |
✅ |
✅ |
Require branches up to date before merge (strict) |
off |
off |
| Require signed commits |
off |
off |
| Require linear history |
off |
off |
| Allow force pushes |
blocked |
blocked |
| Allow deletions |
blocked |
blocked |
| Lock branch |
off |
off |
| Do not allow bypassing (enforce admins) |
✅ |
✅ |
| Restrict who can push |
off |
✅ → only @jrfnl |
The two classic rules are identical apart from the last row.
Classic vs rulesets: how each setting maps
Everything maps cleanly:
| Classic setting |
Ruleset equivalent |
| Require a pull request (1 approval, nothing else) |
pull_request rule, required_approving_review_count: 1, all other sub-options false |
| Require status checks (non-strict) |
required_status_checks rule, strict_required_status_checks_policy: false, the 29 check contexts |
| Block force pushes |
non_fast_forward rule |
| Block deletions |
deletion rule |
| Enforce admins / no bypass |
empty bypass_actors list |
| Signed commits / linear history / conversation resolution (all off) |
no rule added |
Restrict who can push (main → @jrfnl) |
separate update-rule ruleset with @jrfnl as bypass (see below) |
⚠️ Note on bypass_actors: classic "do not allow bypassing" maps to an empty bypass list. GitHub's auto-migration tool sometimes pre-populates admins into the bypass list, which would weaken protection versus today. The shared ruleset deliberately keeps it empty.
Two ruleset-only concepts have no classic counterpart and were left at permissive defaults: allowed_merge_methods (all three allowed) and Copilot auto-review (omitted).
Structuring the rulesets: one shared set, differences layered on top
Because the two classic rules are identical except for main's push lock, we can collapse the common part into a single shared ruleset targeting both branches, and express each difference as its own small, targeted ruleset:
develop + main (shared protection) — PR + 29 status checks + no-force-push + no-delete, targeting both refs/heads/develop and refs/heads/main, empty bypass.
main - restrict updates (jrfnl bypass) — a single update ("Restrict updates") rule targeting only main, with @jrfnl as the bypass actor.
Benefits:
- One touch point for the required status-check list (updated in one place instead of two as PHP/PHPCS versions come and go).
- Differences are explicit and isolated — the
main-only push lock lives in its own ruleset rather than being an invisible checkbox difference between two near-identical rules. Any future divergence (e.g. a rule we want on one branch only) is added the same way.
One thing to bear in mind with this model: rulesets are additive — the effective policy is the union of all applicable rulesets (most-restrictive-wins), and a ruleset can only add restrictions, never relax one imposed elsewhere. So the shared ruleset should hold the least-strict common baseline, with per-branch rulesets layering extra strictness on top. That fits today (main is the stricter branch). If a branch ever needed to be looser than the shared baseline on some axis, that rule would have to be pulled out of the shared set rather than "subtracted" per branch.
Reproducing main's push lock (@jrfnl only)
Classic "Restrict who can push" has no single-rule equivalent, but it can be reproduced exactly, because bypass lists are per-ruleset:
- The shared ruleset applies to everyone, including
@jrfnl (empty bypass).
- The
update-rule ruleset exempts only @jrfnl, so only she can update the main ref.
@jrfnl is therefore still bound by the shared PR + status-check requirements (she must open a PR, get an approval, pass the 29 checks) but is the only person permitted to perform the merge. This matches today's behaviour exactly:
Everyone can open a PR against main; only @jrfnl can merge it; @jrfnl is still bound by the PR + status-check requirements.
This has been verified empirically (on a scratch repo, not this one). With an active update-only ruleset and an empty bypass list, a user not in the bypass list was blocked from both a direct push and a PR merge (Cannot update this protected ref). After adding that user to the bypass list, the same PR merged successfully. So the update rule governs PR merges, not just direct pushes — which is what this design depends on.
⚠️ Rulesets stack on top of classic rules
Enabling a ruleset does not remove the classic rule. Both are evaluated and the most restrictive wins. A true switch is therefore two steps per branch: enable the ruleset, confirm, then delete the classic rule. Until the classic rules are deleted we'd be running both, and won't have actually tested that the rulesets alone suffice.
Classic rules are deleted per-branch (the numeric IDs 69591/69592 in the settings URLs are UI-only; the REST API deletes by branch name and removes all classic protection on that branch):
# Delete classic protection on develop (rule 69592)
gh api --method DELETE repos/WordPress/WordPress-Coding-Standards/branches/develop/protection
# Delete classic protection on main (rule 69591)
gh api --method DELETE repos/WordPress/WordPress-Coding-Standards/branches/main/protection
Proposed sequence
- Review the two disabled rulesets in the settings UI.
- Set both to Active (
develop + main (shared protection) and main - restrict updates).
- Smoke-test on a real PR: confirm checks are required, a non-
@jrfnl merge into main is blocked, and develop behaves as before. (The underlying update-rule behaviour is already verified; this step just confirms the config is wired to the right branches and checks.)
- Delete the classic rule(s) with the commands above.
- Optionally note the two-ruleset arrangement in the contributing/maintenance docs, since the
main lock now lives in a separate ruleset's bypass list rather than a single "restrict pushes" checkbox.
/cc @jrfnl — worth confirming the main → @jrfnl-only lock is still wanted before we make it active.
Summary
GitHub is steering repositories away from classic branch protection rules towards branch rulesets (layering, per-ruleset bypass lists, enforcement statuses, org/enterprise rules). This issue evaluates whether we should switch this repository across.
Two disabled rulesets have been created so we can inspect them in the UI without any enforcement taking effect:
develop + main (shared protection)— the rules both branches sharemain - restrict updates (jrfnl bypass)— themain-only push lockCurrent state: two classic branch protection rules
developmainstrict)@jrfnlThe two classic rules are identical apart from the last row.
Classic vs rulesets: how each setting maps
Everything maps cleanly:
pull_requestrule,required_approving_review_count: 1, all other sub-optionsfalserequired_status_checksrule,strict_required_status_checks_policy: false, the 29 check contextsnon_fast_forwardruledeletionrulebypass_actorslistmain→@jrfnl)update-rule ruleset with@jrfnlas bypass (see below)Two ruleset-only concepts have no classic counterpart and were left at permissive defaults:
allowed_merge_methods(all three allowed) and Copilot auto-review (omitted).Structuring the rulesets: one shared set, differences layered on top
Because the two classic rules are identical except for
main's push lock, we can collapse the common part into a single shared ruleset targeting both branches, and express each difference as its own small, targeted ruleset:develop + main (shared protection)— PR + 29 status checks + no-force-push + no-delete, targeting bothrefs/heads/developandrefs/heads/main, empty bypass.main - restrict updates (jrfnl bypass)— a singleupdate("Restrict updates") rule targeting onlymain, with@jrfnlas the bypass actor.Benefits:
main-only push lock lives in its own ruleset rather than being an invisible checkbox difference between two near-identical rules. Any future divergence (e.g. a rule we want on one branch only) is added the same way.One thing to bear in mind with this model: rulesets are additive — the effective policy is the union of all applicable rulesets (most-restrictive-wins), and a ruleset can only add restrictions, never relax one imposed elsewhere. So the shared ruleset should hold the least-strict common baseline, with per-branch rulesets layering extra strictness on top. That fits today (
mainis the stricter branch). If a branch ever needed to be looser than the shared baseline on some axis, that rule would have to be pulled out of the shared set rather than "subtracted" per branch.Reproducing
main's push lock (@jrfnlonly)Classic "Restrict who can push" has no single-rule equivalent, but it can be reproduced exactly, because bypass lists are per-ruleset:
@jrfnl(empty bypass).update-rule ruleset exempts only@jrfnl, so only she can update themainref.@jrfnlis therefore still bound by the shared PR + status-check requirements (she must open a PR, get an approval, pass the 29 checks) but is the only person permitted to perform the merge. This matches today's behaviour exactly:This has been verified empirically (on a scratch repo, not this one). With an active
update-only ruleset and an empty bypass list, a user not in the bypass list was blocked from both a direct push and a PR merge (Cannot update this protected ref). After adding that user to the bypass list, the same PR merged successfully. So theupdaterule governs PR merges, not just direct pushes — which is what this design depends on.Enabling a ruleset does not remove the classic rule. Both are evaluated and the most restrictive wins. A true switch is therefore two steps per branch: enable the ruleset, confirm, then delete the classic rule. Until the classic rules are deleted we'd be running both, and won't have actually tested that the rulesets alone suffice.
Classic rules are deleted per-branch (the numeric IDs
69591/69592in the settings URLs are UI-only; the REST API deletes by branch name and removes all classic protection on that branch):Proposed sequence
develop + main (shared protection)andmain - restrict updates).@jrfnlmerge intomainis blocked, anddevelopbehaves as before. (The underlyingupdate-rule behaviour is already verified; this step just confirms the config is wired to the right branches and checks.)mainlock now lives in a separate ruleset's bypass list rather than a single "restrict pushes" checkbox./cc @jrfnl — worth confirming the
main→@jrfnl-only lock is still wanted before we make it active.