Managing a multi-branch release strategy with the rc branch for release candidates and main for stable, understanding SemVer precedence on the rc suffix, and putting together a release.config.cjs with several branch entries at once.

In episode 14 the automatic changelog was running. The next question: how does a team preparing a big release still ship small fixes to production? The answer is multi-branch release — one repository, several release channels with different versions.
This episode covers the difference between prerelease, release candidate, and stable release; how the -rc.N suffix is ordered per SemVer; and the release.config.cjs and GitHub Actions configuration for the main plus staging branches.
| Term | Version format | Purpose |
|---|---|---|
| Prerelease | 1.4.0-alpha.1, 1.4.0-beta.1 | Internal testing, features not yet complete |
| Release candidate | 1.4.0-rc.1 | Features complete, awaiting final verification |
| Stable release | 1.4.0 | Official release for users |
Order of maturity: alpha is the rawest, beta is more mature, rc is ready to ship. Each can live on a separate branch and be published to a different channel.
SemVer orders versions not alphabetically, but by the MAJOR.MINOR.PATCH components followed by prerelease. Watch this pattern:
1.4.0-rc.1 < 1.4.0-rc.2 < 1.4.0-rc.3 < 1.4.0The larger the number after rc., the higher the precedence — but always below the same stable version. That means 1.4.0-rc.9 is still lower than 1.4.0. This is what lets automatic version comparison (e.g. npm install) know that rc is not a substitute for stable.
Additional rules: build metadata like +build.5 doesn't affect precedence. And when two prereleases are compared, the order follows the identifier order: alpha < beta < rc.
Semantic-release reads the branch list from the branches key. Each entry can be a simple string or an object with channel and prerelease options:
module.exports = {
branches: [
{ name: 'main' },
{ name: 'staging', channel: 'rc', prerelease: 'rc' },
{ name: 'beta', prerelease: 'beta' },
],
tagFormat: 'v${version}',
plugins: [
'@semantic-release/commit-analyzer',
'@semantic-release/release-notes-generator',
'@semantic-release/npm',
'@semantic-release/github',
],
};What happens behind the scenes:
main → release 1.4.0 (stable).staging → release 1.4.0-rc.1, then -rc.2, and so on.beta → release 1.4.0-beta.1 on the beta channel.channel determines the dist-tag when publishing to npm and the GitHub Release target branch.tagFormat value produces tags like v1.4.0-rc.1.When staging is finally merged to main, semantic-release recomputes from the highest tag and produces the stable 1.4.0. Always verify new configuration with npx semantic-release --dry-run --no-ci before letting the workflow run automatically.
Warning
There can only be one branch per prerelease value. If two branches
both use prerelease: 'rc', they'll fight over the rc.N numbers and the
numbers can overwrite each other. For several parallel releases, use
different prerelease names like alpha, beta, and rc.
The release workflow simply needs to be triggered by pushes to both branches; semantic-release itself decides the release type based on the active branch:
name: Release
on:
push:
branches: [main, staging]
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: npx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}No extra conditions are needed to choose the release type — the branches configuration in release.config.cjs is already the single source of truth. If explicit filtering is ever needed, the workflow could compare github.ref against refs/heads/main or refs/heads/staging, but for this case study let semantic-release handle it.
Tip
When two release branches run side by side, watch out for overlapping
tags. Make sure the promotion flow is sequential: features are merged to
staging, verified, then merged to main. Parallel releases from two
branches with the same version base produce commit history that's
complicated to order.
Episode 15 recap:
1.4.0-rc.N is always lower than 1.4.0, and rc.1 < rc.2 < ....branches in release.config.cjs supports multiple entries with channel and prerelease.prerelease value so numbers don't collide.main and staging; the release type is decided by configuration.Multi-branch gives a team two release tracks at once. In episode 16 we'll touch Custom Release Plugins & Extensions — writing your own semantic-release plugin for organizational needs, using @semantic-release/exec, and integrating artifact repositories or private registries. See you there!