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, andBREAKING CHANGEa 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 thev1dist-tag.main-v2(or any name) — active branch for v2.x.x, published to thelatestdist-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)mainandmain-v2diverge 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:
| Field | Purpose |
|---|---|
name | The git branch name semantic-release will act on |
range | Restricts releases on this branch to a semver range (e.g. 1.x.x) |
channel | The npm dist-tag to publish under (latest, v1, next, etc.) |
Note on
rangefor the new major branch: Omittingrangeonmain-v2lets 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 main3. Create and push the new major branch
git checkout -b main-v2
git push origin main-v2At 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.0This 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-v2Then 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: 0is critical. Without it,actions/checkoutdoes 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@v1Common Pitfalls
| Problem | Cause | Fix |
|---|---|---|
| Semantic release publishes the wrong version on the new branch | Missing initial tag | Push v2.0.0 tag before the first release |
| ”No commits found since last release” error | Shallow clone in CI | Add fetch-depth: 0 to actions/checkout |
| Release runs but publishes to wrong dist-tag | Wrong branch selected in workflow dispatch | Double-check which branch you triggered the workflow on |
| Both branches bump the same version | range not set on maintenance branch | Add "range": "1.x.x" to the old branch config |
[skip ci] in release commit causes infinite loops | Missing [skip ci] in git plugin message | Ensure the @semantic-release/git message includes [skip ci] |
Adding More Major Versions
The pattern scales linearly. To add v3:
- Add a new entry to the
branchesarray on both existing branches:and demote{ "name": "main-v3", "channel": "latest" }main-v2to a range:{ "name": "main-v2", "range": "2.x.x", "channel": "v2" } - Push the config change to all active branches.
- Create
main-v3from the current tip ofmain-v2. - Tag
v3.0.0onmain-v3and push. - Trigger the workflow on
main-v3.