Rulesets that enforce the workflow
GitHub branch rulesets, the settings that turn your pull-request workflow from a team norm into a rule the platform mechanically enforces.
The last three lessons taught you a workflow: trunk-based development, short-lived branches off main, 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. Discipline holds right up until the one bad day it doesn’t.
Picture three of those bad days. A teammate spots a typo in main, fixes it locally, and force-pushes. That rewrites the commit history out from under everyone who had already pulled, so the next person’s git pull turns into a mess of conflicts against commits that no longer exist. Or it’s a 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 on every pull request for a month and everyone trusts the checkmark, until you discover the checks were never wired to block a merge, so a PR with failing tests sailed through whenever someone clicked merge fast enough. None of these are exotic. They’re the normal failure modes of a workflow that exists only as a team norm.
The fix is not “be more careful.” The fix is to make the rule mechanically true: hand the workflow to a system that will refuse to let you break it. A rule the platform won’t let you violate beats a norm everyone hopes holds. On GitHub the mechanism is a ruleset , a named set of conditions a branch enforces, configured in the repo’s settings. By the end of this lesson you’ll be able to stand up the six-rule baseline every production main runs, wire the CODEOWNERS file from the last lesson to the rule that finally gives it teeth, know which extra rules to reach for and when, and recognize the failure modes where a gate looks present but enforces nothing. The previous lesson left CODEOWNERS as a polite suggestion and promised the next one would make it binding. This is that lesson.
Rulesets, not branch protection rules
Section titled “Rulesets, not branch protection rules”One thing to clear up first, because it’ll confuse you the moment you open a tutorial: GitHub has two mechanisms for this same job. Branch protection rules are the original. Rulesets are the newer replacement, and they’re what a new repo shows in its settings UI today. This course teaches rulesets. Branch protection rules still work and aren’t going away soon, so plenty of older tutorials show a “Branches → Add rule” screen. When you hit one, you don’t need to start over: both mechanisms enforce the same underlying primitives, so an old tutorial is teaching the same concepts under an older UI, and the translation is mechanical.
Why do rulesets win for a team building today? Three reasons, and you’ll meet each of them in this lesson. First, one ruleset can target multiple branch patterns at once instead of needing one rule per branch. Second, rulesets stack: you can layer a loose org-wide ruleset under a stricter one on main. Third, a ruleset can carry a bypass list, naming users or apps allowed to skip the rules, with every bypass written to an audit trail. That last one is the real judgment call, and we’ll get to it.
If you’ve used GitLab or Bitbucket, they have equivalents such as push rules and merge-request approval rules, but the shapes differ enough that covering all three would dilute the point. This course teaches GitHub.
The two surfaces: .github/ files and the ruleset
Section titled “The two surfaces: .github/ files and the ruleset”One idea makes everything in this lesson click, so it’s worth slowing down for. Repo policy lives in two different places, and they behave completely differently. Get this split straight and every specific rule has an obvious home. Miss it and you’ll keep getting blindsided by a file you committed that doesn’t seem to do anything.
The first surface is the .github/ directory. It lives in the repo tree, right alongside your code. It’s version-controlled, so every change to it goes through the same pull request review as a change to a component. This is where configuration lives: CODEOWNERS, the pull_request_template.md you met last lesson, the workflows/*.yml files that will define your CI in the next chapter, and a dependabot.yml for dependency updates. The reason this material sits in the repo is itself part of the lesson: when a change to who must review billing code or what checks CI runs is a diff in a pull request, that change is reviewed, visible in history, and revertable like any other.
The second surface is Repo Settings → Rules → Rulesets. This one does not live in the repo tree. It lives in GitHub’s settings, attached to the repo but not committed anywhere you can reach with git log. This is where the enforcement lives, in the active ruleset. Why does it sit apart from everything else? Because GitHub does not read a ruleset file out of your repo and apply it automatically. The active rule lives in settings. Hold onto that gap, because it’s exactly why the “settings-as-code” option named at the end of this lesson can exist at all.
The relationship between the two surfaces is the whole point. The ruleset in settings references things defined in .github/. It names, by string, the CI checks that your workflows/*.yml produce, and it switches on the CODEOWNERS file. The file is the data: it says who owns what. The rule is the switch: it decides whether that data actually blocks a merge. Neither does the job alone.
.github/) reviewed in PRs like code CODEOWNERS pull_request_template.md workflows/ci.yml dependabot.yml main - Require a pull request
- Require 1 approval
- Dismiss stale approvals
- Require code-owner review
- Require status checks
- Require linear history
Keep that picture in mind. Every rule we enable from here is either a file in the left zone, a switch in the right zone, or, in the interesting cases, a switch on the right that only does anything because of a file on the left.
The minimum-viable ruleset for main
Section titled “The minimum-viable ruleset for main”Now for the artifact itself. Essentially every production main runs a baseline of six rules, and they aren’t arbitrary: each one closes a specific hole. The cleanest way to learn them is one at a time, each paired with the single sentence that justifies it, which is what breaks the day you don’t have it. The order isn’t alphabetical; it builds. First the gate that makes a PR mandatory, then who has to approve it, then keeping that approval honest, then the owners, then the machine checks, then the shape of history.
-
Require a pull request before merging. This disables direct pushes to
main, so the only way in is a PR. It’s the keystone: without it, every rule below is optional, because someone can always sidestep the pull-request flow and push straight to the branch. Everything that follows only has something to act on because changes now have to arrive as PRs. -
Require approvals: 1. At least one human other than the author must approve before a merge. Be honest about team size here. On a two-person team you review each other’s PRs and one approval works fine. On a true solo project there’s no second person, so you either drop this rule deliberately or self-review and accept that “approval” is just you reading your own diff again; a solo enforcement of human review isn’t real review. A separate Required reviewer rule reached general availability , meaning a stable, fully released feature rather than a preview, in February 2026. It lets you require a number of approvals from specific teams on specific file or folder globs, with
!to negate a path. Think of it as a policy layer on top of ownership: CODEOWNERS says who owns the billing code, and this rule says “and two of them must sign off.” They complement each other rather than compete. -
Dismiss stale pull request approvals when new commits are pushed. Say a reviewer approved commit A, then the author pushed B and C. Without this rule, that stale approval still counts and B and C merge unreviewed, so the green check no longer describes what’s actually landing. Turning it on drops the approval the moment new commits arrive, which forces a fresh look. This is what makes the fixup-commit loop from the pull request lesson structural rather than merely polite: push changes after an approval and the approval is gone until someone re-reviews.
-
Require review from Code Owners. This is the rule that finally gives the
CODEOWNERSfile its teeth, and it’s the clearest example of the gap at the heart of this lesson. 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 means the named owners’ approval is required to merge. Same file, completely different outcome, decided entirely by whether this switch is on. -
Require status checks to pass before merging. This is the CI gate. You list the required status checks by their exact name strings. The four jobs the next chapter’s CI produces are
typecheck,lint,test, andbuild, and a PR can’t merge until all four are green. One sub-decision is worth understanding now: strict mode , also labeled “require branches to be up to date before merging”. With it on, a PR must be rebased onto the latestmainbefore its checks count, which re-runs CI every timemainmoves. That pairs well with the rebase workflow when CI is fast, say a couple of minutes, but on a busymainwith slow CI it makes every PR re-run the whole suite repeatedly, and the team ends up waiting. So enable strict mode when CI is fast (a few minutes), and leave it off when CI is slow (10+ minutes) ormainis busy enough to cause constant re-runs. -
Require linear history . This forbids merge commits on
main. It’s the structural backstop for the squash-merge discipline: even with squash-merge configured as the team default, GitHub still offers a “Create a merge commit” button unless this rule is on, and one teammate clicking it pollutes your clean one-commit-per-change history. With this rule, that button is gone. Note that linear history requires squash or rebase merging to be enabled on the repo first. The squash-only setting from the pull request lesson already satisfies that, but if the toggle ever looks greyed out, this is why.
That’s the baseline: six switches, six holes closed. But six switches in a settings UI that will look different a year from now is a fragile thing to learn from. So let’s collapse all six into something stable: the ruleset as an object.
A ruleset isn’t only a set of checkboxes. GitHub can export it as JSON and import it back through its API, and that JSON is the durable, inspectable truth behind the clicks. You will mostly click these settings rather than write JSON by hand, so don’t read what follows as the form you’ll author. Read it as what those six clicks actually produce, the thing you could read, diff, or copy to another repo. Here’s the exported ruleset, trimmed to the parts that matter; the real export 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" } ]}This ruleset targets the main branch and is active: live, not the evaluate-only dry-run mode. Everything below applies the moment a PR touches main. The whole 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 of the six baseline rules at once: in this object’s parameters 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: change a job’s name 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 just confirm they’re on.
Read it once and the six rules stop being scattered toggles and become one concrete thing you can hold. That object survives the next UI redesign, and it’s the same object the settings-as-code option at the end of the lesson manages as a file.
Wiring CODEOWNERS to the rule
Section titled “Wiring CODEOWNERS to the rule”The fourth baseline rule turned CODEOWNERS from a suggestion into a requirement, but the file itself is worth slowing down on, because this is where the two-surfaces idea gets concrete and where the previous lesson left a debt.
Here’s a quick recap, because 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 the file.
Here’s a realistic one for an invoicing app:
* @org/eng
/app/billing/ @org/billing-leads/db/schema.ts @org/dba/.github/ @org/platformRead it top to bottom. The first line is the catch-all: everything defaults to the engineering team. Each line below it carves out a higher-stakes zone. Billing code goes to the billing leads, the schema file goes to whoever owns the database, and the .github/ directory itself goes to the platform team so nobody quietly weakens a ruleset config. Because the last match wins, the order has to run general to specific: if you put * @org/eng last, it would override every specific line above it and route everything back to engineering. Flip the order by accident and your careful billing ownership silently evaporates. The file looks right, nothing errors, and yet nothing works, which is the failure mode this whole lesson is about.
The judgment call is what to put in this 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. Resist code-owning your general UI surfaces or routine feature work. Every code-owned path adds a required approver to every PR that touches it. Spread that across the whole tree and you’ve both diffused responsibility, since everyone is an owner so no one feels like one, and slowed 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.
Notice what introducing an ownership zone actually takes: a line in the file and the rule in settings, both surfaces, every time. Add /app/billing/ @org/billing-leads to the file but leave Require review from Code Owners off, and you’ve changed nothing, because the billing leads get auto-requested and then ignored. The reflex to internalize is that ownership is a file plus a switch, and you verify both whenever you touch either.
So the six rules aren’t really six independent toggles. They compose into a single gate that a pull request has to clear, in sequence, before the merge button turns green.
Before moving on, lock in the mental model that all of this 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.
CODEOWNERSpull_request_template.mddependabot.ymlci.yml workflowRules to reach for as the repo grows
Section titled “Rules to reach for as the repo grows”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 makes them worth it, not a checklist to enable blindly. The trap with a settings page full of toggles is flipping all of them on because they sound responsible; half of them will only slow you down or lock you out. So learn each of these by its trigger, the situation that earns it.
- Require signed commits. The trigger is that the repo is audit-grade or handles genuinely sensitive data, and the whole team already has GPG or SSH signing configured. Be careful: turn this on for a team that hasn’t set up signing and you block every push, for everyone, immediately. It’s worth the friction for a regulated codebase, but overkill for a normal SaaS, which is why earlier lessons cut signing from the baseline.
- Require deployments to succeed before merging. The trigger is that you want a PR’s preview deployment to be mandatory, so nothing merges unless its preview actually built. Once a later chapter wires a Vercel preview per PR, you list that deployment as a required check and a broken preview blocks the merge. That’s a forward reference, not something to set up now.
- Restrict who can push to matching branches. This is an explicit allow-list of who may push at all. It’s rare for a trunk-based
main, since the PR requirement already governs who lands code, but it’s genuinely useful on release-tagged or specially protected branches if your team ever has them. - Block force pushes. This one is on by default in rulesets. The reach action isn’t to add it but to confirm it’s there, because it’s the rule that stops the force-push-to-
maindisaster from the opening of this lesson. - Restrict deletions. Also typically on by default. Same reflex: verify
maincan’t be deleted by an accidental click or an over-eager script.
Then there’s bypass actors , which deserves its own treatment because it’s the one piece that’s pure judgment. A ruleset can name specific users, teams, or apps allowed to bypass the rules, and every bypass is written to the audit log. The legitimate trigger is narrow: a real production emergency where the normal PR flow can’t ship the fix fast enough, handled by the on-call account. The discipline around it is the whole point. Default to an empty bypass list. A bypass should be rare, deliberate, and visible, and an after-action review should later check the audit log to confirm it was justified. The anti-pattern is a standing bypass actor that nobody ever reviews, because that one entry quietly defeats the entire reason you set up structural enforcement: now there’s a person who can do everything the rules forbid, so the rules are a suggestion again, just for them.
One last capability, named once: rulesets layer. You can run a loose ruleset across all branches, a stricter one on main, and a stricter one still on release/*, and they all apply at once. A trunk-based team needs exactly the one main ruleset, so this is awareness rather than homework; just know the capability exists for when a repo grows into needing it.
Now sort the rules by whether they’re baseline or reach.
Not every rule belongs on day one. Sort each into the always-ship baseline or the add-when-triggered reach set. Drag each item into the bucket it belongs to, then press Check.
Standing up the ruleset on a fresh repo
Section titled “Standing up the ruleset on a fresh repo”With the theory in place, let’s turn the six rules into the actual procedure on a brand-new repo. One thing is worth getting straight first, because it sounds like a paradox: if a PR is the only way onto main, how did the very 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 (the one from way back at project setup) gets pushed straight to main before any ruleset exists. The ruleset then governs commit two onward, and from that point the only path onto main is a pull request. So the sequence is always the same: push the first commit directly, create the ruleset, and require PRs from then on. If you ever wonder how main got its first commit when direct pushes are banned, that’s the answer: the ban didn’t exist yet.
Here’s the procedure.
- Open the repo on GitHub and go to Settings → Rules → Rulesets, then click New branch ruleset.
- Name it
mainand set Enforcement status to Active. The alternatives are Disabled, which does nothing, and Evaluate, which logs would-be violations without blocking; Evaluate is useful for a dry run, but not what you want here. - Under Target branches, add the default branch (or a pattern matching
main). - Enable Require a pull request before merging, then in its sub-options set Required approvals to
1, tick Dismiss stale pull request approvals when new commits are pushed, and tick Require review from Code Owners. - Enable Require status checks to pass, then 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. - Enable Require linear history.
- Confirm Block force pushes and Restrict deletions are on (they’re on by default).
- Click Create to save.
Each of those steps maps straight onto a field in GitHub’s New-ruleset panel: a name box, the enforcement dropdown, the target-branch picker, and a checklist of rules with sub-options that unfold as you tick them. The exact layout shifts over time, so don’t memorize the pixels; the exported JSON from earlier is the stable reference for what these toggles produce.
Now the step a lot of people skip and shouldn’t: prove the rule actually fires. A rule you’ve configured but never watched reject anything is a rule you only believe is on. So switch to main locally, make any trivial change, and try to push it straight up, with no branch and no PR. GitHub refuses it:
$ git push origin mainremote: 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 branch is no longer something you can push to on a whim; the workflow is now mechanically true. Make this a habit: every time you stand up a gate, watch it reject something once.
When the gate silently disappears
Section titled “When the gate silently disappears”The reason these rules need real attention is that they fail quietly. A gate doesn’t usually break with a loud error; it breaks by looking present while enforcing nothing, and the first you hear of it is a bad merge that “shouldn’t have been possible.” So the skill that matters here isn’t memorizing a list of don’ts. It’s recognizing the symptom and tracing it to the cause. Here are the ones that will bite you, each laid out as symptom, cause, and fix.
CI runs on every PR but never actually 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 completely 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. Pay the most attention to this one, because it’s the subtlest. 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. There’s no error anywhere. The gate just evaporates, and 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 the kind of thing the person who owns the platform config keeps an eye on.
A CODEOWNERS file that does nothing. The file exists, the owners are listed, and 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. It’s the enforcement gap again. The fix is to turn the rule on.
Required reviews set to 2 on a two-person team. Now 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. It’s deadlock by configuration. The fix is 1 approval on small teams.
Strict mode plus slow CI. Every time main moves, every open PR has to rebase and re-run the full suite, so a busy day turns into 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 to 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. The reflex here is that a disabled ruleset is 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 .
Settings-as-code, named once
Section titled “Settings-as-code, named once”One loose thread is left to tie off. You saw the ruleset as JSON earlier, which raises a fair question: if it can be a file, why isn’t it in the repo? Because, as we covered, GitHub doesn’t auto-apply a ruleset committed to the tree; the active rule lives in settings. But the JSON export is the seam that lets tooling close that gap. You can manage rulesets as code through the API, with tools like Terraform or a GitHub App applying the same ruleset across many repos from one source of truth. For a single SaaS-startup repo that’s pure overhead, since the UI is the right tool and settings-as-code earns nothing. It starts paying for itself at org scale, when you’re keeping dozens of repos in lockstep and clicking through each one’s settings stops being viable.
That’s the chapter’s spine delivered in full: the workflow you learned by hand is now enforced by a system that won’t let you break it. This ruleset is also a seam the upcoming chapters plug into. The next chapter authors the CI whose typecheck, lint, test, and build checks this rule requires, and a later one adds a preview deployment as another required check. You’ve built the gate, and those chapters fill it with what it gates on.
When you need the exhaustive reference, every rule type and every parameter, these are the two pages to bookmark.
The conceptual overview of how rulesets work and stack.
The full reference of every rule type and its parameters, the page you return to when configuring.
External resources
Section titled “External resources”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.
GitHub's own GitHub App for the settings-as-code option named at the end: rulesets and branch protections applied across an org from one admin repo.
A Swissquote engineer on running layered, org-wide rulesets across 3,000+ repos: bypass, audit, and the limits you hit at scale.
A focused CODEOWNERS deep dive: full pattern syntax and the last-match-wins ordering this lesson leans on.
Graphite's conceptual walkthrough of the older branch-protection mechanism rulesets replaced, useful when an old tutorial shows the Branches → Add rule screen.