Skip to main content

Guide

semantic-release vs changesets vs release-it 2026

Compare semantic-release, changesets, and release-it for automating npm package versioning and releases. Changelog generation, CI integration, monorepo.

·PkgPulse Team·
0

TL;DR

semantic-release automates versioning and publishing based on commit message conventions (Conventional Commits) — no manual version bumps, CHANGELOG generated automatically, fully hands-off. changesets uses a pull-request-based workflow — developers add changeset files describing what changed, then a CI bot opens PRs to bump versions and publish. release-it is the most flexible manual release tool — interactive CLI, supports any versioning strategy, hooks for custom scripts. In 2026: semantic-release for fully automated CI/CD, changesets for team-based open source projects, release-it when you want control.

Key Takeaways

  • semantic-release: ~2M weekly downloads — fully automated, commit-message-driven, zero human interaction
  • changesets: ~3M weekly downloads — PR-based workflow, great for open source and monorepos
  • release-it: ~2M weekly downloads — interactive CLI, flexible, not opinionated about commit style
  • semantic-release requires Conventional Commits (feat:, fix:, chore:) for version detection
  • changesets uses .changeset/ markdown files in the repo — devs describe changes in prose
  • All three support monorepos — changesets has the best monorepo support

The Release Problem

Without automation:
  1. Manually decide: is this a major, minor, or patch?
  2. Update version in package.json
  3. Write CHANGELOG entry
  4. git commit && git tag v1.2.3
  5. git push && git push --tags
  6. npm publish
  → Error-prone, inconsistent, easy to forget a step

With automation:
  semantic-release: git commit → CI detects → publishes automatically
  changesets: PR merged → CI bot bumps version → publish
  release-it: `release-it` → interactive prompts → done

semantic-release

semantic-release — fully automated releases:

How it works

1. Developer commits with Conventional Commits:
   "feat: add new package comparison endpoint"
   "fix: correct health score calculation"
   "BREAKING CHANGE: remove deprecated /v1 API"

2. CI runs semantic-release on main branch
3. semantic-release analyzes commits since last release:
   - feat → minor version bump (1.2.0 → 1.3.0)
   - fix → patch version bump (1.2.0 → 1.2.1)
   - BREAKING CHANGE → major version bump (1.2.0 → 2.0.0)

4. semantic-release:
   - Creates GitHub release with auto-generated changelog
   - Publishes to npm
   - Updates package.json version (in release artifacts)
   - Commits release notes back to repo

.releaserc.json configuration

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

GitHub Actions workflow

# .github/workflows/release.yml
name: Release

on:
  push:
    branches: [main]

permissions:
  contents: write    # Create tags and releases
  issues: write      # Comment on released issues
  pull-requests: write  # Comment on merged PRs

jobs:
  release:
    name: Release
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # semantic-release needs full git history
          persist-credentials: false

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - run: npm ci

      - name: Release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
        run: npx semantic-release

Conventional Commits cheat sheet

# patch: 1.0.0 → 1.0.1
git commit -m "fix: handle null healthScore in comparison"
git commit -m "perf: optimize package search query"
git commit -m "docs: update API documentation"

# minor: 1.0.0 → 1.1.0
git commit -m "feat: add weekly download trend chart"
git commit -m "feat(api): add /packages/compare endpoint"

# major: 1.0.0 → 2.0.0
git commit -m "feat!: remove deprecated v1 API endpoints"
git commit -m "feat: new auth system
BREAKING CHANGE: JWT tokens now required for all endpoints"

# No release:
git commit -m "chore: update dependencies"
git commit -m "ci: add coverage reporting"
git commit -m "test: add unit tests for calculator"

changesets

changesets — PR-based release workflow:

How it works

1. Developer opens a PR
2. Developer adds a changeset file:
   npx changeset
   → Interactive prompt: what packages changed? major/minor/patch? What changed?
   → Creates .changeset/a-random-slug.md

3. Changesets bot comments on the PR:
   "This PR will release @pkgpulse/api@1.2.0 when merged"

4. PR is merged to main

5. Changesets GitHub Action opens a "Version PR" automatically:
   - Bumps version numbers
   - Updates CHANGELOG.md
   - Consumes all pending changeset files

6. Team merges the Version PR → GitHub Action publishes to npm

Setup

npm install -D @changesets/cli

npx changeset init
# Creates .changeset/config.json

.changeset/config.json

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

Adding a changeset

# Run this when you have changes to release:
npx changeset

# Interactive prompts:
# ? Which packages should be included?
#   ● @pkgpulse/api
#   ○ @pkgpulse/ui
# ? Which type of change is this?
#   ○ patch (0.0.1)
#   ● minor (0.1.0)  ← selected
#   ○ major (1.0.0)
# ? Describe the changes — will appear in CHANGELOG:
#   Added support for comparing multiple packages at once

# Creates: .changeset/lemon-boots-help.md
<!-- .changeset/lemon-boots-help.md (committed to the PR): -->
---
"@pkgpulse/api": minor
---

Added support for comparing multiple packages at once via the new `/compare` endpoint.
Users can now pass `?names=react,vue,angular` to get a comparison in a single request.

GitHub Actions workflow

# .github/workflows/release.yml
name: Release

on:
  push:
    branches: [main]

jobs:
  release:
    name: Release
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22

      - run: npm ci

      - name: Create Release Pull Request or Publish
        uses: changesets/action@v1
        with:
          publish: npm run release    # Script that calls: changeset publish
          version: npm run version    # Script that calls: changeset version
          commit: "chore: release"
          title: "chore: release packages"
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

Monorepo support (killer feature)

// packages/api/package.json
{
  "name": "@pkgpulse/api",
  "version": "1.2.0"
}

// packages/ui/package.json
{
  "name": "@pkgpulse/ui",
  "version": "0.5.0"
}
# Changeset for specific packages:
npx changeset
# Select only @pkgpulse/api (not @pkgpulse/ui)
# → Only api gets a version bump

# Multiple packages in one changeset:
# → Each can have different bump types (api: minor, ui: patch)

release-it

release-it — interactive release CLI:

Basic usage

# Interactive release (prompts for bump type):
npx release-it

# Non-interactive (pass bump type):
npx release-it patch   # 1.2.0 → 1.2.1
npx release-it minor   # 1.2.0 → 1.3.0
npx release-it major   # 1.2.0 → 2.0.0

# Dry run (see what would happen):
npx release-it --dry-run

.release-it.json configuration

{
  "git": {
    "commitMessage": "chore: release v${version}",
    "tagName": "v${version}",
    "tagAnnotation": "Release v${version}",
    "requireCleanWorkingDir": true,
    "requireUpstream": true
  },
  "npm": {
    "publish": true,
    "publishPath": ".",
    "tag": "latest"
  },
  "github": {
    "release": true,
    "releaseName": "v${version}",
    "draft": false,
    "autoGenerate": true
  },
  "hooks": {
    "before:init": ["npm run lint", "npm run test"],
    "after:bump": "npm run build",
    "after:release": "echo Successfully released ${name} v${version} to ${repo.repository}"
  }
}

With conventional-changelog

// .release-it.json
{
  "plugins": {
    "@release-it/conventional-changelog": {
      "preset": "angular",
      "infile": "CHANGELOG.md"
    }
  },
  "git": {
    "requireCommits": true,
    "commitMessage": "chore(release): ${version}"
  },
  "github": {
    "release": true
  },
  "npm": {
    "publish": true
  }
}

Feature Comparison

Featuresemantic-releasechangesetsrelease-it
Automation level⚡ Fully automatedSemi-automatedManual
Requires Conventional Commits❌ (optional)
PR-based workflow
Monorepo support✅ (plugin)✅ First-class✅ (partial)
Interactive CLI
GitHub releases
npm publish
Custom changelog
Pre-release channels
Weekly downloads~2M~3M~2M

When to Use Each

Choose semantic-release if:

  • Fully automated releases with no human decision-making
  • Team already uses Conventional Commits
  • You want CI/CD to handle everything automatically
  • Single-package library with continuous deployment

Choose changesets if:

  • Open source project with multiple contributors
  • Monorepo with multiple packages that need independent versioning
  • Want PR authors to describe their changes in human-readable prose
  • Need the Changesets GitHub bot to automate the Version PR

Choose release-it if:

  • Want control over when and how you release
  • Don't use Conventional Commits and don't want to change
  • Simple interactive release flow
  • Small team or solo developer

Pre-release Channels and Beta Distribution

All three tools support pre-release versions, but the workflows differ significantly. semantic-release handles pre-releases through branch configuration: add { "name": "beta", "prerelease": true } to the branches array in .releaserc.json, and any push to the beta branch triggers a pre-release publish with a tag like 2.1.0-beta.1. The version number increments automatically based on commit history, and npm install my-package@beta installs the latest beta. No human needs to decide the version number — the commit messages determine it.

changesets uses a --snapshot mode for pre-release testing. Running npx changeset version --snapshot canary applies all pending changesets as a pre-release, producing versions like 1.2.0-canary-20260401-abc123. This is particularly useful for testing cross-package changes in a monorepo before the real release: a CI workflow can publish snapshot versions on every PR push, letting downstream packages consume the unreleased changes via npm install @pkgpulse/api@canary. The @changesets/cli's prerelease mode also supports a more structured alpha/beta flow where you enter pre-release mode, accumulate changesets, and then exit pre-release mode to publish the final version.

release-it's pre-release support is the most manual — you run release-it --preRelease=alpha which appends the pre-release identifier to the version you specify. This fits release-it's overall philosophy of keeping humans in control, but it means someone decides when a feature is "beta-ready" rather than the tooling deriving it from commit history or PR metadata.

Migrating to Conventional Commits for semantic-release

Teams adopting semantic-release for the first time often face the challenge of enforcing Conventional Commits (feat:, fix:, chore:, etc.) in a codebase with no existing commit message discipline. Without enforcement, a feat: typo as feature: or a plain updated stuff commit message causes semantic-release to treat the commit as a no-release commit, silently skipping the version bump.

The standard enforcement mechanism is commitlint combined with a Git commit-msg hook via husky. Running npx commitlint --config commitlint.config.js --edit "$1" in a .husky/commit-msg hook rejects commits that don't match the Conventional Commits pattern. On CI, you can add commitlint --from HEAD~1 --to HEAD --verbose to the release workflow to catch any commits that slipped past the local hook.

The transition period for existing teams is the hardest part. Commits made before adopting Conventional Commits don't affect semantic-release's version calculations — it only looks at commits since the last tag. So you can adopt semantic-release on an existing project by tagging the current state as v1.0.0 and starting the Conventional Commits discipline from that point forward without needing to rewrite history.

Monorepo Considerations: changesets vs semantic-release

For monorepos with multiple publishable packages, changesets has the most mature independent versioning story. Each package can be at a different version, and a single changeset file can specify different bump types for different packages in the same PR (@pkgpulse/api: minor, @pkgpulse/ui: patch). The changesets bot comments on PRs with exactly which packages will be released and at what versions, making the release intent visible during code review.

semantic-release requires the @semantic-release/exec or semantic-release-monorepo plugin to handle independent package versioning. The configuration is more complex, and the fully-automated nature of semantic-release can be a liability in monorepos where a single commit touching a shared utility might inadvertently trigger version bumps in unrelated packages. Teams using Turborepo or Nx often pair changesets with their monorepo tooling because changesets' explicit changeset files integrate cleanly with PR-based workflows and require developers to consciously decide which packages a change affects.


Security Considerations in Automated Release Pipelines

Automated release tools require elevated credentials: npm publish tokens, GitHub tokens with write access to tags and releases, and sometimes registry-specific signing keys. The security posture of each tool differs in how it encourages credential management. semantic-release's documentation strongly recommends short-lived CI-environment tokens using GitHub's GITHUB_TOKEN (generated per-workflow-run and automatically expiring) rather than long-lived personal access tokens. The plugin configuration lets you specify which secrets each plugin consumes, making it possible to audit which credentials flow through the release pipeline and rotate them independently.

changesets takes a similar approach through the changesets/action GitHub Action, which uses GITHUB_TOKEN for the Version PR and a separate NPM_TOKEN for publishing. One meaningful security advantage of changesets' two-phase workflow is that the Version PR and publish steps are separated: the bot creates the Version PR (which requires push access) and the publish only runs when a human merges that PR. This separation means that even if a malicious dependency in your build chain triggers during the version PR creation, it cannot directly publish to npm — the publish requires an explicit human merge action.

release-it's manual nature is both a security advantage and a risk. Since a human runs release-it from their local machine, credentials must be available in the developer's environment rather than CI-managed secrets. This increases the risk of credential leakage through shell history or environment variable logging. Teams using release-it in production should use granular npm automation tokens (scoped to specific packages, read-write only for publish) rather than full account access tokens, and run releases from a dedicated CI environment rather than developer laptops when possible.

TypeScript Integration and Type-Safe Release Configurations

All three tools are authored in JavaScript but expose TypeScript-friendly configuration surfaces that improve the developer experience when managing release workflows in TypeScript-first monorepos. semantic-release's .releaserc.json is a static JSON file, but you can use .releaserc.ts or .releaserc.js for typed configuration — the SemanticRelease.Options type from the semantic-release package provides full IntelliSense over the configuration object. This is particularly useful when configuring the @semantic-release/exec plugin with complex shell commands, where seeing the exact expected shape of the plugin options prevents misconfiguration that only surfaces during CI runs.

changesets provides TypeScript types through its @changesets/types package, which exports the core data structures: NewChangeset, Release, ReleasePlan, and ComprehensiveRelease. Teams that write custom changeset tooling — for example, scripts that read pending changesets and post a summary to Slack before the release — can import these types to write type-safe traversal code. The @changesets/read package exposes an async function to read all pending changesets in a repository, returning Promise<NewChangeset[]> with full TypeScript inference.

release-it's TypeScript integration centers on its plugin API. Plugins are JavaScript modules that implement the Plugin interface with hooks like beforeBump, beforeRelease, and afterRelease. The @release-it/conventional-changelog plugin is the most commonly typed integration — the ConventionalChangelogOptions type ensures the preset name, infile path, and version bump rules match the expected format. For teams building custom release-it plugins, the TypeScript type definitions ship in the main package under @types/release-it, providing the full hook lifecycle contract.

Methodology

Download data from npm registry (weekly average, February 2026). Feature comparison based on semantic-release v24.x, @changesets/cli v2.x, and release-it v17.x.

Compare developer tooling and CI/CD packages on PkgPulse →

See also: cac vs meow vs arg 2026 and cosmiconfig vs lilconfig vs conf, archiver vs adm-zip vs JSZip (2026).

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.