Skip to content
Chapter 96Lesson 4

GitHub branch rulesets for `main`

GitHub branch rulesets, the settings that turn your pull-request workflow from a team norm into a rule the platform enforces.

The last three lessons built a workflow on main: short-lived branches, small reviewable pull requests, squash-merge, and owners routed to the files they care about. All of it is discipline, a set of habits the team agrees to follow, and discipline holds right up until the one bad day it doesn’t.

Picture three of those days. A teammate fixes a typo and force-pushes to main, rewriting history under everyone who had already pulled, so the next git pull collides with commits that no longer exist. It’s Friday at 18:00, a customer-facing bug is live, someone pushes the fix straight to main with no review, and the fix has its own bug. Or CI runs green for a month and everyone trusts the checkmark, until a PR with failing tests merges and you learn the checks were never wired to block anything. These are the normal failure modes of a workflow that exists only as a team norm.

The fix is not “be more careful.” It is to make the rule mechanically true: hand the workflow to a system that refuses to let you break it. On GitHub that mechanism is a ruleset . This lesson stands up the six-rule baseline every production main runs, wires last lesson’s CODEOWNERS to the rule that finally gives it teeth, covers which extra rules to reach for and when, and shows the failure modes where a gate looks present but enforces nothing.

The two surfaces: .github/ files and the ruleset

Section titled “The two surfaces: .github/ files and the ruleset”

Repo policy lives in two places that behave differently, and once you see the split, every rule in this lesson has an obvious home.

The first surface is the .github/ directory, which lives in the repo tree alongside your code. It is version-controlled, so every change to it goes through pull request review like a change to any component. This is where configuration lives: CODEOWNERS, the pull_request_template.md from the last lesson, the workflows/*.yml files that define CI, and a dependabot.yml for dependency updates. Keeping this material in the repo is the point: when who must review billing code is a diff in a PR, that change is reviewed, visible in history, and revertable like any other.

The second surface is Repo Settings → Rules → Rulesets. The active ruleset does not live in the repo tree; it lives in GitHub’s settings, attached to the repo but not reachable from git log. This is where enforcement lives. (If you hit an older tutorial pointing at Branches → Add rule, that is branch protection rules , the mechanism rulesets replaced.)

The two surfaces only work together. The ruleset references what .github/ defines: it names, by exact string, the CI checks your workflows/*.yml produce, and it switches on the CODEOWNERS file. The file is the data, naming who owns what; the rule is the switch, deciding whether that data actually blocks a merge. Neither does the job alone.

In the repo (.github/) reviewed in PRs like code
CODEOWNERS
activates
pull_request_template.md
workflows/ci.yml
references checks by name
dependabot.yml
In repo settings not in the tree
Ruleset: main
  • Require a pull request
  • Require 1 approval
  • Dismiss stale approvals
  • Require code-owner review
  • Require status checks
  • Require linear history
Configuration lives in the repo and is reviewed like code; enforcement lives in repo settings. The ruleset is the switch that wires the two together.

Nearly every production main runs the same six rules, and each one closes a specific hole. We’ll take them one at a time, paired with the failure that hits the day you skip it. They build in order: make a PR mandatory, require an approval, keep that approval honest, require the owners, run the machine checks, then fix the shape of history.

  1. Require a pull request before merging. Direct pushes to main are now blocked, so the only way in is a PR. This is the keystone: without it every rule below is optional, because anyone can push straight to the branch and skip them.

  2. Require approvals: 1. At least one human other than the author must approve. Size this to your team. Two people review each other’s PRs, and one approval works. A true solo project has no second reviewer, so either drop this rule or accept that self-approval isn’t real review. A separate Required reviewer rule (stable since February 2026) goes further: it can require approvals from specific teams on specific path globs, with ! to negate a path. CODEOWNERS says who owns the billing code; this rule says two of them must sign off.

  3. Dismiss stale pull request approvals when new commits are pushed. A reviewer approves commit A, then the author pushes B and C. Without this rule the stale approval still counts and B and C merge unreviewed, so the green check no longer describes what’s landing. Turning it on drops the approval the moment new commits arrive, forcing a fresh look.

  4. Require review from Code Owners. This is what gives CODEOWNERS teeth. The file alone only auto-requests the owners as reviewers, and an auto-requested reviewer can be ignored, so the PR merges without them. The file plus this rule makes their approval required. Same file, opposite outcome, decided by this one switch.

  5. Require status checks to pass before merging. The CI gate. You list the required status checks by their exact name strings. The next chapter’s CI produces four jobs, typecheck, lint, test, and build, and a PR can’t merge until all four are green. One sub-decision matters now: strict mode (labeled “require branches to be up to date before merging”) forces a PR to be rebased onto the latest main before its checks count, re-running CI every time main moves. Enable it when CI is fast (a few minutes); leave it off when CI is slow (10+ minutes) or main is busy enough to trigger constant re-runs.

  6. Require linear history . This forbids merge commits on main. It’s the structural backstop for squash-merge: even with squash set as the default, GitHub still offers a “Create a merge commit” button until this rule is on, and one click pollutes your one-commit-per-change history. It requires squash or rebase merging to be enabled first; the squash-only setting from the pull request lesson already covers that, which is why the toggle isn’t greyed out.

That’s the baseline: six switches, six holes closed. But switches in a settings UI that will look different in a year are a fragile thing to learn from, so let’s collapse them into something stable.

GitHub can export a ruleset as JSON and import it back through its API, and that JSON is the durable, inspectable truth behind the clicks. You’ll mostly click these settings rather than write the JSON, so read what follows not as a form you author but as what those six clicks produce, the thing you could read, diff, or copy to another repo. The export is trimmed here to the parts that matter.

{
"name": "main",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": { "include": ["refs/heads/main"], "exclude": [] }
},
"rules": [
{ "type": "pull_request",
"parameters": {
"required_approving_review_count": 1,
"dismiss_stale_reviews_on_push": true,
"require_code_owner_review": true
}
},
{ "type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": false,
"required_status_checks": [
{ "context": "typecheck" },
{ "context": "lint" },
{ "context": "test" },
{ "context": "build" }
]
}
},
{ "type": "required_linear_history" },
{ "type": "non_fast_forward" },
{ "type": "deletion" }
]
}

This ruleset targets main and is active: live, not the evaluate-only dry-run mode. Everything below applies the moment a PR touches main. The export is abbreviated; GitHub’s real one carries more default fields.

{
"name": "main",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": { "include": ["refs/heads/main"], "exclude": [] }
},
"rules": [
{ "type": "pull_request",
"parameters": {
"required_approving_review_count": 1,
"dismiss_stale_reviews_on_push": true,
"require_code_owner_review": true
}
},
{ "type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": false,
"required_status_checks": [
{ "context": "typecheck" },
{ "context": "lint" },
{ "context": "test" },
{ "context": "build" }
]
}
},
{ "type": "required_linear_history" },
{ "type": "non_fast_forward" },
{ "type": "deletion" }
]
}

One pull_request clause carries three baseline rules at once: it requires a PR, requires one approval, dismisses stale approvals on push, and requires code-owner review.

{
"name": "main",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": { "include": ["refs/heads/main"], "exclude": [] }
},
"rules": [
{ "type": "pull_request",
"parameters": {
"required_approving_review_count": 1,
"dismiss_stale_reviews_on_push": true,
"require_code_owner_review": true
}
},
{ "type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": false,
"required_status_checks": [
{ "context": "typecheck" },
{ "context": "lint" },
{ "context": "test" },
{ "context": "build" }
]
}
},
{ "type": "required_linear_history" },
{ "type": "non_fast_forward" },
{ "type": "deletion" }
]
}

The four CI checks, listed by exact name string. This literal list is what the renamed-job trap later in the lesson is about: rename a job and the matching string here no longer fires. strict_required_status_checks_policy: false means strict mode is off.

{
"name": "main",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": { "include": ["refs/heads/main"], "exclude": [] }
},
"rules": [
{ "type": "pull_request",
"parameters": {
"required_approving_review_count": 1,
"dismiss_stale_reviews_on_push": true,
"require_code_owner_review": true
}
},
{ "type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": false,
"required_status_checks": [
{ "context": "typecheck" },
{ "context": "lint" },
{ "context": "test" },
{ "context": "build" }
]
}
},
{ "type": "required_linear_history" },
{ "type": "non_fast_forward" },
{ "type": "deletion" }
]
}

required_linear_history forbids merge commits, non_fast_forward blocks force-pushes, and deletion stops the branch being deleted. The last two are GitHub’s default protections, so you mostly confirm they’re on.

1 / 1

Read it once and the six rules stop being scattered toggles and become one concrete object you can hold, copy, and diff, one that survives the next UI redesign.

The fourth baseline rule turned CODEOWNERS from a suggestion into a requirement, so the file is worth getting right.

One detail trips up nearly everyone. .github/CODEOWNERS maps path globs to reviewers, written gitignore-style, and the last matching line wins: not the most specific match, but the last one in the file. That single rule decides how you order it.

Here’s a realistic one for an invoicing app:

.github/CODEOWNERS
* @org/eng
/app/billing/ @org/billing-leads
/db/schema.ts @org/dba
/.github/ @org/platform

Read it top to bottom. The first line is the catch-all: everything defaults to the engineering team. Each line below carves out a higher-stakes zone, routing billing code to the billing leads, the schema file to the database owners, and the .github/ directory to the platform team so nobody quietly weakens a ruleset config. Because the last match wins, the order has to run general to specific: put * @org/eng last and it overrides every line above it, routing everything back to engineering. Flip the order by accident and your billing ownership silently evaporates: the file looks right, nothing errors, and yet nothing works.

The harder call is what to put in the file, and the answer is less than you’d guess. Assign code owners only to zones with clear ownership and real stakes, such as auth, billing, schema, and infrastructure. Every code-owned path adds a required approver to every PR that touches it, so spreading ownership across the whole tree both diffuses responsibility, since everyone is an owner and no one feels like one, and slows every merge to a crawl. On a two-person team with no real specialization, the honest move is to cut the file entirely, because there are no distinct owners to route to.

Introducing an ownership zone takes two surfaces, every time: a line in the file and the rule in settings. Add /app/billing/ @org/billing-leads but leave Require review from Code Owners off, and you’ve changed nothing, because the billing leads get auto-requested and then ignored. Ownership is a file plus a switch, so verify both whenever you touch either.

So the six rules aren’t really six independent toggles. They compose into a single gate a pull request has to clear, in sequence, before the merge button turns green.

enters
1 approval passed
no stale approval passed
code-owner approved blocked here
4 checks green not reached
linear history not reached
Merge
Once a change arrives as a PR, the baseline rules compose into one gate. The merge button stays locked until every gate passes — here a missing code-owner approval blocks it, even though approvals and CI are green.

To lock in the model everything hangs on: each item below lives in exactly one of two places, the .github/ directory that’s reviewed in pull requests or the ruleset in repo settings. Sort them.

Each piece of repo policy lives in one of two surfaces. Sort each into where it actually lives. Drag each item into the bucket it belongs to, then press Check.

Lives in .github/ Version-controlled, reviewed in PRs
Lives in repo settings The active ruleset
CODEOWNERS
pull_request_template.md
dependabot.yml
The ci.yml workflow
Require linear history
Require code-owner review
Dismiss stale approvals
The required status checks list

The six baseline rules are worth setting up now for any production main. Past them is a set of rules you reach for when a specific condition earns them, not a checklist to enable blindly. A settings page full of toggles tempts you to flip them all on because they sound responsible, but half will only slow you down or lock you out. So learn each one by its trigger: the situation that makes it worth the friction.

  • Require signed commits. Trigger: the repo is audit-grade or handles genuinely sensitive data, and the whole team already has GPG or SSH signing set up. Turn it on before the team has signing configured and you block every push, for everyone, immediately. Worth it for a regulated codebase, overkill for a normal web app, which is why the baseline left signing out.
  • Require deployments to succeed before merging. Trigger: you want a PR’s preview deployment to be mandatory, so nothing merges unless its preview built. A later chapter wires a Vercel preview per PR; you then list that deployment as a required check, and a broken preview blocks the merge. Set it up then, not now.
  • Restrict who can push to matching branches. An explicit allow-list of who may push at all. Rare for a trunk-based main, since the PR requirement already governs who lands code, but useful on release-tagged or specially protected branches if your team has them.
  • Block force pushes. On by default in rulesets. The reach action is to confirm it’s on, since this is the rule that stops the force-push-to-main disaster from the start of this lesson.
  • Restrict deletions. Also on by default. Same reflex: verify main can’t be deleted by an accidental click or an over-eager script.

Then there are bypass actors , the one piece that is pure judgment. A ruleset can name users, teams, or apps allowed to bypass the rules, and every bypass is logged. The legitimate trigger is narrow: a real production emergency the normal PR flow can’t ship a fix for fast enough, handled by the on-call account. Default to an empty bypass list, and treat any bypass as rare, deliberate, and visible, with an after-action review that checks the log to confirm it was justified. A standing bypass actor nobody reviews defeats the whole point of structural enforcement: it leaves one person who can do everything the rules forbid, so the rules become a suggestion, just for them.

One last capability: rulesets layer. A loose ruleset across all branches, a stricter one on main, and a stricter one still on release/* all apply at once. A trunk-based team needs only the one main ruleset, so this is awareness for when a repo grows into it.

Now turn the six rules into a procedure on a brand-new repo. First settle what sounds like a paradox: if a pull request is the only way onto main, how did the first commit get there?

The answer is the bootstrap exception. A brand-new repo has no ruleset yet, and an empty main has nothing to protect, so the initial scaffold commit is pushed straight to main. You then create the ruleset, and it governs commit two onward, when a pull request becomes the only path in. The sequence is always: push the first commit directly, create the ruleset, require PRs from then on.

  1. Open the repo on GitHub, go to Settings → Rules → Rulesets, and click New branch ruleset.
  2. Name it main and set Enforcement status to Active. (Disabled does nothing; Evaluate logs would-be violations without blocking, useful for a dry run but not here.)
  3. Under Target branches, add the default branch (or a pattern matching main).
  4. Enable Require a pull request before merging, then set Required approvals to 1, tick Dismiss stale pull request approvals when new commits are pushed, and tick Require review from Code Owners.
  5. Enable Require status checks to pass and add the four CI checks by name: typecheck, lint, test, build. Leave Require branches to be up to date (strict mode) off unless CI is fast.
  6. Enable Require linear history.
  7. Confirm Block force pushes and Restrict deletions are on (they are by default).
  8. Click Create to save.

Each step is one field in the panel: a name box, the enforcement dropdown, the target-branch picker, and a checklist whose sub-options unfold as you tick them. The layout shifts over time, so don’t memorize it; the exported JSON from earlier is the stable reference for what these toggles produce.

Now the step many people skip: prove the rule actually fires. A rule you have configured but never watched reject anything is one you only believe is on. Switch to main locally, make any trivial change, and try to push it straight up, with no branch and no PR. GitHub refuses:

Terminal window
$ git push origin main
remote: error: GH006: Protected branch update failed for refs/heads/main.
remote: error: Changes must be made through a pull request.
To github.com:org/invoicing-app.git
! [remote rejected] main -> main (protected branch hook declined)
error: failed to push some refs to 'github.com:org/invoicing-app.git'

That rejection is the confirmation: the workflow is now mechanically true, not just configured. Make it a habit, every time you stand up a gate, watch it reject something once.

These rules deserve attention because they fail quietly. A gate rarely breaks with a loud error; it breaks by looking present while enforcing nothing, and you find out from a bad merge that “shouldn’t have been possible.” Each failure below is laid out as symptom, cause, and fix.

CI runs on every PR but never blocks a merge. The cause is almost always an empty required-status-checks list, or a check that was never added to it. A workflow producing green checkmarks is a different thing from a ruleset requiring those checks; the checkmark is decoration until the rule names it. The fix is to add each job name to the required list.

The renamed-job trap. This one is the subtlest, so pay it the most attention. The ruleset names checks by string. Rename a CI job from test to unit-tests, and the ruleset is still waiting on a check called test, which no longer exists, so it never reports, so the requirement is silently treated as satisfied. No error appears anywhere. The gate evaporates while CI looks as green as ever. The fix is a reflex: when you rename a CI job, update the ruleset’s required-checks list in the same pull request. As the repo grows, this is what the person who owns the platform config watches for.

A CODEOWNERS file that does nothing. The file exists, the owners are listed, yet PRs merge without them. The cause is the one you now expect: Require review from Code Owners is off, so the file only auto-requests reviewers who can be ignored. The fix is to turn the rule on.

Required reviews set to 2 on a two-person team. Every PR needs two approvals, the author can’t be one of them, and there’s only one other person, so nothing can ever merge: deadlock by configuration. The fix is 1 approval on small teams.

Strict mode plus slow CI. Every time main moves, every open PR must rebase and re-run the full suite, so a busy day becomes the team watching CI spin instead of shipping. The fix is to turn strict mode off until CI is fast enough to absorb the re-runs.

The signed-commits rule with no signing configured. Enable it before the team has signing set up and every push is rejected, for everyone, instantly. The fix is to configure signing first, or leave the rule off.

“Disable the ruleset for this one PR” that never gets re-enabled. Someone flips the ruleset off to unblock an urgent merge, ships, and forgets to flip it back. The gate is now off for everything, and nobody notices until something bad slips through. Treat a disabled ruleset as an incident, not a casual ops move: it gets an after-action review, and re-enabling it is part of closing that incident.

Try diagnosing a few. For each symptom, pick the most likely cause.

A gate that looks present can enforce nothing. Diagnose each silent failure. Pick the right option from each dropdown, then press Check.

CI goes green on every PR, yet a PR with a clearly failing job still merged — the most likely cause is that .

A teammate renamed the CI job from test to unit-tests and now that gate never blocks anything, with no error shown anywhere — the fix is to .

The CODEOWNERS file lists the billing leads, but PRs touching billing keep merging without their approval — the cause is that .

The JSON you saw earlier is a seam: through the API, tools like Terraform or a GitHub App can apply one ruleset across many repos from a single source of truth. For one web-app repo that is pure overhead, since the UI is the right tool. It starts paying off at org scale, when clicking through dozens of repos’ settings to keep them in lockstep stops being viable.

The next chapter authors the CI whose typecheck, lint, test, and build checks this rule requires.

For the exhaustive reference, every rule type and parameter, bookmark these two pages.

Beyond the official reference, these go deeper on the corners this lesson only pointed at: settings-as-code, ownership routing, and what rulesets look like once a real team runs them at scale.