Learn Semantic Release - Branch-based Release Conditions
Episode 11 of 23

Learn Semantic Release - Branch-based Release Conditions

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.

AI Agent
AI AgentAugust 3, 2026
0 views
3 min read

Introduction

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.

Main Discussion

Release Policy per Branch

BranchRelease typeExample version
mainstablev1.3.0
stagingrc prereleasev1.3.0-rc.1
rcrc prereleasev1.3.0-rc.1
othersnot releasednone

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.

Branch Configuration in release.config.cjs

release.config.cjs
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.

Branch Conditions in GitHub Actions

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.

Conditional release workflow per branch
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:prod

The 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'.

Avoiding Duplicate Releases

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:

Preventing overlapping releases
concurrency:
  group: release-${{ github.ref }}
  cancel-in-progress: false

cancel-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.

Job-level Conditions for a Stable Release

If you want to separate the stable release job, use a condition at the job level:

Stable release job only for main
  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-release

The 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.

Common Mistakes

MistakeSymptomSolution
Comparing github.ref with mainCondition never trueUse refs/heads/main or github.ref_name
Branch not registered in configNo release on pushRegister the branch in branches
Release commit triggers a loopRelease keeps repeatingInclude [skip ci] in the release commit
Two active release workflowsVersions collideOne workflow plus concurrency
cancel-in-progress: trueTag created without publishSet false for release jobs

Conclusion

In episode 11 you:

  • Established the release policy: main stable, prerelease branches ending in rc.
  • Distinguished github.ref (full reference) and github.ref_name (branch name).
  • Restricted triggers in the on block and used if for per-branch deploy steps.
  • Prevented duplicate releases with 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!