Skip to content
Français
CI/CD & Automatisation medium

GitHub rulesets: protecting the branch that carries your workflows

30 min de lecture

Read this page in French

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 protectionRuleset
LocationSettings > BranchesSettings > Rules
ScopeOne branch at a timeA reference pattern, several branches
StackingA single rule appliesSeveral rulesets add up
Admin bypassPossible unless disabledOnly through the bypass list
Test modeNoevaluate, 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.

RuleWhat it prevents
Pull request requiredA direct commit to main adding or modifying a workflow
Code owner reviewA change to .github/workflows/ reviewed by someone unfamiliar with it
Required checksA merge while the workflow scanners are red
Force push forbiddenA 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.

Fenêtre de terminal
REPO=my-org/my-project
# List the active rulesets
gh api "repos/$REPO/rulesets" --jq '.[] | {id, name, enforcement}'
# Read the parameters of the pull_request rule
gh 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.

  1. Back up the current state, before any change.

    Fenêtre de terminal
    gh api "repos/$REPO/rulesets/$RULESET_ID" > ruleset-backup.json
  2. 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 = 0
    else . end))}
    | with_entries(select(.value != null))' ruleset-backup.json > ruleset-relaxed.json
  3. 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.json
    gh api "repos/$REPO/rulesets/$RULESET_ID" \
    --jq '.rules[] | select(.type=="pull_request") | .parameters'
  4. 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 evaluate mode 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_push and require_last_push_approval close 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 --admin is 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_TOKEN is not enough.

Next steps

Is this site useful to you?

Fewer than 1% of readers support this site.

I maintain more than 700 free guides, with no ads and no tracking. Any support, even a symbolic one, helps cover hosting and keeps these resources free. Thank you for the help.

The form does not show? Open Ko-fi in a new tab.

Subscribe and follow my DevSecOps work on LinkedIn