Skip to Content
FrontendStructureManaging Multiple Major Versions with Semantic Release

Managing Multiple Major Versions with Semantic Release

Introduction

Semantic-Release lets us release workable versions to our users frequently, driven entirely by our commit history. It relies on three main components:

  • Git hook: husky  triggers commit message validation.
  • Commitlint: lints commit messages against the Conventional Commits  standard.
  • Semantic-release: reads the conventional commit history to generate release notes and bump the version automatically — fix: triggers a patch, feat: a minor bump, and BREAKING CHANGE a major bump.

See Semantic releases for the full single-branch setup (Git hook, Commitlint config, package.json plugins, and the GitHub Actions workflow) before following the multi-version strategy below.

This guide explains how to maintain parallel major versions of an npm package on separate branches using semantic-release . Each branch publishes independently to a separate npm dist-tag, so consumers can pin to a major version or always get the latest.

The strategy is:

  • main — maintenance branch for v1.x.x, published to the v1 dist-tag.
  • main-v2 (or any name) — active branch for v2.x.x, published to the latest dist-tag.

Both branches share the same release pipeline. Semantic release determines which version range to publish based on the branch configuration.


Branch Strategy

main ──●──●──●──●──●── (v1.x.x → dist-tag: v1) └── main-v2 ──●──●──●──●── (v2.x.x → dist-tag: latest)
  • main and main-v2 diverge at the point you decide to start a new major version.
  • Fixes and features are committed independently to each branch.
  • Semantic release on each branch only bumps within its configured range.

Step-by-Step Setup

1. Configure both branches in package.json (or .releaserc)

Do this on main first, before creating the new branch.

"release": { "branches": [ { "name": "main", "range": "1.x.x", "channel": "v1" }, { "name": "main-v2", "channel": "latest" } ], "plugins": [...] }

Key fields:

FieldPurpose
nameThe git branch name semantic-release will act on
rangeRestricts releases on this branch to a semver range (e.g. 1.x.x)
channelThe npm dist-tag to publish under (latest, v1, next, etc.)

Note on range for the new major branch: Omitting range on main-v2 lets semantic release freely bump from where the tag starts (v2.0.0). Adding "range": "2.x.x" is theoretically valid but may cause issues depending on the semantic-release version — omitting it is the safer choice when starting fresh.

2. Commit and push the configuration to main

git add package.json git commit -m "chore: configure multi-branch semantic release for v1 and v2" git push origin main

3. Create and push the new major branch

git checkout -b main-v2 git push origin main-v2

At this point both branches exist but share the same commit history up to the divergence point.

4. Tag the starting version on the new branch

Semantic release uses git tags to determine the current version. You must manually create the initial v2 tag so it knows where to start.

git tag v2.0.0 git push origin v2.0.0

This tag must be pushed to the remote. Without it, semantic release has no baseline and will fail or produce unexpected versions.

5. Make a commit on main-v2 and trigger a release

Any conventional commit will do:

# example: add a feature git commit --allow-empty -m "feat: initial v2 release setup" git push origin main-v2

Then trigger the release workflow on main-v2. Semantic release will detect the v2.0.0 tag as the baseline and publish v2.1.0 (or v2.0.1 for a fix, etc.) to the latest dist-tag.

6. Releases on main continue as v1.x.x

Commits on main and triggering the release workflow there will publish within the 1.x.x range to the v1 dist-tag, independently of v2.


GitHub Actions Workflow

The workflow is triggered manually (workflow_dispatch) so you can control when each branch publishes.

name: Publish the package on: [workflow_dispatch] jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # required — semantic release needs full git history - name: Setup Node uses: actions/setup-node@v4 with: node-version: 22 - name: Install packages run: npm install - name: Build the package run: npm run build - name: Publish using semantic release env: NPM_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: CI=true npx semantic-release

fetch-depth: 0 is critical. Without it, actions/checkout does a shallow clone and semantic release cannot walk the tag history to determine the previous version.

To publish a release, navigate to Actions → Publish the package → Run workflow, then select the branch (main or main-v2).


Full release Configuration Example For NPM Bundles

"release": { "branches": [ { "name": "main", "range": "1.x.x", "channel": "v1" }, { "name": "main-v2", "channel": "latest" } ], "plugins": [ [ "@semantic-release/commit-analyzer", { "releaseRules": [ { "type": "feat", "release": "minor" }, { "type": "fix", "release": "patch" }, { "type": "perf", "release": "patch" }, { "type": "revert", "release": "patch" }, { "breaking": true, "release": false } ] } ], "@semantic-release/release-notes-generator", "@semantic-release/changelog", [ "@semantic-release/npm", { "pkgRoot": "./" } ], [ "@semantic-release/git", { "assets": ["package.json", "CHANGELOG.md"], "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" } ], "@semantic-release/github" ] }

Note on "breaking": true, "release": false: this suppresses automatic major bumps from BREAKING CHANGE commits. Major version boundaries are managed manually by branching and tagging, not by commit convention.


Installing Specific Versions

Consumers can install either major version by dist-tag:

# always latest (v2.x.x) npm install your-package # or explicitly npm install your-package@latest # pin to v1.x.x npm install your-package@v1

Common Pitfalls

ProblemCauseFix
Semantic release publishes the wrong version on the new branchMissing initial tagPush v2.0.0 tag before the first release
”No commits found since last release” errorShallow clone in CIAdd fetch-depth: 0 to actions/checkout
Release runs but publishes to wrong dist-tagWrong branch selected in workflow dispatchDouble-check which branch you triggered the workflow on
Both branches bump the same versionrange not set on maintenance branchAdd "range": "1.x.x" to the old branch config
[skip ci] in release commit causes infinite loopsMissing [skip ci] in git plugin messageEnsure the @semantic-release/git message includes [skip ci]

Adding More Major Versions

The pattern scales linearly. To add v3:

  1. Add a new entry to the branches array on both existing branches:
    { "name": "main-v3", "channel": "latest" }
    and demote main-v2 to a range:
    { "name": "main-v2", "range": "2.x.x", "channel": "v2" }
  2. Push the config change to all active branches.
  3. Create main-v3 from the current tip of main-v2.
  4. Tag v3.0.0 on main-v3 and push.
  5. Trigger the workflow on main-v3.
Last updated on