Manual releases are where good engineering teams quietly bleed time and ship mistakes. Someone forgets to bump the version, the changelog is written from memory three weeks after the fact, a tag points at the wrong commit, and the npm publish runs from a laptop with a personal token. None of that should exist in 2026. The version, the changelog, the git tag, and the publish should all be deterministic functions of your commit history, executed by CI, with nobody touching a keyboard.
This guide builds that pipeline twice: once with semantic-release for single-package repos, and once with Changesets for monorepos where each package versions independently. Both consume Conventional Commits, both produce changelogs, and both publish from CI with provenance. By the end you will know which to reach for and how to recover when a release half-fails.
1. Conventional Commits, enforced at the door
Everything downstream keys off commit message structure. The Conventional Commits spec is a tiny grammar:
<type>(<optional scope>): <description>
<optional body>
<optional footer>
The mapping that drives versioning:
| Commit | Version impact (SemVer) |
|---|---|
fix: ... |
patch (1.4.2 -> 1.4.3) |
feat: ... |
minor (1.4.2 -> 1.5.0) |
feat!: ... or a BREAKING CHANGE: footer |
major (1.4.2 -> 2.0.0) |
chore:, docs:, refactor:, test:, ci: |
no release by default |
Do not trust humans to follow this voluntarily. Enforce it with commitlint and a Husky hook so a malformed message never reaches the remote.
npm install --save-dev @commitlint/cli @commitlint/config-conventional husky
npx husky init
// commitlint.config.js
export default {
extends: ['@commitlint/config-conventional'],
rules: {
// Fail commits whose subject line exceeds 100 chars
'header-max-length': [2, 'always', 100],
// Force a scope so monorepo commits route to the right package
'scope-empty': [2, 'never'],
},
};
Wire the hook. husky init creates a pre-commit file; add the commit-message check:
echo 'npx --no-install commitlint --edit "$1"' > .husky/commit-msg
Enforce the same rule in CI on the PR title if you squash-merge, because the squash commit message is the one that lands on trunk.
commitlintreads the title with--from/--toover the PR commit range, or use a dedicated PR-title linter action. The local hook protects authors; the CI check protects trunk.
2. How semantic-release derives the next version
semantic-release does not store the version in package.json and does not read it from there. It treats your git tags as the source of truth. On each run it:
- Finds the last release tag reachable on the current branch (e.g.
v1.4.2). - Parses every commit since that tag with the commit-analyzer.
- Computes the highest version bump implied by those commits.
- Generates notes, writes the changelog, publishes, tags, and (optionally) opens a GitHub release.
If no commit since the last tag warrants a release (all chore/docs), it exits cleanly and does nothing. That idempotence is the whole point: you can run it on every push to main and it self-throttles.
Install the core plus the plugins you need:
npm install --save-dev semantic-release \
@semantic-release/commit-analyzer \
@semantic-release/release-notes-generator \
@semantic-release/changelog \
@semantic-release/npm \
@semantic-release/github \
@semantic-release/git
3. The plugin pipeline
semantic-release is an ordered set of lifecycle hooks (verifyConditions, analyzeCommits, generateNotes, prepare, publish, success). Each plugin opts into the hooks it cares about. Order in the plugins array matters: it defines execution order within each step.
{
"branches": ["main", { "name": "next", "prerelease": true }],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
["@semantic-release/changelog", { "changelogFile": "CHANGELOG.md" }],
"@semantic-release/npm",
["@semantic-release/github", {
"successComment": false,
"failComment": false
}],
["@semantic-release/git", {
"assets": ["CHANGELOG.md", "package.json", "package-lock.json"],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}]
]
}
Save this as .releaserc.json (or a release key in package.json). What each plugin does:
- commit-analyzer runs in
analyzeCommitsand returns the release type (major/minor/patch/null). - release-notes-generator runs in
generateNotesand turns commits into formatted markdown grouped by type. - changelog runs in
prepareand prepends those notes toCHANGELOG.md. - npm runs in
prepare(writes the version intopackage.json) andpublish(runsnpm publish). - github runs in
publishto create the GitHub Release and attach the notes. - git runs in
prepareto commit the updated changelog and manifest back, then the tag is created.
The [skip ci] in the git commit message is load-bearing: without it, the release commit re-triggers your CI pipeline and you risk an infinite loop. Most CI providers honor [skip ci] in the commit message.
Tuning the analyzer
You can extend which commit types trigger a release. For example, treat perf: as a patch and a custom revert as a patch:
["@semantic-release/commit-analyzer", {
"preset": "conventionalcommits",
"releaseRules": [
{ "type": "perf", "release": "patch" },
{ "type": "refactor", "scope": "core", "release": "patch" }
]
}]
4. Wiring it into CI with provenance and protected credentials
The non-negotiables: the publish runs in CI, never locally; the npm token is a CI-only automation token (or, better, OIDC trusted publishing); and the artifact carries provenance.
npm provenance (--provenance, surfaced by @semantic-release/npm via NPM_CONFIG_PROVENANCE=true) makes the registry generate a signed attestation linking the published tarball to the exact GitHub Actions run and commit that built it. It requires a public package and id-token: write permission.
# .github/workflows/release.yml
name: release
on:
push:
branches: [main, next]
permissions:
contents: read # least privilege at the top
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write # push the changelog commit + tag
issues: write # semantic-release comments on resolved issues
pull-requests: write
id-token: write # npm provenance + OIDC
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # semantic-release needs full history + tags
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: https://registry.npmjs.org
- run: npm ci
- run: npm audit signatures # verify dependency provenance pre-publish
- run: npx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: true
Two things people get wrong here:
fetch-depth: 0. A shallow clone hides tags and history, so the analyzer thinks every commit is new and tries to publish the entire backlog as one release.- The
GITHUB_TOKENfromsecretsis the built-in token. It cannot trigger downstream workflows, which is actually what you want given the[skip ci]strategy. If you genuinely need the release to trigger another workflow, use a GitHub App token instead, not a PAT.
If your package is public, prefer npm trusted publishing (OIDC) over a stored NPM_TOKEN entirely: configure the GitHub repo as a trusted publisher in the npm package settings, and the id-token: write permission lets npm exchange the OIDC token for publish rights with no secret stored anywhere.
5. Pre-releases, channels, and maintenance branches
semantic-release maps git branches to distribution channels and release types. This is its most underused capability and it directly models a real branching strategy.
{
"branches": [
"+([0-9])?(.{+([0-9]),x}).x",
"main",
{ "name": "next", "channel": "next", "prerelease": true },
{ "name": "beta", "channel": "beta", "prerelease": true }
]
}
Decoding this:
mainis the default channel; publishing here updates the npmlatestdist-tag.nextandbetapublish prereleases like2.0.0-next.1, tagged on npm undernext/betadist-tags. Consumers opt in withnpm install pkg@next.- The regex
+([0-9])?(.{+([0-9]),x}).xmatches maintenance branches like1.xor1.2.x. Pushing afix:to1.xreleases1.4.3to the1.xchannel without disturbing thelatest2.x line. This is how you ship security patches to an old major that customers are pinned to.
The workflow: cut a 1.x branch from the last 1.y.z tag, backport the fix as a fix: commit, push. semantic-release publishes a patched 1.x and tags it so the next backport computes correctly. No manual version math.
6. Independent versioning of monorepo packages with Changesets
semantic-release is single-version by design. For a monorepo where @acme/ui and @acme/api must version and publish independently, reach for Changesets. It inverts the model: instead of inferring intent from commits, contributors declare intent in a small markdown file per change.
npm install --save-dev @changesets/cli
npx changeset init
This creates .changeset/config.json:
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": "@changesets/changelog-github",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}
When a contributor changes a package, they run npx changeset, pick the affected packages, choose the bump level, and write a human summary. That produces a file like:
---
"@acme/ui": minor
"@acme/api": patch
---
Add a `variant` prop to Button and fix the corresponding API serializer.
These changeset files accumulate in .changeset/ and travel with the PR, so the bump intent is reviewed alongside the code. Key config knobs:
fixed: packages that must always bump together (lock-step major).linked: packages that share a version line only when they actually change.updateInternalDependencies: when@acme/apidepends on@acme/ui, bumpinguiautomatically bumpsapi’s dependency range and triggers its own patch.
7. Generating and committing changelogs without polluting trunk
Changesets uses a two-phase model that keeps version churn off your feature branches. changeset version consumes all pending changeset files, applies the bumps to each package.json, regenerates each package’s CHANGELOG.md, and deletes the consumed changeset files. You never run that on a feature branch.
The clean pattern is the Changesets release bot, which opens a dedicated “Version Packages” PR:
# .github/workflows/release.yml
name: release
on:
push:
branches: [main]
concurrency: release-${{ github.ref }}
permissions:
contents: read
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
id-token: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: https://registry.npmjs.org
- run: npm ci
- uses: changesets/action@v1
with:
version: npm run version # runs `changeset version`
publish: npm run release # runs `changeset publish`
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: true
// package.json scripts
{
"scripts": {
"version": "changeset version",
"release": "changeset publish"
}
}
How the loop works:
- PRs merge to
maincarrying changeset files. No version bump happens yet. - The action sees pending changesets and opens or updates a “Version Packages” PR that applies all the bumps and changelogs. Trunk stays clean.
- When you merge that PR, the action runs again, finds no pending changesets but a version change, and runs
changeset publishto push every changed package to npm and create git tags per package.
This separation is the feature: day-to-day merges never carry version noise, and the actual release is a single reviewable PR you merge when ready. changeset publish only publishes packages whose version in package.json is newer than what is on the registry, so it is safe to re-run.
Verify
Prove the pipeline works before you trust it.
# 1. semantic-release: full dry run, no publish, no tag, no commit
npx semantic-release --dry-run
# 2. Confirm the computed next version and notes in the log output,
# e.g. "The next release version is 1.5.0"
# 3. Changesets: see exactly what WOULD be bumped and published
npx changeset status --verbose
# 4. Validate commit linting catches a bad message
echo "broke everything" | npx commitlint # should exit non-zero
# 5. After a real release, confirm the dist-tags and provenance
npm dist-tag ls @acme/ui
npm view @acme/ui --json | jq '.dist' # look for provenance/attestation
For semantic-release specifically, --dry-run skips prepare/publish but still runs analyzeCommits and generateNotes, so the log tells you the exact version and changelog it would produce. Treat a clean dry run on a throwaway branch as your gate before enabling the main trigger.
8. Rollback, re-publishing, and partial failures
Published versions are immutable by policy. npm forbids re-publishing the same version, and npm unpublish is restricted (72-hour window, and blocked entirely if anything depends on it). So “rollback” almost never means deleting; it means rolling forward.
| Failure | What actually happened | Recovery |
|---|---|---|
| Publish failed, no tag, no npm artifact | Pipeline died before publish |
Fix CI, re-run the job. semantic-release is idempotent. |
| npm published, but tag/commit push failed | Partial release (the dangerous one) | Manually create the matching git tag at that commit; never re-publish that version. |
Bad version published to latest |
Broken build shipped to everyone | Publish a fix: immediately for a higher patch; then npm dist-tag add pkg@<good> latest to repoint. |
| Wrong version on a dist-tag | Mistagged prerelease | npm dist-tag rm / add to correct it; no unpublish needed. |
The dist-tag repoint is the fastest real-world recovery: it does not delete the bad version, it just stops new installs from resolving to it.
# Stop the bleeding: point latest back at a known-good version
npm dist-tag add @acme/ui@1.4.2 latest
# Verify
npm dist-tag ls @acme/ui
For the partial-failure case (npm succeeded, git push failed), the critical invariant is that the git tag must end up pointing at the commit that was published. Recreate it deliberately rather than letting the next semantic-release run miscompute against a missing tag:
git tag v1.5.0 <published-commit-sha>
git push origin v1.5.0
Enterprise scenario
A platform team at a fintech ran a 40-package internal monorepo on Changesets, publishing to a private Artifactory-backed npm registry. Their constraint: a regulated SOC 2 / change-management process required that every published artifact be traceable to an approved change ticket, and that no human could publish from a workstation. They had been letting team leads run changeset publish locally with a shared token, which auditors flagged hard.
The failure mode that triggered the redesign: two leads merged version PRs within minutes of each other, both ran changeset publish locally, and the second clobbered the first’s in-flight git tags, leaving three packages published to Artifactory with no corresponding tags. Provenance was unprovable.
The fix had three parts. First, they removed all human publish tokens and moved publishing entirely into a CI job gated by a concurrency: release group so two release runs could never overlap. Second, they kept the changeset summary as the authoritative change record and added a CI check that every changeset file referenced a ticket ID in its summary, failing the PR otherwise. Third, they used the bot’s “Version Packages” PR as the formal approval gate: merging that PR (which required a CODEOWNERS approval from release engineering) was the single auditable action that authorized a publish.
The enforcing piece was a pre-publish guard that refused to publish if the working tree did not match the merged version PR, eliminating the local-publish path for good:
- name: Block ad-hoc publishes
run: |
if [ -n "$(git status --porcelain)" ]; then
echo "::error::Working tree dirty. Publishes must run from the merged Version PR."
exit 1
fi
- name: Verify on release commit
run: |
git log -1 --pretty=%s | grep -q '^chore(release)' \
|| { echo "::error::Not a release commit; refusing to publish."; exit 1; }
Result: zero local publishes, every artifact traceable from npm provenance back through the Version PR to a ticket, and the concurrency lock made the double-publish race structurally impossible. Auditors signed off on the Version PR as the change-control artifact.
Release engineering checklist
Wire it once, verify with dry runs, and releases stop being an event. They become a side effect of merging good commits, fully attributable from the npm artifact back to the line of code that caused the bump.