Automating changelog generation with semantic-release Jump to heading
Hand-maintained changelogs drift the moment a release is cut under pressure: the version number in package.json, the Git tag, the release notes, and the CHANGELOG.md entry are four artifacts that must agree, and any manual step is a step that gets skipped. semantic-release closes that gap by deriving every one of those artifacts from a single source of truth — your commit history. When commits follow the Conventional Commits grammar, the tool computes the next semantic version, writes the changelog, creates a signed Git tag, and publishes a GitHub Release, all inside CI with no human deciding “is this a minor or a patch?”. This page is a focused recipe within Release Tagging & Versioning, which covers the broader model of how tags, versions, and release notes fit a branching strategy.
When to use this approach Jump to heading
Reach for semantic-release when:
- Your team already writes disciplined commit messages — ideally enforced, not merely encouraged. Automating enforcement is covered in How to Enforce Conventional Commits with commitlint; without that gate, a single non-conforming message silently produces no release.
- Releases are frequent enough that manual version bumps and changelog edits have become error-prone busywork.
- You publish from a single long-lived release branch (typically
main) and want every merge to it to be a candidate release. - You need the version bump,
CHANGELOG.md, Git tag, and GitHub Release to be provably consistent for audit or downstream consumers.
It is not the right fit if your release cadence is irregular and human-curated (a marketing-driven “big release” model), or if your commit history is a firehose of merge commits with no conventional structure — retrofit commit discipline first, or the tool will either release nothing or release unpredictably.
The diagram below shows how a range of conventional commits flows through the plugin chain into four published artifacts.
Step-by-step recipe Jump to heading
Step 1 — Install semantic-release and its core plugins Jump to heading
Install semantic-release and the five plugins that form the release pipeline as dev dependencies. semantic-release requires Node.js 20.8.1 or newer:
# semantic-release core + the plugins referenced in .releaserc.json below
npm install --save-dev \
semantic-release \
@semantic-release/commit-analyzer \
@semantic-release/release-notes-generator \
@semantic-release/changelog \
@semantic-release/git \
@semantic-release/github Verify the binary resolves and reports its version:
# Should print the installed semantic-release version, e.g. 24.x
npx semantic-release --version Step 2 — Configure the plugin chain in .releaserc.json Jump to heading
semantic-release runs its plugins in the order you list them, and order is load-bearing: commit-analyzer must decide the version before release-notes-generator renders notes, and changelog must write the file before git commits it. Create .releaserc.json at the repository root:
{
"branches": ["main"],
"plugins": [
["@semantic-release/commit-analyzer", {
"preset": "conventionalcommits"
}],
["@semantic-release/release-notes-generator", {
"preset": "conventionalcommits"
}],
["@semantic-release/changelog", {
"changelogFile": "CHANGELOG.md"
}],
["@semantic-release/git", {
"assets": ["CHANGELOG.md", "package.json", "package-lock.json"],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}],
"@semantic-release/github"
]
} Two details that matter: the [skip ci] marker in the git plugin’s commit message prevents the release commit from re-triggering the workflow (an infinite loop), and the conventionalcommits preset is what maps feat/fix/BREAKING CHANGE to minor/patch/major. Verify the config parses as valid JSON:
# Non-zero exit means the file is malformed — fix before going further
node -e "JSON.parse(require('fs').readFileSync('.releaserc.json','utf8')); console.log('config OK')" Step 3 — Provision the signing key so the tag is signed Jump to heading
semantic-release creates the tag by shelling out to git tag, which means it honours Git’s own signing configuration. To produce a signed tag, import the release signing key into the CI runner and enable tag signing before the release runs. The mechanics of storing and scoping that key belong to Protecting & Rotating Signing Keys — never bake a private key into the repository; inject it from an encrypted secret at job time.
# In CI, before the release step. GPG_PRIVATE_KEY is a repository/org secret.
echo "$GPG_PRIVATE_KEY" | gpg --batch --import
# Tell git which key to use and to sign every tag it creates
git config --global user.signingkey "$GPG_KEY_ID"
git config --global tag.gpgSign true
git config --global commit.gpgSign true
git config --global user.name "release-bot"
git config --global user.email "[email protected]" Verify locally, before wiring CI, that a signed tag actually validates:
# Create a throwaway signed tag and confirm the signature verifies
git tag -s v0.0.0-verify -m "signing smoke test"
git tag -v v0.0.0-verify # prints "Good signature"
git tag -d v0.0.0-verify # clean up the throwaway tag Step 4 — Add the GitHub Actions release job with contents:write Jump to heading
The release job needs write access to repository contents (to push the version commit and create the tag) and to releases. Grant exactly those scopes with the workflow-level permissions block — the default read-only GITHUB_TOKEN cannot create a release or push a tag:
# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
permissions:
contents: write # push the version commit + create the tag/release
issues: write # comment on issues/PRs included in the release
pull-requests: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history — analyzer needs every tag
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Import signing key
run: |
echo "$" | gpg --batch --import
git config --global user.signingkey "$"
git config --global tag.gpgSign true
- name: Release
env:
GITHUB_TOKEN: $
run: npx semantic-release The fetch-depth: 0 line is a frequent cause of “no release” bugs: without full history, semantic-release cannot see prior tags and misreads the commit range. Verify the workflow file is well-formed before pushing:
# Requires yq; confirms the permissions block exists and grants write
yq '.permissions.contents' .github/workflows/release.yml # -> write Step 5 — Dry-run the release to preview the bump before it happens Jump to heading
SAFETY WARNING: A commit typed
feat!:or carrying aBREAKING CHANGE:footer triggers a major version bump — a single mistyped commit can jump1.4.2straight to2.0.0, publish an irreversible GitHub Release, and push a signed tag that downstream consumers immediately pin. Always dry-run first. If a bad tag ships, delete it locally and on the remote withgit push --delete origin vX.Y.Z && git tag -d vX.Y.Z, then delete the GitHub Release from the UI orgh release delete vX.Y.Z— but treat any already-consumed version as burned and move forward, never reuse it.
Run semantic-release in dry-run mode, which computes the next version and prints the release notes without writing, tagging, committing, or publishing anything:
# --dry-run performs analysis only: no tag, no commit, no GitHub Release
# GITHUB_TOKEN can be a read-scoped token for the dry run
npx semantic-release --dry-run Read the output carefully and confirm the announced version matches your expectation:
# Look for a line like this and sanity-check the bump direction:
[semantic-release] › ℹ The next release version is 1.5.0 If the dry run announces a major bump you did not intend, stop and inspect the offending commit before merging anything to the release branch.
Step 6 — Verify the tag, changelog, and GitHub Release Jump to heading
After the first real run merges to main and the workflow completes, confirm all four artifacts landed and agree:
# 1. The tag exists and is signed (look for the PGP signature block)
git fetch --tags
git tag -v "$(git describe --tags --abbrev=0)"
# 2. CHANGELOG.md has a new top section for the released version
head -n 20 CHANGELOG.md
# 3. The release commit is present with the [skip ci] marker
git log --oneline -1 --grep="chore(release)"
# 4. The GitHub Release was published with matching notes
gh release view "$(git describe --tags --abbrev=0)" All four should reference the identical version string. A mismatch — for example a tag without a corresponding CHANGELOG.md section — usually means the git plugin ran before the changelog plugin; re-check the plugin order in Step 2.
Validation checklist Jump to heading
Before treating the pipeline as production-ready, confirm each item:
Frequently asked questions Jump to heading
Why did semantic-release not publish a release even though I pushed commits? Jump to heading
semantic-release only releases when the commit range since the last tag contains at least one commit type that maps to a version bump — by default feat, fix, or a BREAKING CHANGE footer. Commits typed docs, chore, style, refactor, test, or ci produce no release, and the tool prints “There are no relevant changes, so no new version is released.” The other common cause is a shallow checkout: without fetch-depth: 0 the analyzer cannot see prior tags and misjudges the range. Enforcing commit structure with commitlint upstream eliminates most “nothing released” surprises.
How do I make semantic-release sign the Git tag it creates? Jump to heading
semantic-release delegates tag creation to git, so configure Git to sign tags in the CI job: after importing the key, run git config tag.gpgSign true and set user.signingkey. The tool then runs git tag under the hood, which honours tag.gpgSign and produces a signed tag. Keep the private key out of the repository and inject it from an encrypted secret at job time, following the practices in Protecting & Rotating Signing Keys.
Can I run semantic-release on a protected branch that blocks direct pushes? Jump to heading
Yes, but the @semantic-release/git plugin pushes the CHANGELOG.md and version commit back to the release branch, so the identity running the job must be exempt from the push restriction. The default GITHUB_TOKEN cannot push to a branch that requires pull requests, so use a GitHub App installation token or a deploy key with a bypass entry in branch protection. Scope that identity narrowly and audit its use, since it holds an exception to your protection rules.
Related Jump to heading
- Release Tagging & Versioning — the parent page covering how tags, semantic versions, and release notes fit into a branching strategy and release cadence.
- Setting Up PR Templates for Code Review Efficiency — a sibling recipe that shapes the pull-request metadata and commit discipline feeding this automated release pipeline.
- How to Enforce Conventional Commits with commitlint — the upstream gate that guarantees the commit grammar semantic-release depends on to compute the version bump.