Exploring branch-based release policy, how staging produces candidate versions ending in rc while main produces stable versions, and writing branch conditions in GitHub Actions so the pipeline only runs on the right tracks.

In episode 10 we verified versions with a dry run on both branches. Now it's time to lock the policy: a release may only happen on the right branch, with the right version too. The most expensive mistake in the release world isn't a wrong version — it's a release running on the wrong branch, poisoning the registry with a version that shouldn't exist.
This episode covers branch-based release conditions: the release policy per branch, how to write branch conditions in GitHub Actions, and how to pair them with the branches configuration in semantic-release.
| Branch | Release type | Example version |
|---|---|---|
main | stable | v1.3.0 |
staging | rc prerelease | v1.3.0-rc.1 |
rc | rc prerelease | v1.3.0-rc.1 |
| others | not released | none |
This division makes sure every branch has a clear meaning: main only accepts stable versions, prerelease branches only accept candidates. The policy is implemented in two places at once — the semantic-release configuration and the workflow conditions — as layered defense.
module.exports = {
branches: [
"main",
{ name: "staging", prerelease: "rc", channel: "rc" },
{ name: "rc", prerelease: "rc", channel: "rc" },
],
tagFormat: "v${version}",
};The main branch without prerelease produces a full version. The staging and rc branches use prerelease: "rc" so every release gets an rc suffix with a running number: the first commit 1.3.0-rc.1, the next 1.3.0-rc.2, and so on. When staging is merged to main, the stable version is computed from commits since the last stable tag.
In GitHub Actions, every job has access to the github context. For push events, github.ref holds the full branch reference like refs/heads/main, while github.ref_name holds only the branch name like main.
name: Release
on:
push:
branches:
- main
- staging
- rc
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- name: Semantic Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npx semantic-release
- name: Deploy to pre-production
if: github.ref == 'refs/heads/staging' || github.ref == 'refs/heads/rc'
run: npm run deploy:staging
- name: Deploy to production
if: github.ref == 'refs/heads/main'
run: npm run deploy:prodThe workflow is only triggered by pushes to the three branches listed in the on block, so no other branch ever triggers a release. Inside the job, the if conditions make sure each deploy step runs on the right branch. The release itself is just one npx semantic-release command — its version result is already determined by the branch configuration.
Warning
Watch the refs/heads/ prefix. github.ref returns the full
reference, so if: github.ref == 'main' will never be true and the step
will always be skipped. Use refs/heads/main, or compare against the
shorter github.ref_name == 'main'.
Duplicate releases often happen when a push and a merge land almost at the same time. Add concurrency so an old run is cancelled and the newest run proceeds:
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: falsecancel-in-progress: false keeps a release from being cancelled mid-way — which must never happen because it could leave a tag without a package or vice versa. The next run waits for the active run to finish first.
Tip
Don't put release logic on more than one workflow. One workflow file
that reads the branch, combined with the branches configuration,
keeps a single source of truth. A second workflow that also releases
will only cause versions to collide.
If you want to separate the stable release job, use a condition at the job level:
release-stable:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- run: npm ci
- name: Semantic Release
run: npx semantic-releaseThe same pattern applies to staging: just change the comparison to refs/heads/staging or refs/heads/rc. Jobs whose condition isn't met are marked "skipped" and don't affect the pipeline status.
| Mistake | Symptom | Solution |
|---|---|---|
Comparing github.ref with main | Condition never true | Use refs/heads/main or github.ref_name |
| Branch not registered in config | No release on push | Register the branch in branches |
| Release commit triggers a loop | Release keeps repeating | Include [skip ci] in the release commit |
| Two active release workflows | Versions collide | One workflow plus concurrency |
cancel-in-progress: true | Tag created without publish | Set false for release jobs |
In episode 11 you:
main stable, prerelease branches ending in rc.github.ref (full reference) and github.ref_name (branch name).on block and used if for per-branch deploy steps.concurrency and cancel-in-progress: false.In episode 12 we'll secure everything with GitHub Actions Security Best Practices — least privilege, branch restrictions, and secret protection. See you in episode 12!