Skip to content

Evaluate switching from branch protection rules to branch rulesets #2751

Description

@GaryJones

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

  1. Review the two disabled rulesets in the settings UI.
  2. Set both to Active (develop + main (shared protection) and main - restrict updates).
  3. 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.)
  4. Delete the classic rule(s) with the commands above.
  5. 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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions