semantic-release automation
semantic-release reads
Conventional Commits, computes the next semver
version, writes the changelog, tags, and registers a GitHub Release (and
optionally publishes to npm) — all in CI, no manual version bumps. This skill is
the tooling that consumes the commit format; read conventional-commits first for
how the bump is decided.
Copy-paste configs: templates/ — a single-package config, a
monorepo per-package config, and the GitHub Actions workflow.
How a release happens
- You merge a PR to
main (squash; the PR title is the Conventional Commit).
- The workflow runs
semantic-release, which:
- commit-analyzer → reads commits since the last tag, decides major/minor/patch (or no release).
- release-notes-generator → builds the notes from those commits.
- changelog → writes/updates
CHANGELOG.md.
- npm (optional) → bumps
package.json and publishes to npm.
- git → commits
CHANGELOG.md/package.json back as chore(release): X.Y.Z (the version tag itself is created by semantic-release core, not this plugin).
- github → creates the GitHub Release (the canonical record of what shipped).
If no commit since the last release warrants a bump (only chore/docs/…), it
does nothing — correct, not a failure.
Outputs are à la carte — pick any subset
Those six steps read like one bundle, but the outputs are independent: keep only
the plugins for what you actually want. commit-analyzer + release-notes-generator
are the baseline (they compute the version and notes) — keep them; everything below
is opt-in.
| Output |
Plugin(s) |
Needs |
| git tag |
(semantic-release core — no plugin) |
— (created on every release) |
| GitHub Release |
@semantic-release/github |
workflow grants write permissions — the template sets contents + issues + pull-requests (the plugin's default success and failure issue/PR comments need the latter two; see the plugin docs before trimming them); default GITHUB_TOKEN then suffices — a PAT/bot token only if the Release must trigger a downstream on: release workflow |
CHANGELOG.md committed to the repo |
@semantic-release/changelog + @semantic-release/git |
release bot can commit to main (bypass branch protection) |
package.json version bump (no publish) |
@semantic-release/npm ("npmPublish": false) + @semantic-release/git to commit it back |
without @semantic-release/git the bump is made in CI then discarded — package.json in the repo stays unchanged |
| npm publish |
@semantic-release/npm |
NPM_TOKEN; libraries only |
The git tag always happens (core); each other output appears only if its plugin is
present — drop @semantic-release/npm and package.json is never bumped; drop
@semantic-release/git and there's no in-repo CHANGELOG.md/version commit (tag-only).
- A deployed app typically wants
CHANGELOG.md + GitHub Release but not npm
publish — drop @semantic-release/npm (or "npmPublish": false to still bump
package.json). A library adds npm publish on top.
- If your deploy gate fires on the published GitHub Release — both the
release-event-driven deploy and the Promotion Branch gate do (
on: release
with types: [published], see production-release-gating) —
then @semantic-release/github is required: no Release, no deploy. (Only Vercel's
build-skip ignoreCommand gate needs no Release — but it matches on the
chore(release): X.Y.Z commit, so it requires @semantic-release/git instead; a
tag-only config leaves it nothing to detect.)
Where config lives
Either a .releaserc.json at the repo/package root, or a "release" key
in package.json. Both are equivalent; pick one. Plugin order matters — it's
the execution pipeline, and npm must run before git so the bumped
package.json is what gets committed.
Flavor 1 — single package (npm or app)
Use templates/releaserc.single-package.json.
- Publishing to npm: keep
@semantic-release/npm.
- Not publishing (a deployed app, or a private package): drop
@semantic-release/npm (or set ["@semantic-release/npm", { "npmPublish": false }]
to still bump package.json without publishing).
Flavor 2 — monorepo, per-package releases
Each package gets its own .releaserc.json with a package-scoped
tagFormat (my-app-v${version}) so versions/tags don't collide — see
templates/releaserc.monorepo-package.json.
The workflow detects which packages changed with dorny/paths-filter and runs
semantic-release once per changed package (a matrix), max-parallel: 1 with a
git pull --rebase retry so concurrent tag pushes don't collide.
Replace my-app in both tagFormat and the git commit message with the
real package name — otherwise every package shares one tag and the deploy gate
can't match the scope. The template's exec step bumps package.json inline
(no external script to create); swap in a script only if you need extra prepare
steps. (It reserialises package.json with 2-space indent — if your repo uses
other formatting, use a script or a targeted replace to avoid a noisy diff.)
The shipped templates/release.yml is
single-package. For a monorepo, wrap that same semantic-release call in a
dorny/paths-filter → matrix job (max-parallel: 1 + a git pull --rebase retry
so concurrent tag pushes don't collide):
jobs:
detect: # which packages changed?
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.f.outputs.changes }}
steps:
- uses: actions/checkout@v7
- id: f
uses: dorny/paths-filter@v3
with:
filters: | # name each key after the package's directory
apps/web: ['apps/web/**']
packages/lib: ['packages/lib/**']
release:
needs: detect
if: ${{ needs.detect.outputs.changed != '[]' }}
strategy:
max-parallel: 1
fail-fast: false
matrix:
pkg: ${{ fromJson(needs.detect.outputs.changed) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with: { fetch-depth: 0 }
- uses: actions/setup-node@v6
with: { node-version: lts/*, cache: npm }
- run: npm ci
- working-directory: ${{ matrix.pkg }} # filter key == package dir
env: { GITHUB_TOKEN: '${{ secrets.GH_TOKEN || github.token }}' }
# An earlier matrix job may have pushed its release commit; rebase + retry
# so this job isn't behind main when it pushes its own tag.
run: |
git pull --rebase origin main || true
npx semantic-release || { git pull --rebase origin main; npx semantic-release; }
dorny/paths-filter emits changes as a JSON array of the matched filter keys;
naming each key after the package dir lets working-directory: ${{ matrix.pkg }}
resolve. Each package uses its own .releaserc (above).
Which package releases comes from changed file paths (paths-filter), and
the bump from the commit/PR-title type. Keep a PR to one package so the
squash commit maps cleanly. For strict per-package commit attribution, add
semantic-release-monorepo
(it filters commits to those touching the package); plain semantic-release reads
repo-wide history.
The monorepo template's git message carries [skip ci] and uses a [skip ci]-
aware deploy gate — see production-release-gating.
The GitHub Actions workflow
Use templates/release.yml. Non-negotiables:
fetch-depth: 0 — semantic-release needs full history + tags.
- Don't loop: the
chore(release): … commit it pushes would re-trigger the
workflow. Guard with if: !startsWith(github.event.head_commit.message, 'chore(release):')
(this template) or put [skip ci] in the release commit message (the
monorepo template) if your CI honours it.
- Token: the built-in
GITHUB_TOKEN works for tags/Releases, but commits it
makes won't trigger other workflows. If a release must kick off a downstream
deploy via on: push/on: release, use a PAT/bot GH_TOKEN. (See
production-release-gating for the on: release (types: [published]) pattern, which sidesteps this.)
Pool commits into fewer releases
Don't want a release on every merge? Keep merging to main continuously (trunk),
but change the trigger: drop on: push and run the release on
workflow_dispatch (a manual "cut a release" button) and/or a schedule:.
semantic-release batches every commit since the last tag into one larger release —
no release branch needed. For continuous prereleases, add a next/beta branch to
branches (channel releases) and fast-forward to main for the stable cut.
This is the short version. For the full treatment — the manual / scheduled /
prerelease-channel models, when a release branch is (rarely) worth it, the gotchas,
and copy-paste workflow + channel-config templates — use the dedicated
pooled-release skill (it reuses this exact pipeline;
only the trigger changes).
Gotchas
- Plugin order is the pipeline.
commit-analyzer → notes → changelog →
npm → git → github. changelog, npm, and (in the monorepo flavor) the
exec bump must all come before git — git commits the files they
produce/bump, so a wrong order commits a stale CHANGELOG.md/package.json.
EMISMATCHGITHUBURL after an org/repo rename — package.json's repository
field desyncs from the live URL. Pass
--repository-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" (the template
does — GITHUB_SERVER_URL rather than a hard-coded github.com keeps it working
on GitHub Enterprise Server).
- Nothing published? Check: was the merged commit/PR-title a releasable type
(
feat/fix/breaking, not chore)? Is fetch-depth: 0 set? Is the branch in
branches? A non-conventional title silently yields no release.
NPM_TOKEN needs publish rights (and 2FA set to "automation"/auth-token, not
OTP) for @semantic-release/npm.
- Don't hand-write
chore(release): commits — they're the bot's output.
- The
git plugin pushes the release commit straight to main. That's by
design — release automation is the one sanctioned committer to main (it bypasses
the feature-branch + PR rule that applies to humans). If main has branch
protection requiring PRs/reviews, give the release token (a bot/PAT) bypass
permission, or drop @semantic-release/git and run tag-only (no
changelog/version commit back — you lose the in-repo CHANGELOG.md bump).
Verify
npx semantic-release --dry-run on a branch prints the next version and release
notes without publishing — the fastest way to confirm your config and that the
commits produce the bump you expect.
See also
conventional-commits — the input format
that decides the version bump.
pooled-release — want fewer, batched releases
instead of one per merge? The "release train" variant — same pipeline, the trigger
changes (on-demand / scheduled / prerelease channels).
production-release-gating — deploy only
on a real release (the GitHub Release / chore(release): commit this produces).
git-trunk-branch-and-pr-automation
— squash + semantic PR title that becomes the commit analysed here.
Sources
1---2name: semantic-release-automation3description: Automate versioning, changelog, tags, GitHub Releases, and npm publishing from Conventional Commits with semantic-release. Use when setting up or debugging automated releases, wiring a `.releaserc` / `release` config and the plugin pipeline (commit-analyzer, release-notes-generator, changelog, npm, git, github), making `main` cut a version on merge, generating CHANGELOG.md, publishing to npm or creating a GitHub Release per release, doing per-package releases in a monorepo (per-package tags + paths-filter matrix), pooling commits into a less-frequent release, or fixing a release that didn't fire / a CI loop from the release commit. Covers the single-package and monorepo flavors and the GitHub Actions workflow.4---56# semantic-release automation78[semantic-release](https://github.com/semantic-release/semantic-release) reads9[Conventional Commits](../conventional-commits/SKILL.md), computes the next semver10version, writes the changelog, tags, and **registers a GitHub Release** (and11optionally publishes to npm) — all in CI, no manual version bumps. This skill is12the tooling that consumes the commit format; read `conventional-commits` first for13how the bump is decided.1415Copy-paste configs: [`templates/`](./templates) — a single-package config, a16monorepo per-package config, and the GitHub Actions workflow.1718## How a release happens19201. You merge a PR to `main` (squash; the **PR title** is the Conventional Commit).212. The workflow runs `semantic-release`, which:22 - **commit-analyzer** → reads commits since the last tag, decides major/minor/patch (or no release).23 - **release-notes-generator** → builds the notes from those commits.24 - **changelog** → writes/updates `CHANGELOG.md`.25 - **npm** *(optional)* → bumps `package.json` and publishes to npm.26 - **git** → commits `CHANGELOG.md`/`package.json` back as `chore(release): X.Y.Z` (the version **tag** itself is created by semantic-release core, not this plugin).27 - **github** → creates the **GitHub Release** (the canonical record of what shipped).2829If no commit since the last release warrants a bump (only `chore`/`docs`/…), it30does nothing — correct, not a failure.3132### Outputs are à la carte — pick any subset3334Those six steps read like one bundle, but the **outputs are independent**: keep only35the plugins for what you actually want. `commit-analyzer` + `release-notes-generator`36are the **baseline** (they compute the version and notes) — keep them; everything below37is opt-in.3839| Output | Plugin(s) | Needs |40| --- | --- | --- |41| **git tag** | *(semantic-release **core** — no plugin)* | — (created on every release) |42| **GitHub Release** | `@semantic-release/github` | workflow grants write `permissions` — the template sets `contents` + `issues` + `pull-requests` (the plugin's default success **and failure** issue/PR comments need the latter two; see the plugin docs before trimming them); default `GITHUB_TOKEN` then suffices — a PAT/bot token only if the Release must trigger a downstream `on: release` workflow |43| **`CHANGELOG.md` committed to the repo** | `@semantic-release/changelog` + `@semantic-release/git` | release bot can commit to `main` (bypass branch protection) |44| **`package.json` version bump** (no publish) | `@semantic-release/npm` (`"npmPublish": false`) **+ `@semantic-release/git`** to commit it back | without `@semantic-release/git` the bump is made in CI then **discarded** — `package.json` in the repo stays unchanged |45| **npm publish** | `@semantic-release/npm` | `NPM_TOKEN`; libraries only |4647The **git tag always happens** (core); each other output appears only if its plugin is48present — drop `@semantic-release/npm` and `package.json` is never bumped; drop49`@semantic-release/git` and there's no in-repo `CHANGELOG.md`/version commit (tag-only).5051- A **deployed app** typically wants **`CHANGELOG.md` + GitHub Release** but **not** npm52 publish — drop `@semantic-release/npm` (or `"npmPublish": false` to still bump53 `package.json`). A **library** adds npm publish on top.54- If your deploy gate fires on the **published GitHub Release** — both the55 **release-event-driven deploy** and the **Promotion Branch** gate do (`on: release`56 with `types: [published]`, see [`production-release-gating`](../production-release-gating/SKILL.md)) —57 then `@semantic-release/github` is **required**: no Release, no deploy. (Only Vercel's58 build-skip `ignoreCommand` gate needs no Release — but it matches on the59 `chore(release): X.Y.Z` **commit**, so it requires `@semantic-release/git` instead; a60 tag-only config leaves it nothing to detect.)6162## Where config lives6364Either a **`.releaserc.json`** at the repo/package root, or a **`"release"`** key65in `package.json`. Both are equivalent; pick one. Plugin **order matters** — it's66the execution pipeline, and `npm` must run before `git` so the bumped67`package.json` is what gets committed.6869## Flavor 1 — single package (npm or app)7071Use [`templates/releaserc.single-package.json`](./templates/releaserc.single-package.json).72- Publishing to **npm**: keep `@semantic-release/npm`.73- **Not** publishing (a deployed app, or a private package): **drop**74 `@semantic-release/npm` (or set `["@semantic-release/npm", { "npmPublish": false }]`75 to still bump `package.json` without publishing).7677## Flavor 2 — monorepo, per-package releases7879Each package gets its **own** `.releaserc.json` with a package-scoped80**`tagFormat`** (`my-app-v${version}`) so versions/tags don't collide — see81[`templates/releaserc.monorepo-package.json`](./templates/releaserc.monorepo-package.json).82The workflow detects **which packages changed** with `dorny/paths-filter` and runs83`semantic-release` once per changed package (a matrix), `max-parallel: 1` with a84`git pull --rebase` retry so concurrent tag pushes don't collide.8586- **Replace `my-app`** in both `tagFormat` and the git commit `message` with the87 real package name — otherwise every package shares one tag and the deploy gate88 can't match the scope. The template's `exec` step bumps `package.json` **inline**89 (no external script to create); swap in a script only if you need extra prepare90 steps. (It reserialises `package.json` with 2-space indent — if your repo uses91 other formatting, use a script or a targeted replace to avoid a noisy diff.)92- **The shipped [`templates/release.yml`](./templates/release.yml) is93 single-package.** For a monorepo, wrap that same `semantic-release` call in a94 `dorny/paths-filter` → matrix job (`max-parallel: 1` + a `git pull --rebase` retry95 so concurrent tag pushes don't collide):9697 ```yaml98 jobs:99 detect: # which packages changed?100 runs-on: ubuntu-latest101 outputs:102 changed: ${{ steps.f.outputs.changes }}103 steps:104 - uses: actions/checkout@v7105 - id: f106 uses: dorny/paths-filter@v3107 with:108 filters: | # name each key after the package's directory109 apps/web: ['apps/web/**']110 packages/lib: ['packages/lib/**']111 release:112 needs: detect113 if: ${{ needs.detect.outputs.changed != '[]' }}114 strategy:115 max-parallel: 1116 fail-fast: false117 matrix:118 pkg: ${{ fromJson(needs.detect.outputs.changed) }}119 runs-on: ubuntu-latest120 steps:121 - uses: actions/checkout@v7122 with: { fetch-depth: 0 }123 - uses: actions/setup-node@v6124 with: { node-version: lts/*, cache: npm }125 - run: npm ci126 - working-directory: ${{ matrix.pkg }} # filter key == package dir127 env: { GITHUB_TOKEN: '${{ secrets.GH_TOKEN || github.token }}' }128 # An earlier matrix job may have pushed its release commit; rebase + retry129 # so this job isn't behind main when it pushes its own tag.130 run: |131 git pull --rebase origin main || true132 npx semantic-release || { git pull --rebase origin main; npx semantic-release; }133 ```134135 `dorny/paths-filter` emits `changes` as a JSON array of the matched filter keys;136 naming each key after the package dir lets `working-directory: ${{ matrix.pkg }}`137 resolve. Each package uses its own `.releaserc` (above).138- **Which package releases** comes from changed **file paths** (paths-filter), and139 the bump from the commit/PR-title **type**. Keep a PR to **one package** so the140 squash commit maps cleanly. For strict per-package *commit attribution*, add141 [`semantic-release-monorepo`](https://github.com/pmowrer/semantic-release-monorepo)142 (it filters commits to those touching the package); plain semantic-release reads143 repo-wide history.144- The monorepo template's git message carries `[skip ci]` and uses a `[skip ci]`-145 aware deploy gate — see [`production-release-gating`](../production-release-gating/SKILL.md).146147## The GitHub Actions workflow148149Use [`templates/release.yml`](./templates/release.yml). Non-negotiables:150151- **`fetch-depth: 0`** — semantic-release needs full history + tags.152- **Don't loop:** the `chore(release): …` commit it pushes would re-trigger the153 workflow. Guard with `if: !startsWith(github.event.head_commit.message, 'chore(release):')`154 (this template) **or** put `[skip ci]` in the release commit message (the155 monorepo template) if your CI honours it.156- **Token:** the built-in `GITHUB_TOKEN` works for tags/Releases, but commits it157 makes **won't trigger other workflows**. If a release must kick off a downstream158 deploy via `on: push`/`on: release`, use a **PAT/bot `GH_TOKEN`**. (See159 `production-release-gating` for the `on: release` (`types: [published]`) pattern, which sidesteps this.)160161## Pool commits into fewer releases162163Don't want a release on every merge? Keep merging to `main` continuously (trunk),164but **change the trigger**: drop `on: push` and run the release on165`workflow_dispatch` (a manual "cut a release" button) and/or a `schedule:`.166semantic-release batches every commit since the last tag into one larger release —167no release branch needed. For continuous prereleases, add a `next`/`beta` branch to168`branches` (channel releases) and fast-forward to `main` for the stable cut.169170This is the short version. For the **full treatment** — the manual / scheduled /171prerelease-channel models, when a release branch is (rarely) worth it, the gotchas,172and copy-paste workflow + channel-config templates — use the dedicated173[`pooled-release`](../pooled-release/SKILL.md) skill (it reuses this exact pipeline;174only the trigger changes).175176## Gotchas177178- **Plugin order is the pipeline.** `commit-analyzer` → `notes` → `changelog` →179 `npm` → `git` → `github`. `changelog`, `npm`, and (in the monorepo flavor) the180 `exec` bump must all come **before** `git` — `git` commits the files they181 produce/bump, so a wrong order commits a stale `CHANGELOG.md`/`package.json`.182- **`EMISMATCHGITHUBURL` after an org/repo rename** — `package.json`'s `repository`183 field desyncs from the live URL. Pass184 `--repository-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"` (the template185 does — `GITHUB_SERVER_URL` rather than a hard-coded `github.com` keeps it working186 on GitHub Enterprise Server).187- **Nothing published?** Check: was the merged commit/PR-title a *releasable* type188 (`feat`/`fix`/breaking, not `chore`)? Is `fetch-depth: 0` set? Is the branch in189 `branches`? A non-conventional title silently yields no release.190- **`NPM_TOKEN`** needs publish rights (and 2FA set to "automation"/auth-token, not191 OTP) for `@semantic-release/npm`.192- **Don't hand-write `chore(release):` commits** — they're the bot's output.193- **The `git` plugin pushes the release commit straight to `main`.** That's by194 design — release automation is the one sanctioned committer to `main` (it bypasses195 the feature-branch + PR rule that applies to humans). If `main` has branch196 protection requiring PRs/reviews, give the release token (a bot/PAT) **bypass**197 permission, or drop `@semantic-release/git` and run **tag-only** (no198 changelog/version commit back — you lose the in-repo `CHANGELOG.md` bump).199200## Verify201202`npx semantic-release --dry-run` on a branch prints the next version and release203notes **without** publishing — the fastest way to confirm your config and that the204commits produce the bump you expect.205206## See also207208- [`conventional-commits`](../conventional-commits/SKILL.md) — the input format209 that decides the version bump.210- [`pooled-release`](../pooled-release/SKILL.md) — want fewer, batched releases211 instead of one per merge? The "release train" variant — same pipeline, the trigger212 changes (on-demand / scheduled / prerelease channels).213- [`production-release-gating`](../production-release-gating/SKILL.md) — deploy only214 on a real release (the GitHub Release / `chore(release):` commit this produces).215- [`git-trunk-branch-and-pr-automation`](../git-trunk-branch-and-pr-automation/SKILL.md)216 — squash + semantic PR title that becomes the commit analysed here.217218## Sources219220- semantic-release docs & plugin pipeline: <https://semantic-release.gitbook.io/semantic-release/>221- Default release rules (`angular` preset): <https://github.com/semantic-release/commit-analyzer/blob/master/lib/default-release-rules.js>222- Patterns generalised from production repos: `gh-manager-cli` (single-package, npm223 publish, `config in package.json`, no-loop `if` guard, `--repository-url` fix) and224 `piaf-monorepo` (per-package `.releaserc` + `tagFormat`, `dorny/paths-filter`225 matrix, `@semantic-release/exec` prepare step, `[skip ci]` release commit).