DevOps Multi-Cloud

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

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.

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

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

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.

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

Comments

Keep Reading