Back to blog
GitHub automation9 min readPublished September 11, 2026

How to set up semantic-release: config, plugins and a GitHub Actions workflow

A complete semantic-release setup in one place: the real requirements, a working .releaserc.json, the plugin chain in execution order, and the GitHub Actions workflow with the permissions, fetch depth and token handling that most tutorials get wrong.

What this solves

An engineer wants to stop tagging releases by hand and needs the actual files - config plus CI workflow - that make semantic-release run correctly on a GitHub repository the first time.

How S2P helps

A repository that bumps its own version, writes its own release notes, tags itself and publishes a GitHub release on every qualifying push, plus an honest read on when that setup is not worth its cost.

Key takeaways

  • semantic-release 25 declares support for Node ^22.14.0 or 24.10.0 and above, needs a git CLI of at least 2.7.1, and the project recommends running it with npx in CI rather than installing it as a devDependency.
  • Four plugins ship inside semantic-release - commit-analyzer, release-notes-generator, npm and github - and defining a plugins array replaces that default rather than extending it.
  • The default angular preset releases on feat, fix, perf, reverts and a BREAKING CHANGE footer and ignores every other commit type, which is why most first runs report nothing to release.
  • A GitHub Actions release job needs contents, issues and pull-requests write permission, fetch-depth 0 on checkout, and no registry-url in actions/setup-node.
  • semantic-release automates the version, the tag and the release notes, then stops at the repository boundary.

Section 1

What you need before the first release

Four hard requirements, plus one decision you make before writing any config.

semantic-release is a Node CLI that reads your git history and decides the next version. Its requirements are four: code in a git repository, a CI service where you can set credentials securely, and a git CLI and Node runtime in that CI environment that meet its version requirements. Version 25 declares Node ^22.14.0 or 24.10.0 and above; the support docs put the minimum git CLI at 2.7.1.

The fifth requirement is not on that list but decides whether any of this works: a commit message convention. semantic-release does not guess. It parses commit messages with a conventional-changelog preset and produces no release when nothing it recognizes has landed.

Do not add semantic-release to devDependencies. The docs recommend against it: it is a release dependency rather than a development one, and a local install drags in its own copy of npm and can conflict with tools like commitlint. Run it with npx in CI, pinned to at least a major version. A community action, cycjimmy/semantic-release-action, wraps the same CLI if you prefer an action to a run step.

Run semantic-release from CI

# Base package, pinned to a major version
npx semantic-release@25

# Extra plugins and presets go in the same command, pinned the same way
npx \
  --package semantic-release@25 \
  --package conventional-changelog-conventionalcommits@9 \
  semantic-release

Section 2

The .releaserc.json

One file, one branch, four plugins. The entire configuration for a package published to npm.

Configuration can live in a .releaserc file with an optional .yaml, .yml, .json, .js, .ts, .cjs or .mjs extension, in a release.config.js, .ts, .cjs or .mjs that exports an object, or under a release key in package.json. A .releaserc.json is the least surprising choice: no execution semantics, no import resolution to get wrong.

The plugins array below is identical to the documented default: commit-analyzer, release-notes-generator, npm, github, in that order. Writing it out anyway costs four lines and buys you the next edit, because the array replaces the default rather than extending it. Add a fifth plugin and you must list all five.

The branches option defaults to a list covering master, main, next, next-major, beta, alpha and maintenance branch patterns. Narrowing it to the one branch you release from stops a stray push to a branch named next from cutting a version.

.releaserc.json

{
  "branches": ["main"],
  "plugins": [
    "@semantic-release/commit-analyzer",
    "@semantic-release/release-notes-generator",
    "@semantic-release/npm",
    "@semantic-release/github"
  ]
}

Section 3

The GitHub Actions workflow

The permissions block, the fetch depth and the token decide whether this job works or fails at the last step.

The workflow below follows the project's own GitHub Actions recipe: a verify job that runs your tests, then a release job that runs only if verify passed. Four job-level permissions are needed. contents write publishes the GitHub release, issues write and pull-requests write let the github plugin comment on what shipped, and id-token write enables npm trusted publishing over OIDC, which produces provenance without a long-lived NPM_TOKEN in your secrets.

fetch-depth 0 is not optional. semantic-release finds the previous release by reading git tags, and actions/checkout performs a shallow clone by default. Without the full history the run behaves as though nothing was ever released, and it produces no error while doing it.

The token is the second trap. The automatically populated GITHUB_TOKEN covers the default flow. It does not cover a release that pushes commits back to a protected branch: the recipe states that the automatic GITHUB_TOKEN cannot be used when branch protection is enabled on the target branch, and recommends GitHub App authentication over a personal access token, since a PAT exposed to any workflow run can be reused with elevated permissions.

The third trap is quieter. Do not set registry-url in actions/setup-node. The recipe lists it as a pitfall: it writes an .npmrc that conflicts with the auth semantic-release sets up for itself, and the symptom is EINVALIDNPMTOKEN at publish time. Put a custom registry in your project .npmrc instead.

.github/workflows/release.yml

name: Verify and Release

on:
  push:
    branches:
      - main

permissions:
  contents: read

jobs:
  verify:
    name: Verify
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "lts/*"
      - name: Install dependencies
        run: npm clean-install
      - name: Verify provenance and registry signatures
        run: npm audit signatures
      - name: Run lint and tests
        run: npm test

  release:
    name: Release
    runs-on: ubuntu-latest
    needs: verify
    permissions:
      contents: write
      issues: write
      pull-requests: write
      id-token: write
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "lts/*"
      - name: Install dependencies
        run: npm clean-install
      - name: Release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: npx semantic-release@25

Section 4

The plugin chain, in execution order

Plugins run in series, in the order you list them, at every lifecycle step they implement.

Two entries carry an official warning that most tutorials still ignore. The @semantic-release/git README opens by saying you likely do not need the plugin, and links to the project's recommendation against making commits during a release. @semantic-release/changelog carries the matching warning about committing release notes to a file.

The reason is mechanical rather than ideological. Committing during a release means the release job needs push access to a branch that is usually protected, which is exactly where the automatic GITHUB_TOKEN stops working.

Two lifecycle steps belong to semantic-release itself and no plugin touches them: reading the last release from git tags, and creating the new tag. Everything else is plugin work, which is why the order of this array matters.

semantic-release plugins by execution order

PluginLifecycle hooks implementedWhat it doesDefault or opt-in
@semantic-release/commit-analyzeranalyzeCommitsParses commits with a conventional-changelog preset and returns major, minor, patch or no releaseDefault, bundled with semantic-release
@semantic-release/release-notes-generatorgenerateNotesTurns the same commits into the markdown body of the release notesDefault, bundled with semantic-release
@semantic-release/npmverifyConditions, prepare, publish, addChannelUpdates the version in package.json and publishes the package to the npm registryDefault, bundled with semantic-release
@semantic-release/githubverifyConditions, publish, addChannel, success, failCreates the GitHub release, comments on released issues and pull requests, opens an issue when a release failsDefault, bundled with semantic-release
@semantic-release/changelogverifyConditions, prepareCreates or updates a changelog file from the generated notesOpt-in, and the maintainers advise against it
@semantic-release/gitverifyConditions, prepareCommits release assets such as package.json and the changelog back to the branchOpt-in, and the maintainers advise against it
@semantic-release/execverifyConditions, analyzeCommits, verifyRelease, generateNotes, prepare, publish, success, failRuns a shell command you supply at a given step, which is how non-Node projects build and publishOpt-in
@semantic-release/gitlabverifyConditions, publish, success, failPublishes a GitLab release and comments on merge requests instead of doing the GitHub equivalentOpt-in

Section 5

How a commit becomes a version number

The default preset is angular, not conventionalcommits, and that difference decides whether your commits release anything at all.

Semantic versioning, per the semver.org specification, is MAJOR.MINOR.PATCH: increment MAJOR when you make incompatible API changes, MINOR when you add functionality in a backward compatible manner, PATCH when you make backward compatible bug fixes. semantic-release turns that increment into a function of your commit history instead of a judgement call at tag time.

@semantic-release/commit-analyzer defaults to the angular preset plus a built-in set of release rules. A breaking change is a major, a feat is a minor, and fix, perf and reverted commits are patches. Every other type - docs, chore, style, test, refactor, build, ci - matches no rule and produces no release. That is the most common reason a first run reports nothing to release on a branch full of new commits.

A breaking change is not a type, it is a token: the angular convention requires BREAKING CHANGE followed by a colon, in the commit footer, and the commit-analyzer README is explicit that commits must match the chosen convention exactly. To use the conventionalcommits preset instead, where an exclamation mark after the type also marks a break, set the preset option on both commit-analyzer and release-notes-generator, supply the presetConfig object that preset requires, and add conventional-changelog-conventionalcommits to your npx command.

One thing you cannot configure away: the first release is 1.0.0. The FAQ states that starting at 0.0.1 is not supported, because semver rules apply differently to major version zero. For a project that is not production ready, the documented answer is pre-releases, not a 0.x line.

Section 6

Verify the setup before you trust it

Dry-run prints the next version and the release notes without publishing anything. It is the fastest way to find a broken config.

Dry-run skips the prepare, publish, addChannel, success and fail hooks and prints the next version and the release notes to the console. It still verifies push permission on the repository even though nothing will be pushed, deliberately, so a credentials problem surfaces in a preview rather than halfway through a real release.

Two defaults matter before you run it locally. dryRun defaults to false in a CI environment and true everywhere else, and the ci option defaults to true. A run on your laptop is therefore already a dry run unless you say otherwise, and needs --no-ci to get past CI verification at all.

  • Reports no release: your commits used types the default rules ignore, such as chore, docs or refactor.
  • Behaves as if nothing was ever released: actions/checkout did a shallow clone. Add fetch-depth 0.
  • EINVALIDNPMTOKEN at publish: registry-url was set in actions/setup-node. Move the registry into your project .npmrc.
  • Fails creating the release: the release job is missing contents write, or branch protection is blocking the automatic GITHUB_TOKEN.

Preview the next release without publishing

GITHUB_TOKEN=your_token npx semantic-release@25 --dry-run --no-ci

Section 7

npm packages, private apps, monorepos, GitLab and Python

Four common deviations from the default setup, and the knob each one turns.

Not publishing to npm: keep @semantic-release/npm in the chain and set its npmPublish option to false. The plugin still updates the version in package.json, it just does not push to the registry. That default flips on its own when package.json has private set to true. Add tarballDir to attach the packed tarball to the GitHub release instead.

Monorepos: semantic-release assumes one repository maps to one package, and the core does not change that. The community semantic-release-monorepo package attributes each commit to packages by the files it touched and namespaces git tags as package-name plus version; you apply it per package through the extends option. It is not maintained by the semantic-release organisation, so treat it as a dependency you chose rather than a supported path.

GitLab: the project's FAQ answer is the @semantic-release/gitlab-config shareable configuration, which swaps in @semantic-release/gitlab so the publish step creates a GitLab release, comments on resolved merge requests, and opens a GitLab issue when a release fails. Authentication moves from GH_TOKEN to GL_TOKEN or GITLAB_TOKEN.

Python: python-semantic-release is a different project, not a language port. Its documentation says it was originally inspired by the JavaScript semantic-release but that the codebases have significantly deviated since. Separate package, installed with pip, different configuration keys. Do not follow a Node tutorial and expect the options to match.

Section 8

release-please, and when it is the better fit

Same input, different output: release-please proposes a release in a pull request instead of cutting one on every push.

release-please, from googleapis, reads the same conventional commit history but maintains Release PRs rather than releasing continuously. The pull request stays open and keeps itself up to date as more work merges. Merge it and release-please updates the changelog and other language-specific files, tags the commit, and creates the GitHub release. Its README also states that it does not handle publication to package managers.

The real difference is who presses the button and what happens after the tag. semantic-release releases on push and publishes to npm in the same run. release-please gives you a human approval gate and a reviewable changelog diff before anything is tagged, and leaves publishing to a job you write. The wider field, including git-cliff, changesets, release-drafter and GitHub's built-in generator, is compared in our guide to automating release notes on GitHub.

Section 9

Who should not set this up

The setup cost is an afternoon. The ongoing cost is a commit convention every contributor follows, and that trade does not pay off everywhere.

The best case is a package other people install. A library on npm has consumers who read the version number to decide whether an upgrade is safe, and semver is the contract that tells them. Automating the number removes the release-day judgement call and the human tendency to under-report breakage in your own code.

The worst case is a private application with no external consumers. Nobody reads the version, it gates nothing, and you have paid for a convention, a CI job and a plugin chain to produce a number that appears only in your own releases list. If you wanted a record of what shipped, GitHub's built-in generated release notes give you that from merged pull requests with no commit convention at all.

The complaints about semantic versioning itself are fair. Version numbers inflate: strict semver on an active project produces major bumps often enough that the number stops carrying signal. What counts as breaking is a judgement no parser can make, because a bug fix is a breaking change to anyone who depended on the bug. And the scheme rests on disciplined commit messages, a recurring cost paid by everyone who touches the repository, not a one-time setup fee. None of that makes semantic-release the wrong tool. It makes it a tool whose value scales with how many people outside your team read your version numbers.

Section 10

What semantic-release does not do

The pipeline ends at the GitHub release. The announcement is still typed by hand, every time.

Run the workflow above and the version, the tag and the release notes stop being manual work. Everything after the tag does not: the LinkedIn post, the Discord message, the summary your users actually read. That gets written from scratch every release, by whoever remembers, which is why it slips first when a week gets busy.

Ship 2 Post picks up at that boundary. It watches your GitHub for releases, tags, merged pull requests and deployments through a GitHub App, drafts channel-native posts in your brand voice, and holds them in a review queue until you approve them. There is a free plan and no credit card.

FAQ

Questions this article answers

What does semantic release mean?

Semantic release means the version number is derived from the commits rather than chosen by a person. semantic-release, the tool, parses your commit history with a conventional-changelog preset, works out whether the change set is a major, minor or patch, generates release notes from the same commits, creates the git tag and publishes the release, all from CI.

How to setup semantic release?

Adopt a commit convention, add a .releaserc.json listing your release branch and the plugin chain, then add a GitHub Actions job that runs npx semantic-release. The job needs contents, issues and pull-requests write permission, fetch-depth 0 on checkout so git tags are present, and GITHUB_TOKEN in its environment. Preview it first with --dry-run.

How does semantic-release analyze commits?

@semantic-release/commit-analyzer parses each commit since the last git tag using a conventional-changelog preset, angular by default, then matches it against release rules. By default a BREAKING CHANGE footer gives a major, feat gives a minor, and fix, perf and reverts give a patch. Unmatched types produce no release. The highest matched type wins.

What is the key difference between release-please and semantic-release?

release-please opens a Release PR that updates itself as work merges, and only tags and releases when you merge that pull request. semantic-release releases directly on every qualifying push. release-please also states that it does not handle publication to package managers, while semantic-release publishes to npm in the same run through its bundled npm plugin.

How do I use semantic releases with GitLab?

Use the @semantic-release/gitlab-config shareable configuration, which is the answer in the project's own FAQ. It swaps @semantic-release/github for @semantic-release/gitlab, so the publish step creates a GitLab release, comments on resolved merge requests and opens a GitLab issue on failure. Authentication moves to GL_TOKEN or GITLAB_TOKEN, and the GitLab CI recipes cover the pipeline.

Why is semantic versioning bad?

Three complaints hold up. Major numbers inflate on active projects until they stop signalling much. Whether a change is breaking is a judgement call no parser can make, since a fix is a breaking change to anyone depending on the bug. And it requires every contributor to write disciplined commit messages forever, which is a real recurring cost, not a one-time setup.

Related guides and pages

Where to go next

Hand-picked pages that go deeper on the workflow, channels, and tooling covered above.

Ship 2 Post

Stop writing release posts.

Your engineers already commit. Now those commits become content - in your voice, on every channel.