A ruleset is repository policy expressed as enforceable rules over selected refs or repository activity.
Rulesets are GitHub’s newer governance mechanism, and the temptation is to read them as “branch protection with a different settings page”. They differ in kind rather than degree, and the differences change how you design policy.
The four differences that matter
Section titled “The four differences that matter”They layer. Multiple rulesets can target the same branch, and all of them apply. The effective policy is the union — the most restrictive combination. Branch protection picks one rule and ignores the rest.
They can be turned off without being deleted. Enforcement status is a property of the ruleset, so a policy can be disabled during a migration and re-enabled afterwards with its configuration intact.
They are visible. Anyone with read access can see the active rulesets on a repository. Branch protection configuration is administrator-only. That visibility matters more than it sounds: a contributor who can read the policy can comply with it, rather than discovering it as a rejected push.
They target more than branches. Tags can be protected, which branch protection cannot do — and on some plans rulesets can constrain repository-level activity as well.
Anatomy
Section titled “Anatomy”A ruleset has four parts.
A name. It appears in rejection messages, so name it for what it enforces —
protect-main-require-review beats Ruleset 1.
A target. Branches or tags, selected by the default branch, by name, or by pattern. Multiple patterns per ruleset are allowed.
An enforcement status. Active means enforced. Disabled means configured and dormant.
A bypass list. Roles, teams or Apps permitted to ignore it.
The rules
Section titled “The rules”Rules fall into three groups.
Ref lifecycle — whether refs matching the target may be created, updated or deleted, and whether force pushes are permitted. This is the branch protection core.
Pull request requirements — required approvals, dismiss stale reviews, require code owner review, require approval of the most recent push, require conversation resolution.
Content and metadata — required status checks, required deployments, required signed commits, required linear history, and — distinctively — commit metadata restrictions.
That last group is what branch protection cannot express. Rulesets can require commit messages,
author email addresses or branch names to match a pattern. Enforcing that every branch is named
feature/* or fix/*, or that commit messages reference an issue key, becomes a platform rule
rather than a convention people forget.
Layering, in practice
Section titled “Layering, in practice”Because rulesets combine, policy can be composed from small pieces rather than one large configuration.
A workable arrangement for a team repository:
| Ruleset | Targets | Enforces |
|---|---|---|
baseline-protection | Default branch | No deletion, no force push |
require-review | Default branch | Pull request, one approval, stale dismissal |
require-ci | Default branch | Status checks: build, test |
protect-release-tags | v* tags | No deletion, no update |
commit-hygiene | All branches | Branch naming pattern |
All five apply where their targets overlap. The value is that each can be reasoned about, disabled,
or adjusted independently — require-ci can be disabled for an afternoon while the pipeline is
migrated, without touching the rules that stop someone deleting main.
Doing the same with branch protection means one rule containing everything, and any temporary change means editing the rule that also protects against deletion.
Bypass
Section titled “Bypass”Bypass is granted per ruleset, to specific roles, teams or Apps. Two things are worth being deliberate about.
Grant bypass to Apps, not to humans, wherever possible. Release automation genuinely needs to push to protected refs. An App’s bypass is scoped and auditable; a human’s applies to everything they do, including mistakes.
Review the bypass list as part of the policy. A ruleset requiring two approvals with a bypass list containing the engineering team requires two approvals from contractors. That may be intended — but it should be intended.
Rule insights
Section titled “Rule insights”Rulesets record what they evaluated, which branch protection does not. That history answers questions that are otherwise guesswork: which rule rejected that push, how often a bypass is being used, and whether a newly-added rule would have blocked anything.
Working with rulesets
Section titled “Working with rulesets”The CLI has read-only support, which is enough for the important operations — auditing and debugging:
gh ruleset list --repo OWNER/REPOgh ruleset check --repo OWNER/REPO maingh ruleset view RULESET_ID --repo OWNER/REPOWhat it doesLists the rulesets applying to a repository, then reports exactly which rules would apply to a named branch.
Why we run it`check` is the debugging command. It resolves everything — repository rulesets, organisation rulesets and branch protection — into the effective policy for one branch, which no settings page shows in one place.
Expected resultA list of rulesets, then an enumeration of the rules in force for that branch.
Creating and updating rulesets is an API operation:
gh api repos/OWNER/REPO/rulesets --jq '.[] | {id, name, enforcement, target}'gh api --method POST repos/OWNER/REPO/rulesets --input - <<'JSON'{ "name": "require-review", "target": "branch", "enforcement": "active", "conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } }, "rules": [ { "type": "deletion" }, { "type": "non_fast_forward" }, { "type": "pull_request", "parameters": { "required_approving_review_count": 1, "dismiss_stale_reviews_on_push": true, "require_last_push_approval": true, "required_review_thread_resolution": true, "require_code_owner_review": false } } ]}JSONTwo things to note. ~DEFAULT_BRANCH is a special selector that follows the default branch if it is
ever renamed — more robust than hardcoding main. And rules are a list of typed objects, which is
what makes them composable: adding a requirement means appending an object, not rewriting a
configuration blob.
Designing governance rather than toggling settings
Section titled “Designing governance rather than toggling settings”The reason this lesson sits in a cluster about pull requests is that rulesets are where the other mechanisms combine.
A coherent design answers four questions:
- What must never happen? Deletion and force-push of the default branch and release tags. Non-negotiable, minimal, and the first ruleset you write.
- What must be true before code lands? Review and status checks. This is where most policy lives.
- Who is responsible for what? CODEOWNERS routes; a rule decides whether that routing is binding.
- Who may bypass, and why? Automation, and a documented break-glass path.
Everything else is optional and should be justified individually. Governance has a cost measured in latency and friction, paid by every contributor on every change. Rules that do not prevent a problem you actually have are pure cost.
Metadata rules in practice
Section titled “Metadata rules in practice”Commit and branch metadata rules are what rulesets can express and branch protection cannot, and they are worth a concrete look because they change behaviour at push time rather than merge time.
Branch naming:
{ "type": "branch_name_pattern", "parameters": { "operator": "regex", "pattern": "^(feature|fix|chore|docs)/[a-z0-9._-]+$", "name": "conventional branch names", "negate": false }}Commit messages:
{ "type": "commit_message_pattern", "parameters": { "operator": "regex", "pattern": "^(feat|fix|docs|chore|refactor|test)(\\(.+\\))?: .{1,72}", "name": "conventional commits", "negate": false }}Author email domain:
{ "type": "commit_author_email_pattern", "parameters": { "operator": "ends_with", "pattern": "@acme.example", "name": "corporate email required", "negate": false }}Organisation rulesets
Section titled “Organisation rulesets”On Enterprise plans, rulesets defined at the organisation level target many repositories at once, selected by name pattern, by property, or as all repositories.
That changes governance from a per-repository chore into a policy that applies by default — and introduces a debugging problem, because a repository administrator looking at their own settings cannot see the organisation rule that is blocking them.
gh ruleset list --repo OWNER/REPOgh ruleset check --repo OWNER/REPO maingh ruleset list shows the source of each ruleset. When someone reports that they cannot push and
their repository’s settings look permissive, an inherited organisation rule is the usual explanation,
and check is the command that reveals it.
A workable organisation-level baseline is narrow: no deletion and no force push on default branches, and protection on release tags. Requirements that vary by team — review counts, required checks — belong at the repository level, because an organisation-wide rule that half the repositories need exceptions for is a rule generating bypass requests rather than compliance.
Tag protection
Section titled “Tag protection”Only rulesets can protect tags, and this is the gap most repositories have without noticing.
{ "name": "protect-release-tags", "target": "tag", "enforcement": "active", "conditions": { "ref_name": { "include": ["refs/tags/v*"], "exclude": [] } }, "rules": [ { "type": "deletion" }, { "type": "non_fast_forward" }, { "type": "creation" } ]}Why it matters: a release tag is a promise that a version identifier means one specific commit. Moving or deleting one breaks that promise for everyone who pinned to it, and package managers cache the old content, so the result is two people with the same version running different code.
Including creation also prevents accidental tag creation matching the release pattern, which is how
a mistyped tag becomes a published release.
Designing a ruleset set
Section titled “Designing a ruleset set”Because rulesets layer, prefer several small ones over one large one. A structure that works:
| Ruleset | Target | Enforces | Changes |
|---|---|---|---|
never | ~DEFAULT_BRANCH | No deletion, no force push | Never |
review | ~DEFAULT_BRANCH | Pull request, approvals, resolution | Occasionally |
checks | ~DEFAULT_BRANCH | Required status checks | Often |
tags | refs/tags/v* | No deletion, no update | Never |
hygiene | refs/heads/** | Branch naming | Rarely |
Splitting by rate of change rather than by topic is the useful axis. checks changes whenever CI
changes, and separating it means editing it cannot accidentally weaken the rules that stop someone
deleting main.
Common mistakes
Section titled “Common mistakes”Assuming rulesets replace branch protection. Both can apply; the union is enforced.
Debugging the wrong mechanism. Run gh ruleset check before changing anything.
One enormous ruleset. Loses the layering benefit that makes rulesets worth using.
Activating metadata rules without warning. Rejected pushes across the whole team.
Hardcoding main instead of ~DEFAULT_BRANCH. Breaks silently on rename.
Bypass granted to humans for convenience. The bypass list is the real policy.
Forgetting tags. Release tags are as load-bearing as the branch, and only rulesets protect them.
Bypass, examined
Section titled “Bypass, examined”The bypass list is where a ruleset’s real strictness is decided, and it is the part least often reviewed.
Bypass can be granted to:
| Actor type | Typical use |
|---|---|
| Repository admin role | Break-glass, usually too broad |
| Organisation admin role | Enterprise-wide emergency access |
| A specific team | A release team that pushes tags |
| A GitHub App | Release automation, dependency bots |
| Deploy keys | Automated pushes from a specific system |
Each bypass entry also has a mode: always, or only for pull requests. The second is much
narrower and frequently sufficient — an App that needs to merge without an approval does not need to
force-push to main.
gh api "repos/OWNER/REPO/rulesets/RULESET_ID" \ --jq '.bypass_actors[] | {actor_type, actor_id, bypass_mode}'Two questions worth asking of every entry: what breaks if this is removed, and would we notice it being used. An entry nobody can justify should go; an entry that is used routinely suggests the rule is wrong rather than the bypass being necessary.
Granting bypass to Apps rather than humans is the general preference. An App’s bypass is scoped to what the App does; a person’s applies to everything they do, including mistakes made in a hurry.
Enforcement status as a migration tool
Section titled “Enforcement status as a migration tool”The evaluate concept people expect — apply rules and report without blocking — is not universally
available, so the practical approach uses disabled plus targeted testing.
The safe sequence for any new ruleset:
- Create it disabled.
gh ruleset check --repo OWNER/REPO <branch>to confirm what it would apply.- Activate it against a non-default branch pattern first, so people meet it on their own branches.
- Watch for a few days. Metadata rules in particular produce immediate, visible friction.
- Extend the target to the default branch.
Step 3 is the one worth insisting on for anything that rejects pushes. A commit-message rule going
live on main at nine in the morning blocks everyone simultaneously, and the first thing anyone will
ask for is a bypass.
Rulesets as code
Section titled “Rulesets as code”Because a ruleset is JSON, it can live in a repository with everything that implies — review, history, and a commit message explaining why each rule exists.
governance/├── README.md├── rulesets/│ ├── baseline-protection.json│ ├── require-review.json│ ├── require-ci.json│ └── protect-release-tags.json└── apply.shapply.sh reads each file, checks whether a ruleset of that name already exists, and creates or
updates accordingly. The check by name is what makes it idempotent — rulesets do not deduplicate, so
re-running without it produces two identical rulesets both enforcing.
The genuine benefit is not automation but explanation. A settings page shows what a rule is; a repository shows who added it, when, and what incident prompted it. When someone proposes removing a rule two years later, that history is the difference between an informed decision and a guess.
Interaction with organisation policy
Section titled “Interaction with organisation policy”On an Enterprise plan, rules can arrive from three places at once: the repository, the organisation, and branch protection. All apply, and the most restrictive combination wins.
That produces a specific debugging problem: a repository administrator sees permissive settings, is blocked anyway, and has no way to see why from their own settings page.
gh ruleset list --repo OWNER/REPO # shows source, including inheritedgh ruleset check --repo OWNER/REPO mainlist reports where each ruleset comes from. When someone says “my repository has no rules and I
cannot push”, that command is the answer — and it is worth telling people it exists, because the
alternative is a support request that nobody can resolve by looking at the repository.
Exercise
Section titled “Exercise”- Create a ruleset targeting the default branch with
deletionandnon_fast_forwardrules, set to disabled. - Run
gh ruleset check --repo OWNER/REPO mainand note it is not in force. - Activate it and run the check again — compare the output.
- Attempt a force push to
mainand read the rejection, noting which ruleset is named. - Add a second ruleset requiring a pull request, and confirm both appear in
check. - Disable one and confirm the other still applies.
Step 6 is the property that distinguishes rulesets from branch protection: independent policies that can be reasoned about and changed separately.
What you learned
Section titled “What you learned”- Rulesets layer; branch protection does not. The effective policy is the most restrictive union.
- Enforcement status lets a policy be disabled without losing its configuration.
- Rulesets are visible to anyone with read access, so contributors can comply rather than discover.
- They target tags as well as branches, and can enforce commit and branch metadata at push time.
gh ruleset checkresolves repository rules, organisation rules and branch protection into the effective policy for one branch.~DEFAULT_BRANCHsurvives a branch rename; a hardcoded name does not.
Starting from nothing
Section titled “Starting from nothing”For a repository with no governance at all, the first ruleset is short:
{ "name": "baseline", "target": "branch", "enforcement": "active", "conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } }, "rules": [ { "type": "deletion" }, { "type": "non_fast_forward" } ]}Two rules: the default branch cannot be deleted, and cannot be force-pushed. Nothing else.
That is the highest-value governance available per unit of friction. It prevents the two operations that destroy work irrecoverably, and it costs contributors nothing — neither rule affects any normal workflow.
Add review requirements next, then status checks, then anything else, each justified by a failure you can name. A repository that goes from nothing to eight rules in one afternoon will have a bypass list by the end of the week.
The second ruleset most repositories should have is tag protection, because it is the gap that branch protection cannot cover and the one nobody notices until a release tag moves.