A ruleset decides who can write to main, and therefore who can modify your
workflows. It is the control that gives weight to all the others. This page shows
how to configure one, how it differs from classic branch protection, and why
its bypass list deserves an explicit decision: without one, nobody works
around it, not even the repository owner.
What you will learn
- Tell apart rulesets and classic branch protection, and their distinct APIs
- Configure the rules that matter for CI security
- Decide the bypass list knowingly
- Back up and restore a ruleset without going through the interface
- Anticipate the deadlock a temporary relaxation creates
Ruleset or classic branch protection?
GitHub offers two mechanisms doing a similar job, coexisting on the same repository, configured neither in the same place nor through the same API. The confusion costs time exactly when you are hunting for why a merge is refused.
| Classic branch protection | Ruleset | |
|---|---|---|
| Location | Settings > Branches | Settings > Rules |
| Scope | One branch at a time | A reference pattern, several branches |
| Stacking | A single rule applies | Several rulesets add up |
| Admin bypass | Possible unless disabled | Only through the bypass list |
| Test mode | No | evaluate, which logs without blocking |
| API | /branches/{branch}/protection | /repos/{owner}/{repo}/rulesets |
The evaluate mode is the decisive argument on an existing repository: it
applies the ruleset without blocking anything and logs what would have been
refused. You measure the real friction before imposing it.
The rules that matter for CI
Not every rule is worth the same from a pipeline security point of view. The four below close the paths actually used.
| Rule | What it prevents |
|---|---|
| Pull request required | A direct commit to main adding or modifying a workflow |
| Code owner review | A change to .github/workflows/ reviewed by someone unfamiliar with it |
| Required checks | A merge while the workflow scanners are red |
| Force push forbidden | A history rewrite hiding the addition of a workflow |
The third deserves a clarification: requiring green checks is only worth something if your checks verify something. A ruleset requiring a test job but ignoring the workflow scanner lets through exactly what this module is trying to block.
Creating the ruleset
The interface is enough for creation. On the command line, reading is more useful than writing: it is what lets you verify, back up, and detect a drift.
REPO=my-org/my-project
# List the active rulesetsgh api "repos/$REPO/rulesets" --jq '.[] | {id, name, enforcement}'
# Read the parameters of the pull_request rulegh api "repos/$REPO/rulesets/RULESET_ID" \ --jq '.rules[] | select(.type=="pull_request") | .parameters'The expected output on a governed repository looks like this:
{ "required_approving_review_count": 1, "require_code_owner_review": true, "dismiss_stale_reviews_on_push": true, "require_last_push_approval": true}dismiss_stale_reviews_on_push and require_last_push_approval are the
two settings people forget, and they are the ones closing the most obvious hole:
getting an approval on an innocuous diff, then pushing the malicious workflow
before the merge.
The bypass list, a decision to take before you need it
This is the behavioural difference that surprises people most, and it is usually discovered at the worst moment.
On classic branch protection, an administrator bypasses by default. On a ruleset, nobody bypasses: not the administrator, not the repository owner. The bypass exists only for the actors listed in the bypass list. An empty bypass list literally means the rule applies to everyone.
The practical consequence is stark: on a repository with a single maintainer, a
mandatory code owner review and an empty bypass list, nothing can be merged any
more. GitHub forbids approving your own pull request, so there is no path. Even
gh pr merge --admin is refused, contrary to what its name suggests.
Backing up and restoring
Any change to a ruleset must be preceded by a backup. The API expects a complete object on write: sending a partial structure overwrites what is missing.
-
Back up the current state, before any change.
Fenêtre de terminal gh api "repos/$REPO/rulesets/$RULESET_ID" > ruleset-backup.json -
Build the modified version from the backup, touching only the rule you target. The final filter drops null keys, which the API refuses.
Fenêtre de terminal jq '{name, target, enforcement, conditions, bypass_actors,rules: (.rules | map(if .type == "pull_request"then .parameters.required_approving_review_count = 0else . end))}| with_entries(select(.value != null))' ruleset-backup.json > ruleset-relaxed.json -
Apply it, then check the value actually in place rather than the return code.
Fenêtre de terminal gh api -X PUT "repos/$REPO/rulesets/$RULESET_ID" --input ruleset-relaxed.jsongh api "repos/$REPO/rulesets/$RULESET_ID" \--jq '.rules[] | select(.type=="pull_request") | .parameters' -
Restore from the backup as soon as the operation is over, and rerun the posture scanner to confirm the return to compliance.
The last step is not cosmetic. A PUT returning 200 proves the API accepted the
body, not that the expected protection is in place. Only reading it back, or
better still the scanner turning green, demonstrates that.
What a scanner sees of your ruleset
A point often ignored: branch protection is readable through the API, and therefore auditable continuously. A posture scanner compares it with your declared expectations and reports the gap.
Two useful consequences. The first is defensive: a temporary weakening that was
forgotten becomes visible on the next CI run, instead of settling in. The second
is a tooling constraint: the default GITHUB_TOKEN cannot read the complete
branch protection configuration. A scanner settling for it sees only part of the
settings and wrongly concludes that code owner review is not required. You have
to give it a token holding the Administration read permission, ideally an
ephemeral GitHub App token rather than a long-lived secret.
Key points
- Rulesets and classic protection coexist, are configured in two places and queried through two distinct APIs; the refusal message tells them apart.
- The
evaluatemode applies a ruleset without blocking and logs what would have been refused: the right way to introduce it on an existing repository. - Four rules matter for CI: pull request required, code owner review, required checks and force push forbidden.
dismiss_stale_reviews_on_pushandrequire_last_push_approvalclose the hole of an approval obtained on an innocuous diff.- On a ruleset, nobody bypasses outside the bypass list, not even the owner:
gh pr merge --adminis refused. - Relaxing to merge creates a non-compliance a scanner detects, which can block the very merge you wanted: the bypass list is decided calmly.
- A successful API write proves nothing: read the value back, and confirm with the scanner turning green.
- A posture scanner needs an Administration read token to see the complete protection; the default
GITHUB_TOKENis not enough.
Next steps
- Restricting the allowed actions: the organisation control deciding which third-party code may run, regardless of who writes the workflow.
- Runner groups and the network boundary: bounding what a compromised run can reach on your infrastructure.
- actionlint: the quickest check to make mandatory in your merge rules.