DevOps Lesson 22 of 56

Fully Automated Release Engineering: Semantic Versioning, Changelogs, and Monorepo Publishing

In a nutshell

Every package you publish has a version number, and every bump is really a promise to the people who install it. A patch (1.4.2 → 1.4.3) promises “nothing you depend on changed — safe to take.” A minor (1.4.2 → 1.5.0) promises “new things exist, but your old code still works.” A major (1.4.2 → 2.0.0) is a warning: “something you were relying on changed — read before you upgrade.” That numbering scheme is Semantic Versioning (SemVer), and consumers, tools, and CI pipelines all trust it to mean exactly that.

The trouble is that humans are terrible at keeping that promise by hand. Someone bumps a patch when it was really a breaking change, the changelog gets written from memory three weeks later, the git tag ends up on the wrong commit, and the publish runs off a laptop with a personal token. Every one of those is a broken promise, and every one is avoidable.

Think of semantic-release as an automated release clerk who reads your commit messages like a rulebook. Each merged commit is written in a tiny grammar called Conventional Commits — a fix: earns a patch, a feat: earns a minor, a commit flagged breaking earns a major. On every push the clerk reads every commit since the last release tag, applies the single highest bump anyone earned, writes the changelog straight from those messages, cuts the git tag, and publishes to the registry. Nobody types a version number anywhere. If no commit since the last tag deserves a release, the clerk quietly does nothing — so you can run it on every merge and it self-throttles.

This guide builds that pipeline twice: once with semantic-release for a single-package repo, and once with Changesets for a monorepo where each package (@acme/ui, @acme/api) must version and publish on its own schedule. Both consume Conventional Commits, both produce changelogs, both publish from CI with provenance. By the end you will know which to reach for and how to recover when a release half-fails.

Commit to release with semantic-release — a Conventional Commit passes commitlint, a CI job checks out full history, semantic-release analyzes commits into one SemVer bump, generates notes, prepares the changelog and package manifest, publishes to npm with provenance, then stamps an immutable git tag and updates dist-tags/channels

Read the diagram left to right: a commit that passes the grammar check triggers CI, the analyzer turns commit history into exactly one version bump, and the outputs — npm publish, git tag, GitHub release, dist-tags — all fall out of that single decision. Each numbered badge is a place a real release commonly breaks.

Level: Intermediate · Time: ~27 min

After this lesson you can:

Prerequisites

Which tool for which repo

Both tools automate the same path; they differ in where the release intent comes from and how many versions the repo has. Pick before you configure anything:

semantic-release Changesets
Repo shape Single package, one version line Monorepo, each package versions independently
Where intent lives Inferred from commit messages Declared in a .changeset/*.md file per change
Version source of truth git tags (nothing in package.json) each package’s package.json version
Release trigger runs on every push; self-throttles a “Version Packages” PR you merge when ready
Changelog generated from commits generated from changeset summaries
Best when libraries, CLIs, single services design systems, SDK suites, platform monorepos

Rule of thumb: one package that ships on every meaningful merge → semantic-release. Many packages that ship on their own schedules → Changesets. The rest of this guide builds both, so you can see the trade-off in code rather than in the abstract.


Manual releases are where good engineering teams quietly bleed time and ship mistakes. Someone forgets to bump the version, the changelog is written from memory three weeks after the fact, a tag points at the wrong commit, and the npm publish runs from a laptop with a personal token. None of that should exist in 2026. The version, the changelog, the git tag, and the publish should all be deterministic functions of your commit history, executed by CI, with nobody touching a keyboard.

This guide builds that pipeline twice: once with semantic-release for single-package repos, and once with Changesets for monorepos where each package versions independently. Both consume Conventional Commits, both produce changelogs, and both publish from CI with provenance. By the end you will know which to reach for and how to recover when a release half-fails.

Why manual versioning fails

If you have never felt the pain, here is the concrete failure list that automation removes. Each row is a real broken promise:

Manual habit What goes wrong What automation guarantees
A human decides “patch or minor?” Breaking change shipped as a patch; consumers’ builds break on ^ ranges The bump is a function of the commits — a breaking change cannot ship as a patch
Changelog written after the fact Entries missing, vague, or invented from memory Every entry is generated from the commit that caused it
git tag typed by hand Tag on the wrong commit, or forgotten entirely The tag is created by the tool at the exact published commit
npm publish from a laptop Personal token, no provenance, no audit trail, race conditions Publish runs only in CI with a scoped token and a signed attestation
Version bumped in package.json by hand Merge conflicts on every release; the number drifts from reality The version is derived, not stored — no conflicts

Automation does not just save time; it makes an entire class of mistakes structurally impossible. That is the real reason to adopt it.

1. Conventional Commits, enforced at the door

Everything downstream keys off commit message structure. The Conventional Commits spec is a tiny grammar:

<type>(<optional scope>): <description>

<optional body>

<optional footer>

The mapping that drives versioning:

Commit Version impact (SemVer)
fix: ... patch (1.4.2 -> 1.4.3)
feat: ... minor (1.4.2 -> 1.5.0)
feat!: ... or a BREAKING CHANGE: footer major (1.4.2 -> 2.0.0)
chore:, docs:, refactor:, test:, ci: no release by default

Read the grammar against a real example so the parts are concrete:

feat(auth): add refresh-token rotation

Rotate the refresh token on every use and revoke the previous one.

Closes #212
BREAKING CHANGE: refresh tokens issued before this release are invalid.

Do not trust humans to follow this voluntarily. Enforce it with commitlint and a Husky hook so a malformed message never reaches the remote.

npm install --save-dev @commitlint/cli @commitlint/config-conventional husky
npx husky init
// commitlint.config.js
export default {
  extends: ['@commitlint/config-conventional'],
  rules: {
    // Fail commits whose subject line exceeds 100 chars
    'header-max-length': [2, 'always', 100],
    // Force a scope so monorepo commits route to the right package
    'scope-empty': [2, 'never'],
  },
};

Wire the hook. husky init creates a pre-commit file; add the commit-message check:

echo 'npx --no-install commitlint --edit "$1"' > .husky/commit-msg

Enforce the same rule in CI on the PR title if you squash-merge, because the squash commit message is the one that lands on trunk. commitlint reads the title with --from/--to over the PR commit range, or use a dedicated PR-title linter action. The local hook protects authors; the CI check protects trunk.

2. How semantic-release derives the next version

semantic-release does not store the version in package.json and does not read it from there. It treats your git tags as the source of truth. On each run it:

  1. Finds the last release tag reachable on the current branch (e.g. v1.4.2).
  2. Parses every commit since that tag with the commit-analyzer.
  3. Computes the highest version bump implied by those commits.
  4. Generates notes, writes the changelog, publishes, tags, and (optionally) opens a GitHub release.

If no commit since the last tag warrants a release (all chore/docs), it exits cleanly and does nothing. That idempotence is the whole point: you can run it on every push to main and it self-throttles.

Install the core plus the plugins you need:

npm install --save-dev semantic-release \
  @semantic-release/commit-analyzer \
  @semantic-release/release-notes-generator \
  @semantic-release/changelog \
  @semantic-release/npm \
  @semantic-release/github \
  @semantic-release/git

A worked trace: several commits become one release

Nothing makes the model click like watching it run against a concrete history. Suppose the last tag is v1.4.2, and since then five commits landed on main:

$ git log v1.4.2..HEAD --oneline
a1b2c3d chore: bump eslint to 9.9
d4e5f6a docs: clarify the retry section in the README
9f8e7d6 fix(api): stop dropping the trace-id header on retry
1c2b3a4 feat(ui): add a dark-mode toggle
7a6b5c4 refactor(core): extract the token cache

Here is exactly what the analyzer does with that list:

  1. chore and docs and refactor (no configured rule) → no release on their own.
  2. fix(api): ...patch.
  3. feat(ui): ...minor.
  4. It takes the highest bump present. A feat beats a fix, so the release type is minor.
  5. Next version = 1.4.2 with a minor bump = 1.5.0.

generateNotes then groups the releasing commits by type into a changelog block (non-releasing commits are omitted):

## [1.5.0](https://github.com/acme/app/compare/v1.4.2...v1.5.0) (2026-06-08)

### Features

* **ui:** add a dark-mode toggle ([1c2b3a4](https://github.com/acme/app/commit/1c2b3a4))

### Bug Fixes

* **api:** stop dropping the trace-id header on retry ([9f8e7d6](https://github.com/acme/app/commit/9f8e7d6))

(Representative output — the compare/commit links are generated from your repo URL.) That block is prepended to CHANGELOG.md, the tag v1.5.0 is created, and 1.5.0 is published. Had even one of those commits carried a BREAKING CHANGE: footer, the whole release would have jumped to 2.0.0 regardless of the feat/fix mix. Highest wins, breaking beats everything.

3. The plugin pipeline

semantic-release is an ordered set of lifecycle hooks (verifyConditions, analyzeCommits, generateNotes, prepare, publish, success). Each plugin opts into the hooks it cares about. Order in the plugins array matters: it defines execution order within each step.

{
  "branches": ["main", { "name": "next", "prerelease": true }],
  "plugins": [
    "@semantic-release/commit-analyzer",
    "@semantic-release/release-notes-generator",
    ["@semantic-release/changelog", { "changelogFile": "CHANGELOG.md" }],
    "@semantic-release/npm",
    ["@semantic-release/github", {
      "successComment": false,
      "failComment": false
    }],
    ["@semantic-release/git", {
      "assets": ["CHANGELOG.md", "package.json", "package-lock.json"],
      "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
    }]
  ]
}

Save this as .releaserc.json (or a release key in package.json). What each plugin does:

The [skip ci] in the git commit message is load-bearing: without it, the release commit re-triggers your CI pipeline and you risk an infinite loop. Most CI providers honor [skip ci] in the commit message.

Tuning the analyzer

You can extend which commit types trigger a release. For example, treat perf: as a patch and a custom revert as a patch:

["@semantic-release/commit-analyzer", {
  "preset": "conventionalcommits",
  "releaseRules": [
    { "type": "perf", "release": "patch" },
    { "type": "refactor", "scope": "core", "release": "patch" }
  ]
}]

4. Wiring it into CI with provenance and protected credentials

The non-negotiables: the publish runs in CI, never locally; the npm token is a CI-only automation token (or, better, OIDC trusted publishing); and the artifact carries provenance.

npm provenance (--provenance, surfaced by @semantic-release/npm via NPM_CONFIG_PROVENANCE=true) makes the registry generate a signed attestation linking the published tarball to the exact GitHub Actions run and commit that built it. It requires a public package and id-token: write permission.

# .github/workflows/release.yml
name: release
on:
  push:
    branches: [main, next]

permissions:
  contents: read # least privilege at the top

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write      # push the changelog commit + tag
      issues: write         # semantic-release comments on resolved issues
      pull-requests: write
      id-token: write       # npm provenance + OIDC
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0    # semantic-release needs full history + tags
          persist-credentials: false
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          registry-url: https://registry.npmjs.org
      - run: npm ci
      - run: npm audit signatures   # verify dependency provenance pre-publish
      - run: npx semantic-release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
          NPM_CONFIG_PROVENANCE: true

Two things people get wrong here:

If your package is public, prefer npm trusted publishing (OIDC) over a stored NPM_TOKEN entirely: configure the GitHub repo as a trusted publisher in the npm package settings, and the id-token: write permission lets npm exchange the OIDC token for publish rights with no secret stored anywhere.

5. Pre-releases, channels, and maintenance branches

semantic-release maps git branches to distribution channels and release types. This is its most underused capability and it directly models a real branching strategy.

{
  "branches": [
    "+([0-9])?(.{+([0-9]),x}).x",
    "main",
    { "name": "next", "channel": "next", "prerelease": true },
    { "name": "beta", "channel": "beta", "prerelease": true }
  ]
}

Decoding this:

The workflow: cut a 1.x branch from the last 1.y.z tag, backport the fix as a fix: commit, push. semantic-release publishes a patched 1.x and tags it so the next backport computes correctly. No manual version math.

6. Independent versioning of monorepo packages with Changesets

semantic-release is single-version by design. For a monorepo where @acme/ui and @acme/api must version and publish independently, reach for Changesets. It inverts the model: instead of inferring intent from commits, contributors declare intent in a small markdown file per change.

npm install --save-dev @changesets/cli
npx changeset init

This creates .changeset/config.json:

{
  "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
  "changelog": "@changesets/changelog-github",
  "commit": false,
  "fixed": [],
  "linked": [],
  "access": "public",
  "baseBranch": "main",
  "updateInternalDependencies": "patch",
  "ignore": []
}

When a contributor changes a package, they run npx changeset, pick the affected packages, choose the bump level, and write a human summary. That produces a file like:

---
"@acme/ui": minor
"@acme/api": patch
---

Add a `variant` prop to Button and fix the corresponding API serializer.

These changeset files accumulate in .changeset/ and travel with the PR, so the bump intent is reviewed alongside the code. Key config knobs:

7. Generating and committing changelogs without polluting trunk

Changesets uses a two-phase model that keeps version churn off your feature branches. changeset version consumes all pending changeset files, applies the bumps to each package.json, regenerates each package’s CHANGELOG.md, and deletes the consumed changeset files. You never run that on a feature branch.

The clean pattern is the Changesets release bot, which opens a dedicated “Version Packages” PR:

# .github/workflows/release.yml
name: release
on:
  push:
    branches: [main]

concurrency: release-${{ github.ref }}

permissions:
  contents: read

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
      id-token: write
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          registry-url: https://registry.npmjs.org
      - run: npm ci
      - uses: changesets/action@v1
        with:
          version: npm run version       # runs `changeset version`
          publish: npm run release       # runs `changeset publish`
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
          NPM_CONFIG_PROVENANCE: true
// package.json scripts
{
  "scripts": {
    "version": "changeset version",
    "release": "changeset publish"
  }
}

How the loop works:

  1. PRs merge to main carrying changeset files. No version bump happens yet.
  2. The action sees pending changesets and opens or updates a “Version Packages” PR that applies all the bumps and changelogs. Trunk stays clean.
  3. When you merge that PR, the action runs again, finds no pending changesets but a version change, and runs changeset publish to push every changed package to npm and create git tags per package.

This separation is the feature: day-to-day merges never carry version noise, and the actual release is a single reviewable PR you merge when ready. changeset publish only publishes packages whose version in package.json is newer than what is on the registry, so it is safe to re-run.

Verify

Prove the pipeline works before you trust it.

# 1. semantic-release: full dry run, no publish, no tag, no commit
npx semantic-release --dry-run

# 2. Confirm the computed next version and notes in the log output,
#    e.g. "The next release version is 1.5.0"

# 3. Changesets: see exactly what WOULD be bumped and published
npx changeset status --verbose

# 4. Validate commit linting catches a bad message
echo "broke everything" | npx commitlint   # should exit non-zero

# 5. After a real release, confirm the dist-tags and provenance
npm dist-tag ls @acme/ui
npm view @acme/ui --json | jq '.dist'      # look for provenance/attestation

For semantic-release specifically, --dry-run skips prepare/publish but still runs analyzeCommits and generateNotes, so the log tells you the exact version and changelog it would produce. Treat a clean dry run on a throwaway branch as your gate before enabling the main trigger.

8. Rollback, re-publishing, and partial failures

Published versions are immutable by policy. npm forbids re-publishing the same version, and npm unpublish is restricted (72-hour window, and blocked entirely if anything depends on it). So “rollback” almost never means deleting; it means rolling forward.

Failure What actually happened Recovery
Publish failed, no tag, no npm artifact Pipeline died before publish Fix CI, re-run the job. semantic-release is idempotent.
npm published, but tag/commit push failed Partial release (the dangerous one) Manually create the matching git tag at that commit; never re-publish that version.
Bad version published to latest Broken build shipped to everyone Publish a fix: immediately for a higher patch; then npm dist-tag add pkg@<good> latest to repoint.
Wrong version on a dist-tag Mistagged prerelease npm dist-tag rm / add to correct it; no unpublish needed.

The dist-tag repoint is the fastest real-world recovery: it does not delete the bad version, it just stops new installs from resolving to it.

# Stop the bleeding: point latest back at a known-good version
npm dist-tag add @acme/ui@1.4.2 latest
# Verify
npm dist-tag ls @acme/ui

For the partial-failure case (npm succeeded, git push failed), the critical invariant is that the git tag must end up pointing at the commit that was published. Recreate it deliberately rather than letting the next semantic-release run miscompute against a missing tag:

git tag v1.5.0 <published-commit-sha>
git push origin v1.5.0

Enterprise scenario

A platform team at a fintech ran a 40-package internal monorepo on Changesets, publishing to a private Artifactory-backed npm registry. Their constraint: a regulated SOC 2 / change-management process required that every published artifact be traceable to an approved change ticket, and that no human could publish from a workstation. They had been letting team leads run changeset publish locally with a shared token, which auditors flagged hard.

The failure mode that triggered the redesign: two leads merged version PRs within minutes of each other, both ran changeset publish locally, and the second clobbered the first’s in-flight git tags, leaving three packages published to Artifactory with no corresponding tags. Provenance was unprovable.

The fix had three parts. First, they removed all human publish tokens and moved publishing entirely into a CI job gated by a concurrency: release group so two release runs could never overlap. Second, they kept the changeset summary as the authoritative change record and added a CI check that every changeset file referenced a ticket ID in its summary, failing the PR otherwise. Third, they used the bot’s “Version Packages” PR as the formal approval gate: merging that PR (which required a CODEOWNERS approval from release engineering) was the single auditable action that authorized a publish.

The enforcing piece was a pre-publish guard that refused to publish if the working tree did not match the merged version PR, eliminating the local-publish path for good:

- name: Block ad-hoc publishes
  run: |
    if [ -n "$(git status --porcelain)" ]; then
      echo "::error::Working tree dirty. Publishes must run from the merged Version PR."
      exit 1
    fi
- name: Verify on release commit
  run: |
    git log -1 --pretty=%s | grep -q '^chore(release)' \
      || { echo "::error::Not a release commit; refusing to publish."; exit 1; }

Result: zero local publishes, every artifact traceable from npm provenance back through the Version PR to a ticket, and the concurrency lock made the double-publish race structurally impossible. Auditors signed off on the Version PR as the change-control artifact.

Going deeper

You have a working pipeline. This section is what separates “it releases” from “I can debug it at 2 a.m. and reason about it in a 60-package monorepo.”

The full plugin lifecycle, not just the happy path

The six hooks in Section 3 are the ones you touch most, but the real lifecycle has nine, and knowing the extras explains a lot of behaviour:

Hook When What a plugin does here
verifyConditions first, before anything Fail fast: check auth, env vars, config. @semantic-release/npm verifies the token; @semantic-release/git checks push access.
analyzeCommits after verify commit-analyzer returns major/minor/patch/null.
verifyRelease after a release type is known Last chance to reject (e.g. a plugin enforcing a minimum changelog).
generateNotes before prepare release-notes-generator builds the markdown.
prepare before publish changelog write, package.json version write, git stages assets.
publish the actual release npm publish, GitHub Release created.
addChannel when a commit becomes available on a new channel Adds a dist-tag when merging nextmain, without re-building.
success after a successful publish Comment on fixed issues/PRs, notifications.
fail on any error Open an issue, post to Slack — your failure alerting.

A semantic-release “plugin” is just a Node module that exports functions named after these hooks. That is why verifyConditions runs everything upfront: it collects the verifyConditions export from every plugin and runs them all before a single commit is analyzed, so a missing NPM_TOKEN fails in seconds instead of after the changelog is already written. The addChannel hook is the subtle one — it is how a version built on the next prerelease channel gets promoted to latest when next merges into main, reusing the exact tarball rather than rebuilding.

Config resolution trips people up too. semantic-release looks for, in order: a release key in package.json, then .releaserc, .releaserc.json, .releaserc.yaml/.yml, .releaserc.js/.cjs/.mjs, then release.config.js/.cjs/.mjs. The first one found wins — a leftover .releaserc.json silently shadows the release.config.js you thought was active.

Monorepo strategies: fixed vs independent, and the three tool families

“Monorepo release” is not one problem. The first fork is fixed vs independent versioning:

Fixed (lock-step) Independent
Version numbers every package shares one version; all bump together each package has its own version, bumps only when it changes
A changelog one repo-wide changelog one per package
Good for tightly-coupled suites released as a unit (a design system that always ships together) large SDK suites where a typo fix in one package should not re-version 40 others
Failure mode one package’s patch drags 39 others to a new version (install churn) dependency graph must be tracked so dependents bump when a dependency does

Then the tool families, each solving independent versioning differently:

Decision shortcut: already on Nx → Nx Release. Want humans to declare intent in review → Changesets. Committed to the commit-message-only workflow across every package → semantic-release-monorepo. All three publish from CI; none of them changes the immutability and provenance rules below.

The token, precisely

The credential model is where security review lives. Four options, in rough order of preference:

  1. OIDC trusted publishing (best). No stored secret. You register the repo/workflow as a trusted publisher in the npm package settings; id-token: write lets the npm CLI exchange the short-lived Actions OIDC token for publish rights. Nothing to leak, nothing to rotate. Public packages, current npm.
  2. npm automation token. A CI-scoped token that bypasses 2FA for publishing, stored as NPM_TOKEN. Rotate it; scope it to the one package/org if your registry supports granular tokens.
  3. GitHub App installation token. Needed only when the release must trigger a downstream workflow — the built-in GITHUB_TOKEN deliberately cannot, to prevent recursion. Mint a short-lived App token instead of a PAT.
  4. Personal Access Token (avoid). Tied to a human, broad scope, outlives the person’s access. Auditors hate it and they are right.

GITHUB_TOKEN vs App token is the nuance most teams get wrong: the built-in token’s inability to trigger other workflows is a feature here (it reinforces [skip ci]), but it means “publish then kick off a docs deploy” needs the App token path.

Supply chain: what provenance actually buys you

NPM_CONFIG_PROVENANCE=true makes npm generate a signed attestation (a SLSA-style provenance statement) that ties the exact tarball hash to the GitHub Actions run, workflow file, and commit SHA that produced it, recorded in a public transparency log. A consumer running npm audit signatures can then verify that the package they installed was built by the workflow it claims, not swapped out by a compromised maintainer account. This is the same trust chain covered end to end in SLSA, Supply-Chain Security, SBOM & Sigstore Provenance. Provenance needs a public package and id-token: write; private registries increasingly support their own attestation flows but the public-npm path is the reference implementation.

Failure modes and idempotence at scale

Version and API caveats worth knowing

Practice challenges

Work these against a throwaway repo (or on paper for the prediction ones). Each has a worked solution — try first, then check.

1. (Beginner) Write the commit for a bug fix that closes an issue. You fixed a null-pointer in the auth refresh path; it should ship as a patch and link issue #212.

<details> <summary>Solution</summary>

fix(auth): stop null token from crashing refresh

Closes #212

Why: the fix type maps to a patch bump, and the Closes #212 footer lets @semantic-release/github comment on the issue when the release ships. </details>

2. (Beginner) Predict the version. The last tag is v2.3.1. Since then: one feat:, two fix:, one docs:. What does semantic-release publish, and what does docs: contribute?

<details> <summary>Solution</summary>

Next version is 2.4.0. The analyzer takes the highest bump present: feat (minor) beats fix (patch), so it is a minor. docs: contributes nothing — it is a no-release type and is omitted from the changelog too.

Why: highest-bump-wins is the core rule; non-releasing types never move the number. </details>

3. (Intermediate) Gate on a dry run before going live. Give the command that shows the computed next version and generated notes without publishing, tagging, or committing — and say which hooks still run.

<details> <summary>Solution</summary>

npx semantic-release --dry-run
# To test a branch that isn't in your `branches` config, add:
npx semantic-release --dry-run --branches "$(git branch --show-current)"

--dry-run still runs verifyConditions, analyzeCommits, and generateNotes (so the log prints “The next release version is X.Y.Z” and the notes), but skips prepare, publish, and success.

Why: a clean dry run on a throwaway branch is your gate — it proves the version math and changelog before you ever arm the main trigger. </details>

4. (Intermediate) Force a major bump two different ways. Current version is 1.9.0. Write a commit that ships 2.0.0 using the ! shorthand, and an equivalent that uses the footer form.

<details> <summary>Solution</summary>

feat(api)!: drop the v1 request signature

# --- equivalent, footer form ---

feat(api): switch to the v2 request signature

BREAKING CHANGE: the v1 signature header is no longer accepted.

Why: either the ! after the type/scope or a BREAKING CHANGE: footer signals a breaking change and forces a major bump — even though the type is feat, which would otherwise be a minor. </details>

5. (Advanced) Ship a security patch to an old major without touching latest. latest is 3.4.0. A customer pinned to 2.x needs a fix. What branch, what commit, and what config makes it publish 2.7.4 to a 2.x channel while latest stays on 3.x?

<details> <summary>Solution</summary>

# 1. Cut a maintenance branch from the last 2.y.z tag
git checkout -b 2.x v2.7.3
# 2. Backport the fix as a Conventional Commit
git commit -m "fix(auth): patch CVE-2026-1234 token replay"
git push -u origin 2.x
// .releaserc.json — this range already matches 2.x
{ "branches": ["+([0-9])?(.{+([0-9]),x}).x", "main",
    { "name": "next", "prerelease": true }] }

semantic-release recognises 2.x as a maintenance branch, publishes 2.7.4, and tags it under the 2.x dist-tag — the latest 3.x line is untouched.

Why: branch→channel mapping is exactly how you serve patches to pinned consumers without forcing them onto a new major. </details>

6. (Advanced) Independent monorepo bump with an internal dependency. In a Changesets repo, @acme/api depends on @acme/ui. You add a feature to ui and a bugfix to api. Write the changeset file and predict what changeset version does to all three version lines (ui, api, and api’s dependency on ui).

<details> <summary>Solution</summary>

---
"@acme/ui": minor
"@acme/api": patch
---

Add a `variant` prop to Button; fix the API serializer to accept it.

Running changeset version: @acme/ui gets a minor bump, @acme/api gets a patch from your declared intent. Because updateInternalDependencies is patch (the default), bumping ui also bumps api’s dependency range on ui — which on its own would trigger a patch, but api was already getting a patch, so it stays a single patch. Each package’s CHANGELOG.md is regenerated and the changeset file is deleted.

Why: Changesets composes declared intent with derived internal-dependency propagation, so a consumer of a changed package can never be left pinned to a version that no longer exists. </details>

Common beginner mistakes

Glossary

Release engineering checklist

Wire it once, verify with dry runs, and releases stop being an event. They become a side effect of merging good commits, fully attributable from the npm artifact back to the line of code that caused the bump.

release-engineeringsemantic-versioningci-cdconventional-commitsmonorepo
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments