Skip to content

GitHub Repository Rulesets: Policy as Layered Rules

Lesson 8 of 12Intermediate → Advanced11 min readGitHub Engineering · Pull RequestsVerified: GitHub.com and gh 2.98.0, August 2026

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.

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.

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.

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.

Because rulesets combine, policy can be composed from small pieces rather than one large configuration.

A workable arrangement for a team repository:

RulesetTargetsEnforces
baseline-protectionDefault branchNo deletion, no force push
require-reviewDefault branchPull request, one approval, stale dismissal
require-ciDefault branchStatus checks: build, test
protect-release-tagsv* tagsNo deletion, no update
commit-hygieneAll branchesBranch 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 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.

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.

The CLI has read-only support, which is enough for the important operations — auditing and debugging:

Terminal window
gh ruleset list --repo OWNER/REPO
gh ruleset check --repo OWNER/REPO main
gh ruleset view RULESET_ID --repo OWNER/REPO

What 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:

Terminal window
gh api repos/OWNER/REPO/rulesets --jq '.[] | {id, name, enforcement, target}'
Terminal window
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
}
}
]
}
JSON

Two 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:

  1. What must never happen? Deletion and force-push of the default branch and release tags. Non-negotiable, minimal, and the first ruleset you write.
  2. What must be true before code lands? Review and status checks. This is where most policy lives.
  3. Who is responsible for what? CODEOWNERS routes; a rule decides whether that routing is binding.
  4. 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.

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
}
}

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.

Terminal window
gh ruleset list --repo OWNER/REPO
gh ruleset check --repo OWNER/REPO main

gh 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.

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.

Because rulesets layer, prefer several small ones over one large one. A structure that works:

RulesetTargetEnforcesChanges
never~DEFAULT_BRANCHNo deletion, no force pushNever
review~DEFAULT_BRANCHPull request, approvals, resolutionOccasionally
checks~DEFAULT_BRANCHRequired status checksOften
tagsrefs/tags/v*No deletion, no updateNever
hygienerefs/heads/**Branch namingRarely

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.

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.

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 typeTypical use
Repository admin roleBreak-glass, usually too broad
Organisation admin roleEnterprise-wide emergency access
A specific teamA release team that pushes tags
A GitHub AppRelease automation, dependency bots
Deploy keysAutomated 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.

Terminal window
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.

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:

  1. Create it disabled.
  2. gh ruleset check --repo OWNER/REPO <branch> to confirm what it would apply.
  3. Activate it against a non-default branch pattern first, so people meet it on their own branches.
  4. Watch for a few days. Metadata rules in particular produce immediate, visible friction.
  5. 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.

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.sh

apply.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.

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.

Terminal window
gh ruleset list --repo OWNER/REPO # shows source, including inherited
gh ruleset check --repo OWNER/REPO main

list 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.

  1. Create a ruleset targeting the default branch with deletion and non_fast_forward rules, set to disabled.
  2. Run gh ruleset check --repo OWNER/REPO main and note it is not in force.
  3. Activate it and run the check again — compare the output.
  4. Attempt a force push to main and read the rejection, noting which ruleset is named.
  5. Add a second ruleset requiring a pull request, and confirm both appear in check.
  6. 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.

  • 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 check resolves repository rules, organisation rules and branch protection into the effective policy for one branch.
  • ~DEFAULT_BRANCH survives a branch rename; a hardcoded name does not.

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.

Professional ToolkitThe ruleset planning checklist is in the Professional Toolkit.