
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 apicommand 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.
| Criterion | Classic branch protection | Repository Ruleset |
|---|---|---|
Readable by the default GITHUB_TOKEN | No (an admin token is required) | Yes |
| Multiple rules stacking on one branch | One rule per pattern | Several stackable rulesets |
| GitHub status | Legacy | Recommended |
| Read by Scorecard and plumber | Partial without an admin token | Complete |
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.
-
Blocking
deletionandnon_fast_forward: the foundation. You need both to validate the first tier; one without the other leaves the score at the floor. -
A mandatory pull request with at least one approval (
pull_requestwithrequired_approving_review_countof 1 or more). -
Named status checks required before merge, with
strict_required_status_checks_policyforcing the branch to be up to date (our complete CI job set). -
Two approvals and code-owner review (
require_code_owner_reviewplusrequired_approving_review_count: 2). -
Dismiss stale reviews (
dismiss_stale_reviews_on_push) and zero admin bypass (bypass_actorsempty, 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.
gh api --method POST repos/stephrobert/secure-python-pipeline/rulesets \ --input ruleset.jsonThe 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:
gh api repos/stephrobert/secure-python-pipeline/rulesets \ --jq '.[] | "\(.id) \(.name) \(.enforcement)"'19057499 Protection de main activeIf 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:
gh api --method DELETE repos/stephrobert/secure-python-pipeline/branches/main/protectionCode-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-srt20The * 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.
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.
-
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. -
Collect the Client ID and generate a private key (a
.pemfile). 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. -
Install the App on the repository (the Install App tab), otherwise
create-github-app-tokenfails withNot Found - repository-installation. The App exists then, but has access to no repository. -
Record the credentials:
PLUMBER_APP_CLIENT_IDas a repository variable (not sensitive) andPLUMBER_APP_PRIVATE_KEYas a secret (the content of the.pem, sensitive). The workflow reads them throughvars.andsecrets..
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
deletionandnon_fast_forwardrules form the foundation of the protection; without them, no higher tier counts. - The status check
contextvalues 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.
administrationis not a workflow permission: full read access goes through an ephemeral GitHub App token withAdministration: Read.- The App must be installed on the repository, with the private key (not
the client secret) and the client-id;
min-points: 100plussoft-fail: falsemakeplumberblocking.
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
- Lab 5: scoring and hardening: turning this governance into a measurable OpenSSF Scorecard result.
- Rulesets and branch protection: the governance module, with the bypass list and the backup of a ruleset.
- GitHub CLI (gh): automating
gh api, listing and updating a ruleset without going back to the web interface.