Securing the release pipeline with branch protection rules on main and rc, requiring lint build and dry run status checks, applying mandatory reviewers, and choosing a merge strategy that keeps the Conventional Commits history clean.

In episode 12 we discussed workflow security, from least privilege to secret protection. Now consider a reality: once releases run automatically, one bad commit on main immediately becomes a released production version. This is where branch protection becomes the last line of defense before code touches a release.
Branch protection rules are a gate that forces every change through a PR, status checks, and review before it lands on important branches. For a semantic-release pipeline, this isn't just good practice — it's an absolute requirement so automation doesn't backfire.
The two most sensitive branches in our repository:
main — the source of stable releases; every merge here can produce a new 1.2.3 version.staging (aka rc) — the source of release candidate 1.2.3-rc.N tested in the staging environment.Because both branches trigger automatic releases, they must be governed by the strictest rules. feature/* branches can be looser — a green pipeline is enough — but main and rc must pass every gate.
Note
A branch protection rule works based on a branch name pattern, not a
single branch. You can create rules for main, staging, and feature/*
with different levels of strictness, ordered from the most to the least
important.
The steps go through the GitHub interface:
main.staging pattern.For teams that want to apply the same rules across repositories, GitHub also offers rulesets (Settings → Rules → Rulesets) that can be managed centrally and support code-based configuration.
Status checks on GitHub come from the job names in the CI workflow that runs on pull requests. Those are the names you check off in the required status checks list:
name: CI
on:
pull_request:
branches: [main, staging]
permissions:
contents: read
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bun run lint
build:
name: Build
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bun run build
dry-run:
name: Dry Run
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: npx semantic-release --dry-run --no-ciIn the branch protection form, check Lint, Build, and Dry Run as required checks. The presence of the Dry Run check is crucial: it validates that the commits in that PR would produce the correct version, a valid changelog, and no configuration errors — without actually releasing anything. You can try the same command locally with npx semantic-release --dry-run --no-ci.
Warning
Status check names must match the exact job names in the workflow. If the workflow is renamed but branch protection still uses the old names, PRs will be permanently locked until you update the required checks list. Always verify after renaming a job.
Besides the automatic pipeline, human review stays mandatory. Set up:
CODEOWNERS file exists, changes to certain parts automatically assign reviewers.Branch protection can also be applied reproducibly with the GitHub CLI using a JSON file as input:
{
"required_status_checks": {
"strict": true,
"contexts": ["Lint", "Build", "Dry Run"]
},
"required_pull_request_reviews": {
"required_approving_review_count": 1
},
"enforce_admins": true
}This approach makes security configuration reviewable like ordinary code and reusable across other repositories.
How a PR is merged determines the shape of the history semantic-release reads. The three main options on GitHub's merge button:
| Strategy | History shape | Impact on semantic-release |
|---|---|---|
| Merge commit | A combined "Merge pull request #12" commit plus the original commits | Untyped merge commits are safe, but history gets noisy |
| Squash and merge | One new commit using the PR title | Original commits are summarized; the version only bumps if the squash message is valid |
| Rebase and merge | Original commits rewritten without a merge commit | Preserves per-commit detail; all messages must follow the spec |
Warning
If your team uses squash and merge, make sure the PR title follows
Conventional Commits, e.g. feat(api): add export endpoint. The squash
title is the only commit the commit analyzer examines — if you write
Update branch as the title, a release will never ship even though the
feature has landed.
Conversely, merge commit preserves the original per-commit messages so the analysis is more accurate, but it carries a risk: if the commits inside aren't consistent, the version can bump more than once for a single PR. Many teams combine squash-merge with a "PR title must be valid" rule to get both clean history and predictable analysis.
To enforce this policy at the repository level, configure Settings → Allow merge commits / Allow squash merging / Allow rebase merging and turn off the options you don't want.
Episode 13 recap:
main and staging (rc) with strict branch protection rules.With strong gates, every release that ships is tested in two layers: automatic and human. In the next episode we'll discuss Audit, Changelog & Release Notes — how semantic-release writes the changelog automatically and links it to GitHub Releases. See you there!