Guide + free tool

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

  1. .releaserc.json

    The 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"
      ]
    }
    
  2. .github/workflows/release.yml

    Runs 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@9

Then .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-release
  • fetch-depth: 0 is there because semantic-release finds the last release by reading git tags, so it needs the history and the tags.
  • There’s no registry-url on setup-node, on purpose. The docs say it makes setup-node write an .npmrc that conflicts with semantic-release’s own npm auth, and you get EINVALIDNPMTOKEN with 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: write creates the GitHub release. issues and pull-requests: write let the GitHub plugin comment on everything that shipped. id-token: write is for npm trusted publishing, where npm trusts the workflow through OIDC and you keep no NPM_TOKEN secret at all. When you register the trusted publisher on npmjs, name the workflow file that triggers the release.
  • The built-in GITHUB_TOKEN works, 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.

PluginStepsWhat it does
@semantic-release/commit-analyzeranalyzeCommitsReads each commit since the last tag and decides patch, minor, major or nothing.
@semantic-release/release-notes-generatorgenerateNotesTurns the same commits into markdown release notes.
@semantic-release/npmverifyConditions, prepare, publish, addChannelChecks npm auth, writes the version into package.json, publishes the package.
@semantic-release/githubverifyConditions, publish, addChannel, success, failCreates 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.0

Switch 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:

CommitDefault (Angular)conventionalcommits
feat!: drop node 18no releasemajor
chore!: drop node 18no releasemajor
feat: ... with a BREAKING CHANGE: footermajormajor
feat: ... with a BREAKING-CHANGE: footerminormajor
feat: ... with a lower-case breaking change: footermajormajor
feat: add frog searchminorminor
fix: stop the double submitpatchpatch
perf: cache pond lookupspatchpatch
revert: undo frog searchno releaseno release
Feat: add frog searchno releaseno release
feat:add frog searchno releaseno 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 changelog

The 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 ENOGHTOKEN and ENONPMTOKEN and 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 .npmrc files 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 with EINVALIDNPMTOKEN while NPM_TOKEN wasn’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.

Semantic release: GitHub Actions setup, changelog, monorepos