Semantic release, set up so feat! actually releases
semantic-release reads your commits on every push and publishes whatever they add up to. No release PR, no button. Out of the box it also ignores feat!, which I only believed after watching it happen on a test repo. Below is a config that doesn’t, and a generator that writes it for your branch.
Definition
What semantic-release does
semantic-release is a Node tool that runs in CI on every push to your release branch, and it’s for projects where each merged fix or feature should ship without anyone deciding when. It finds the last version from your git tags, reads every commit since, and works out the next semantic version from the messages. That only works if the commits follow conventional commits. fix is a patch, feat is a minor, a breaking change is a major. Then, in the same run, it writes release notes, tags the commit, publishes to npm and creates a GitHub release. There’s no release PR to review. If everything since the last tag is chore, docs or ci, it logs that there are no relevant changes and exits 0. I tested version 25.0.9. The old gitbook docs site now carries a notice saying it’s discontinued, and the current docs live at semantic-release.org.
Free tool
Generate the config
Set your release branch and whether you publish to npm, and you get the two files below. The config already switches both commit plugins to the conventionalcommits preset, for reasons the next few sections spend a while on. The preset package itself isn’t in there, so read the note on .releaserc.json about installing it at major 9.
Release config generator
.releaserc.jsonThe preset is its own package, and it has to be major 9: npm i -D conventional-changelog-conventionalcommits@9. Version 10 crashes the release notes step.
{ "branches": [ "main" ], "plugins": [ [ "@semantic-release/commit-analyzer", { "preset": "conventionalcommits" } ], [ "@semantic-release/release-notes-generator", { "preset": "conventionalcommits" } ], [ "@semantic-release/npm", { "npmPublish": false } ], "@semantic-release/github" ] }.github/workflows/release.ymlRuns semantic-release on every push to the branch. No commit that warrants a release, no release.
name: Release on: push: branches: - main permissions: contents: read # for checkout jobs: release: name: Release runs-on: ubuntu-latest permissions: contents: write # to publish a GitHub release issues: write # to comment on released issues pull-requests: write # to comment on released pull requests id-token: write # for npm trusted publishing and provenance steps: - name: Checkout uses: actions/checkout@v7 with: fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v7 with: node-version: "lts/*" - name: Install dependencies run: npm clean-install - name: Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: npx semantic-release
Checked on 2026-09-23: each tool loaded its generated config and cut a test release from it, and every file this form can produce parses. If an action has shipped a newer major since, bump the number after the @.
Setup
The minimum setup
Install semantic-release and the preset as dev dependencies.
npm install --save-dev semantic-release conventional-changelog-conventionalcommits@9Then .releaserc.json at the repo root. This is what the generator writes for main with npm publishing off.
{
"branches": ["main"],
"plugins": [
["@semantic-release/commit-analyzer", { "preset": "conventionalcommits" }],
["@semantic-release/release-notes-generator", { "preset": "conventionalcommits" }],
["@semantic-release/npm", { "npmPublish": false }],
"@semantic-release/github"
]
}With npmPublish set to false, the npm plugin still writes the new version into package.json during the run. It just doesn’t publish, and it stops asking for an npm token. Make it a plain "@semantic-release/npm" and it publishes, unless your package.json is marked private. The same settings also work as a release key in package.json or a release.config.js. Pick one place.
The workflow is the GitHub Actions recipe from the semantic-release docs with the action versions bumped. The docs’ version also runs npm audit signatures before releasing, to check the provenance of your installed dependencies. Add it back if you want it.
name: Release
on:
push:
branches:
- main
permissions:
contents: read # for checkout
jobs:
release:
name: Release
runs-on: ubuntu-latest
permissions:
contents: write # to publish a GitHub release
issues: write # to comment on released issues
pull-requests: write # to comment on released pull requests
id-token: write # for npm trusted publishing and provenance
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: "lts/*"
- name: Install dependencies
run: npm clean-install
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npx semantic-releasefetch-depth: 0is there because semantic-release finds the last release by reading git tags, so it needs the history and the tags.- There’s no
registry-urlon setup-node, on purpose. The docs say it makes setup-node write an.npmrcthat conflicts with semantic-release’s own npm auth, and you getEINVALIDNPMTOKENwith a token that’s fine. Other release workflows do set it, the release-please one included, so this is the line that breaks when people copy between them. contents: writecreates the GitHub release.issuesandpull-requests: writelet the GitHub plugin comment on everything that shipped.id-token: writeis for npm trusted publishing, where npm trusts the workflow through OIDC and you keep noNPM_TOKENsecret at all. When you register the trusted publisher on npmjs, name the workflow file that triggers the release.- The built-in
GITHUB_TOKENworks, but the GitHub plugin’s README warns that releases made with it won’t start other workflows. If something listens for new releases, it needs a separate token.
One more thing from the configuration docs. Once this is live, anyone who can push to main can publish a release, so protect the branch. And if you already published versions by hand, tag the last released commit as v1.1.0 or whatever it was before the first run. Without a tag, semantic-release starts from nothing, and its first release is always 1.0.0. Mine went straight to 1.0.0 from a package.json that said 0.0.0-development. The field played no part.
What runs
The four default plugins
With no plugins key, semantic-release loads four. I ran it on a repo with no config at all, and these are the ones it loaded, with the steps each one hooks into.
| Plugin | Steps | What it does |
|---|---|---|
@semantic-release/commit-analyzer | analyzeCommits | Reads each commit since the last tag and decides patch, minor, major or nothing. |
@semantic-release/release-notes-generator | generateNotes | Turns the same commits into markdown release notes. |
@semantic-release/npm | verifyConditions, prepare, publish, addChannel | Checks npm auth, writes the version into package.json, publishes the package. |
@semantic-release/github | verifyConditions, publish, addChannel, success, fail | Creates the GitHub release, comments on the issues and PRs it resolved, opens an issue when a release fails. |
Plugins run in series, in the order you list them, for each step they implement. Setting plugins replaces the whole list, which is why the config above names all four. The npm, GitHub and release notes plugins already come with semantic-release, and their READMEs say not to add them as your own dependencies, since that can conflict when you update.
The one that bites
The default preset ignores feat!
This is the part that made me write the page. The README says semantic-release uses the Angular commit convention by default, and the Angular preset’s parser has no idea what ! means. So feat!: drop node 18 doesn’t get a major bump. It doesn’t parse at all, and nothing is released. I tagged a test repo at v1.0.0, committed exactly that, and ran a dry run with the default analyzer.
Found git tag v1.0.0 associated with version 1.0.0 on branch main
Found 2 commits since last release
Analyzing commit: feat!: drop node 18
The commit should not trigger a release
Analyzing commit: ci: semantic-release with default analyzer
The commit should not trigger a release
Analysis of 2 commits complete: no release
There are no relevant changes, so no new version is released.Exit code 0. No error and no warning. If that commit was the only thing you merged this week, CI goes green and nothing ships. Here’s the same repo with both plugins switched to preset: "conventionalcommits".
Analyzing commit: feat!: drop node 18
The release type for the commit is major
Analysis of 3 commits complete: major release
The next release version is 2.0.0Switch both plugins, not just the analyzer, or your notes get parsed by a different preset from your version number. And the preset is a separate package. A clean install of semantic-release only brings the Angular one, which is why the install line above adds it. Here’s what each preset made of a handful of commits when I fed them to the analyzer one at a time:
| Commit | Default (Angular) | conventionalcommits |
|---|---|---|
feat!: drop node 18 | no release | major |
chore!: drop node 18 | no release | major |
feat: ... with a BREAKING CHANGE: footer | major | major |
feat: ... with a BREAKING-CHANGE: footer | minor | major |
feat: ... with a lower-case breaking change: footer | major | major |
feat: add frog search | minor | minor |
fix: stop the double submit | patch | patch |
perf: cache pond lookups | patch | patch |
revert: undo frog search | no release | no release |
Feat: add frog search | no release | no release |
feat:add frog search | no release | no release |
Neither preset releases anything for a capitalised type, a missing space, or revert: on its own. The cheat sheet has the format on one screen if your team needs it.
The file
Writing CHANGELOG.md back to the repo
Out of the box, semantic-release doesn’t write a changelog file. The notes go into the GitHub release and that’s it. The version the npm plugin writes into package.json doesn’t get committed either. That’s deliberate. The docs’ GitHub Actions page describes committing package.json changes as going against their recommendation.
If you want the file anyway, it takes two more plugins. @semantic-release/changelog writes the notes into CHANGELOG.md during prepare, and @semantic-release/git commits it back.
npm install --save-dev @semantic-release/changelog @semantic-release/git{
"branches": ["main"],
"plugins": [
["@semantic-release/commit-analyzer", { "preset": "conventionalcommits" }],
["@semantic-release/release-notes-generator", { "preset": "conventionalcommits" }],
"@semantic-release/changelog",
["@semantic-release/npm", { "npmPublish": false }],
"@semantic-release/git"
]
}Order matters. Both plugins’ READMEs say changelog comes before npm and git, and npm before git, so the file and the new version exist by the time git commits. I left the GitHub plugin out only because my test remote wasn’t on GitHub. Put it back on a real repo. Then I ran it for real against a local bare remote, and it committed and tagged:
$ git log --oneline -3
7b534fd chore(release): 1.0.0 [skip ci]
d4a7b58 feat: add frog search
ea59345 ci: semantic-release writes the changelogThe release commit holds CHANGELOG.md and package.json, now at 1.0.0, and the v1.0.0 tag points at it. [skip ci] is in the default message so the commit doesn’t kick off another build.
The catch is branch protection. The same docs page says the built-in GITHUB_TOKEN can’t push to a protected branch, and it warns against swapping in a personal access token. Secrets are readable from workflows on any branch, so a token with admin rights makes the protection pointless. The git plugin’s README suggests letting the release user bypass the rules instead. That’s real setup work for a file that repeats the GitHub release. If your users already read release notes on GitHub, I’d skip the file.
Whichever way you go, the preset decides what’s in the notes. This is conventionalcommits on seven commits, with a GitHub repo URL:
## [1.3.0](https://github.com/example/frogs/compare/v1.2.0...v1.3.0) (2026-09-23)
### Features
* **search:** find frogs by pond ([abc1230](https://github.com/example/frogs/commit/abc1230))
### Bug Fixes
* stop the double submit ([abc1231](https://github.com/example/frogs/commit/abc1231))
### Performance Improvements
* cache pond lookups ([abc1232](https://github.com/example/frogs/commit/abc1232))
### Reverts
* undo pond colours ([abc1236](https://github.com/example/frogs/commit/abc1236))The docs, chore and refactor commits I fed it are gone. The Angular preset hides the same types, but it lists Bug Fixes above Features and makes each version a top-level heading. And yes, revert: gets a Reverts section in the notes while releasing nothing by itself. If you’re deciding what a good entry looks like, the changelog format guide covers it.
Before you push
Trying it before CI does
Run it on your laptop without flags and it notices it isn’t in CI: This run was not triggered in a known CI environment, running in dry-run mode. Or say so up front.
npx semantic-release --dry-run --no-ci- A dry run still runs verifyConditions. With the default plugins and no tokens in my shell, it stopped at
ENOGHTOKENandENONPMTOKENand never said what the version would be. For a local preview I used a throwaway config with just the analyzer and the notes generator. - It checks it can push before anything else, and logs
Allowed to push to the Git repository. The docs say dry run verifies push permission on purpose, to catch config problems early. - The npm plugin reads more
.npmrcfiles than the one in your project. My test repos sat under my home directory, so it found my user.npmrc, tried a token npm rejected, and failed withEINVALIDNPMTOKENwhileNPM_TOKENwasn’t even set. - The dry-run log ends with
Published release 1.0.0 on default channel. It published nothing. It says that anyway.
Many packages
Semantic release in a monorepo
semantic-release versions one package per repo. Its docs call monorepos “not an officially supported semantic-release setup at this time” and point to community plugins. By default it reads one package.json, and every release is a tag like v1.2.0 with no package name in it, so two packages in one repo would read and write the same tags.
The community wrappers people reach for are semantic-release-monorepo, which describes itself as applying semantic-release’s publishing to a monorepo, and multi-semantic-release, which calls itself a proof of concept and exists in a few forks. I haven’t run either, so that’s all I’ll say about them. If a version per package is the point, the tools built for it are release-please’s manifest mode, which tracks a version per path, and Changesets, which was designed around monorepos from the start.
Troubleshooting
Errors I hit
“ENOGHTOKEN No GitHub token specified.”
The GitHub plugin wants GITHUB_TOKEN or GH_TOKEN in the environment, and it checks during verifyConditions, dry run or not. In Actions, that’s the env block on the Release step. Locally, it’s why the default config can’t preview anything. A remote that isn’t on GitHub adds EINVALIDGITHUBURL on top.
“EINVALIDNPMTOKEN Invalid npm token.”
semantic-release found a token and npm rejected it. The two causes I know of are the registry-url line on setup-node and a stale token in an .npmrc it picked up. With no token anywhere you get ENONPMTOKEN No npm token specified. instead, and that message opens by pointing you at trusted publishing. Either give the job id-token: write and set up trusted publishing, add an NPM_TOKEN secret, or set npmPublish to false if you don’t publish.
“Missing helper”
Error: Missing helper: "conventional-changelog-conventionalcommits requires conventional-changelog-writer@9 or newer (conventional-changelog@8 or newer). Your changelog tooling loaded an older writer which cannot render this preset. Update the tooling or use an older major version of the preset."That’s conventional-changelog-conventionalcommits at major 10 with release-notes-generator 14.1.1. The analyzer was happy and computed 2.0.0, then the notes step died. Version 10 wants a newer changelog writer than the plugin ships. Install @9, which is 9.3.1 as I write this.
“Cannot find module ‘conventional-changelog-conventionalcommits’”
The config asks for the preset and nobody installed it. The analyzer fails first, before anything is released. It’s the install line from the setup section.
“ERELEASEBRANCHES The release branches are invalid”
I got this from a repo whose only branch was trunk. The default branches list looks for main or master on the remote, and the error prints Your configuration for the problematic branches is []. Set branches to the branch you actually release from.
Choosing
Where it fits
semantic-release is the one with no human step. Merge a feat and a minor release goes out on that push. release-please reads the same commits but opens a release PR, and nothing ships until someone merges it. Most people are really choosing whether a person presses the button. Changesets ignores commit messages and has contributors write a small changeset file per change, which suits a monorepo of npm packages. All three bump the version by the same semver rules, and all three need your commits or changesets to be honest about what changed.
Honesty
What this doesn’t fix
semantic-release gets the release out the moment you merge. It doesn’t get anyone to notice. The GitHub release is written for people who already use the thing and went looking, and the npm version bump reaches whoever runs an update. Everyone else finds out you shipped when you tell them, and a line of release notes is the wrong voice for that.
That gap is the one I built Merge & Tell for. It reads each merged pull request, the diff and not just the title, and drafts a post for each network. I read every one before it goes out. The updates page is that running on this product.
The release went out. Nobody got told.
You were going to merge the PR anyway.