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

Branch protection and governance

60 min de lecture

Read this page in French

GitHub logo

A hardened pipeline is worth nothing if a direct push to main can bypass it. This guide governs the repository in three moves: it protects the default branch with a Repository Ruleset (the ruleset.json file applied in one gh api command), enforces code-owner review by a third account through CODEOWNERS, then gives the plumber scanner full read access to the protection through an ephemeral GitHub App token. The audience is an advanced maintainer who wants governance that is effective and readable by the tools that score it. By the end, no change reaches main without going through a reviewed pull request, and plumber blocks the pipeline at the slightest deviation.

What you will learn

  • Choose a ruleset over classic branch protection, and know why
  • Apply the complete ruleset in one gh api command and read each tier
  • Enforce code-owner review and get past the first-owner bootstrap
  • Provide a GitHub App token so the scanners can read all 21 protection settings

Prerequisites

The CI pipeline is running: its five jobs plus the Plumber check of the plumber.yml workflow act as required status checks in the ruleset, that is six status checks. You are an administrator of a public repository; the reference repository of this series, github.com/stephrobert/secure-python-pipeline, stays open and browsable so you can clone the real configuration. The gh CLI is authenticated (gh auth status returns Logged in). Two extra collaborator accounts are available for cross review: without a second reviewer, code-owner review stays blocked.

Ruleset or classic branch protection: which one?

GitHub offers two mechanisms to protect a branch. The choice is not cosmetic: it determines how readable the protection is to the security scanners, and therefore the score you get with no extra effort.

CriterionClassic branch protectionRepository Ruleset
Readable by the default GITHUB_TOKENNo (an admin token is required)Yes
Multiple rules stacking on one branchOne rule per patternSeveral stackable rulesets
GitHub statusLegacyRecommended
Read by Scorecard and plumberPartial without an admin tokenComplete

Classic branch protection only exposes its settings to a token carrying the Administration permission. A Repository Ruleset, by contrast, is readable by the default GITHUB_TOKEN in read mode: Scorecard sees the protection with no special token, and plumber reads most of it. It is the modern choice, the one GitHub has recommended since 2023. So we go with a ruleset, and the GitHub App token will only fill in the last two settings a ruleset does not yet expose to a plain read.

Configuring the ruleset that protects main

A ruleset is a JSON descriptor targeting a branch and stacking rules. Each rule matches a tier the security scoring rewards. The file below, ruleset.json, reproduces exactly the protection applied on the reference repository. It did not exist in the repository in this form (the protection was applied there through successive API calls): this file is reconstructed from the active ruleset, read with gh api repos/OWNER/REPO/rulesets.

{
"name": "Protection de main",
"target": "branch",
"enforcement": "active",
"conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } },
"rules": [
{
"type": "pull_request",
"parameters": {
"required_approving_review_count": 2,
"require_code_owner_review": true,
"dismiss_stale_reviews_on_push": true,
"require_last_push_approval": true,
"required_review_thread_resolution": true,
"allowed_merge_methods": ["squash", "merge", "rebase"]
}
},
{
"type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": true,
"required_status_checks": [
{ "context": "Lint (ruff)" },
{ "context": "Tests (pytest)" },
{ "context": "SAST (bandit)" },
{ "context": "Audit dépendances (pip-audit + Trivy)" },
{ "context": "Build image + scan (trivy)" },
{ "context": "Plumber (trust policy CI/CD)" }
]
}
},
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{ "type": "required_linear_history" }
]
}

Two rules are often forgotten and yet decisive: deletion (forbidding branch deletion) and non_fast_forward (forbidding force-push, which rewrites history). They look secondary, but the protection scoring treats them as the foundation: without them, no higher tier counts, even with two approvals and six status checks.

The six status check context values cover the five CI jobs plus the Plumber check of the plumber.yml workflow; they are the exact names as they appear in the Checks tab of a pull request. An approximate name is silently ignored: the check is never treated as required, and a PR can pass without having run it. So you copy the labels character for character, accents included (Audit dépendances (pip-audit + Trivy) here, since the reference repository is in French).

The cumulative tiers of the protection

Branch scoring works in sequential tiers: each tier must be full to unlock the next. Understanding that order saves you from believing that adding an isolated rule at the top earns anything while the foundation is incomplete. From bottom to top, the tiers are as follows.

  1. Blocking deletion and non_fast_forward: the foundation. You need both to validate the first tier; one without the other leaves the score at the floor.

  2. A mandatory pull request with at least one approval (pull_request with required_approving_review_count of 1 or more).

  3. Named status checks required before merge, with strict_required_status_checks_policy forcing the branch to be up to date (our complete CI job set).

  4. Two approvals and code-owner review (require_code_owner_review plus required_approving_review_count: 2).

  5. Dismiss stale reviews (dismiss_stale_reviews_on_push) and zero admin bypass (bypass_actors empty, nobody works around the rule).

The ruleset above covers every one of those tiers, the last included. That is a strong choice: on the reference repository, bypass_actors is empty and current_user_can_bypass is never. Nobody, not even the administrator, forces a merge on main. That zero admin bypass is the hardest tier to hold solo, because the author loses their safety net; we will see right after how to get past the one moment where that net is genuinely missing, the bootstrap.

Applying the ruleset in one command

The ruleset is applied through a POST on the rulesets API, passing the JSON file as input. It is idempotent only up to creation: a second POST creates a duplicate, so list then update rather than reposting.

Fenêtre de terminal
gh api --method POST repos/stephrobert/secure-python-pipeline/rulesets \
--input ruleset.json

The output returns the created ruleset with its identifier and its enforcement: active status:

{
"id": 19057499,
"name": "Protection de main",
"target": "branch",
"enforcement": "active",
"current_user_can_bypass": "never"
}

You check the protection is indeed active and readable by listing the rulesets again. That read succeeds with the default GITHUB_TOKEN, which confirms the advantage of the ruleset over classic protection:

Fenêtre de terminal
gh api repos/stephrobert/secure-python-pipeline/rulesets \
--jq '.[] | "\(.id) \(.name) \(.enforcement)"'
19057499 Protection de main active

If you are migrating from an existing classic branch protection, remove it after applying the ruleset, so the two mechanisms do not overlap and confuse the scanners' diagnosis:

Fenêtre de terminal
gh api --method DELETE repos/stephrobert/secure-python-pipeline/branches/main/protection

Code-owner review: enforcing a third-party reviewer

The ruleset forbids direct pushes, but you still have to guarantee the pull request is reviewed by someone other than its author. That is the role of the CODEOWNERS file: it declares who owns what, and the require_code_owner_review parameter demands their approval before any merge. You cannot approve your own PR, so the presence of a third-party owner forces cross review.

# CODEOWNERS - default owners: every PR requires their review.
* @stephrobert @coconux3 @outscale-srt20
# The CI/CD chain is sensitive: mandatory review on the workflows.
/.github/workflows/ @stephrobert @coconux3 @outscale-srt20
/Dockerfile @stephrobert @coconux3 @outscale-srt20

The * pattern covers the whole repository: any modified file calls for the review of one of the three owners. The two following lines reinforce the rule on the sensitive paths, the workflows directory and the Dockerfile, where a malicious change would do the most damage. GitHub applies the most specific rule: a PR touching a workflow must be approved by an owner declared on /.github/workflows/, not merely by the generic pattern.

Concretely, once that governance is in place, a change always follows the same path: the author pushes to a feature branch, opens a pull request, a third account approves it, the six status checks turn green, and only then does the merge become possible. A direct push to main is refused by the server, with no exception.

Bootstrapping the first code owner

A starting repository runs into a bootstrap paradox: code-owner review is mandatory, but you cannot approve your own pull request. So you add a collaborator and list them in CODEOWNERS. Except that the first pull request, precisely the one adding them to CODEOWNERS, stays blocked: the collaborator is only recognised as an owner after the merge of the file declaring them.

That deadlock is crossed once only. There are two options: an exceptional administrator merge for that bootstrap PR, or a temporary relaxation of the code-owner rule for the time of the merge. On the reference repository, with bypass_actors empty, the bootstrap was done before the zero-bypass tier was set: you add the collaborator first, merge, then tighten the ruleset to its final form. Afterwards, every pull request goes through an effective review, without ever bypassing the protection.

Why the GITHUB_TOKEN is not enough for the scanners

The ruleset makes most settings readable in plain read mode, but not all of them. The plumber scanner checks the branch protection setting by setting, and two of them are only exposed to a token carrying the Administration: Read permission. With the default GITHUB_TOKEN, plumber resolves only 19 settings out of 21: it wrongly believes code-owner review is not required and caps its score at B, while the protection is correctly set.

The naive reflex would be to add administration: read to the workflow's permissions: block. That is impossible: administration is not a valid workflow permission. actionlint refuses it, and the GITHUB_TOKEN can therefore never obtain it. The permission exists on the GitHub API side, but it is not exposed to the automatic Actions token. That limit is deliberate: a workflow token able to read and modify the repository administration would be a prime target for a compromised action.

The right answer is a GitHub App token, generated on the fly, with minimal permission (Administration: Read alone) and scoped to the current repository. It is ephemeral: created at the start of the job, it expires at the end. That is far better than a long-lived personal access token stored as a secret, which would carry far more rights and survive for months.

The GitHub App token for full read access

The official actions/create-github-app-token action exchanges the App identity for an installation token valid for the duration of the job. The plumber.yml workflow of the reference repository generates it, then passes it to plumber through the github-token input. Here is the complete workflow, pinned by SHA and at least privilege.

.github/workflows/plumber.yml
name: Plumber
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions: {}
jobs:
plumber:
name: Plumber (trust policy CI/CD)
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
security-events: write # uploading the SARIF
id-token: write # publishing the score (badge)
steps:
- name: Harden runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Generate a GitHub App token (Administration:read)
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.PLUMBER_APP_CLIENT_ID }}
private-key: ${{ secrets.PLUMBER_APP_PRIVATE_KEY }}
- name: Plumber analysis
uses: getplumber/plumber@ef9864df81f444647d95889d98695434f6fa9732 # v0.4.12
with:
# A strict gate from the start: the slightest non-compliance fails
# the workflow (min-points 100, no soft-fail).
min-points: "100"
soft-fail: false
# Verifies the SLSA provenance of the downloaded plumber binary.
verify-attestation: true
# Publishes the score to the badge (OIDC, public repository).
score-push: true
# Ephemeral GitHub App token (Administration:read) to read the
# complete branch protection.
github-token: ${{ steps.app-token.outputs.token }}

Two details make plumber blocking rather than informational. min-points: "100" demands a perfect score: the slightest non-compliance drops the total below 100. soft-fail: false turns that drop into a job failure, therefore into a red check on the pull request. Since that Plumber (trust policy CI/CD) check appears in the ruleset's required status checks, a non-compliant pipeline can no longer be merged. The loop is closed: plumber reads the protection thanks to the App token, and the protection requires plumber to pass.

Creating and installing the App

The App is created once, then installed on the repository. Order matters: without the installation, token generation fails with an unhelpful error. Follow the steps while granting strictly the permission needed, nothing more.

  1. Create the App at github.com/settings/apps/new: uncheck the webhook, grant only Repository permissions > Administration: Read-only. No other permission is needed to read the branch protection.

  2. Collect the Client ID and generate a private key (a .pem file). Careful: it is the private key you need, not the client secret. The latter serves OAuth, not installation token generation, and produces an authentication error when confused.

  3. Install the App on the repository (the Install App tab), otherwise create-github-app-token fails with Not Found - repository-installation. The App exists then, but has access to no repository.

  4. Record the credentials: PLUMBER_APP_CLIENT_ID as a repository variable (not sensitive) and PLUMBER_APP_PRIVATE_KEY as a secret (the content of the .pem, sensitive). The workflow reads them through vars. and secrets..

Once the App token is in place, rerun the plumber workflow. It now reads 21 settings out of 21, recognises code-owner review as required, and its score moves from B to A. Without that token, the rest of the configuration is correct but invisible to the scanner: the hardening exists, the proof is missing.

Key points

  • A ruleset is readable by the default GITHUB_TOKEN, unlike classic branch protection: it is the modern choice, adopt it.
  • The deletion and non_fast_forward rules form the foundation of the protection; without them, no higher tier counts.
  • The status check context values are the exact names of the CI jobs: an approximate label is ignored and lets an untested PR through.
  • Code-owner review forces a third-party approval; the bootstrap of the first owner is crossed once only, never as a habit.
  • administration is not a workflow permission: full read access goes through an ephemeral GitHub App token with Administration: Read.
  • The App must be installed on the repository, with the private key (not the client secret) and the client-id; min-points: 100 plus soft-fail: false make plumber blocking.

The repository github.com/stephrobert/secure-python-pipeline is public and clonable: it carries the exact ruleset, CODEOWNERS and plumber.yml workflow of this guide.

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