48.5 Release Automation with Changesets or semantic-release
Right, so you’ve got your tests passing and your linter is happy. Now comes the fun part: actually shipping this thing to users without causing a collective gasp on Twitter. Doing this manually is a recipe for human error, forgotten steps, and a special kind of developer despair. We automate. The goal is to take your merged code and, with zero human intervention, bump the version number, generate changelogs, and publish to your package registry (be it npm, GitHub Packages, etc.).
The two biggest players in this space for the TypeScript/JavaScript world are changesets and semantic-release. They approach the same problem from two fundamentally different philosophies. Picking one isn’t about which is “better,” but which fits your team’s brain.
The Philosophical Divide: Curated vs. Automated
semantic-release is ruthlessly automated. It analyzes your commit messages (specifically, using the Conventional Commits standard) to determine what the next version number should be. A fix: commit? That’s a patch bump. A feat: commit? That’s a minor bump. A commit body containing BREAKING CHANGE:? Hello, major version. It then automatically publishes that new version. It’s brilliant for teams that are disciplined about commit messages and want a fully hands-off, continuous delivery approach. The upside is total automation; the downside is that a sloppy commit message can accidentally ship a major release.
changesets, on the other hand, is built for human curation. It doesn’t trust your commit messages as far as it can throw them. Instead, when a developer makes a change that needs a release, they run npx changeset (or pnpm changeset). This interactive CLI creates a Markdown file describing the change and its impact (patch, minor, or major). These markdown files live in your repo. When it’s time to release, you run changeset version to consume those files, bump the version, and generate the changelog, and then changeset publish to ship it. It provides a fantastic audit trail and gives the team explicit control over the release process. The upside is clarity and control; the downside is an extra step for developers.
Setting Up semantic-release: For the Commit Puritan
First, install the squad:
npm install --save-dev semantic-release @semantic-release/git @semantic-release/changelog @semantic-release/commit-validator
Your .releaserc.json config file is where the magic happens. This one handles changelog generation, updating the version in package.json, and even committing the changes back to your main branch—which is crucial for keeping everything in sync.
{
"branches": ["main"],
"plugins": [
"@semantic-release/commit-validator",
"@semantic-release/changelog",
"@semantic-release/npm",
[
"@semantic-release/git",
{
"assets": ["package.json", "CHANGELOG.md"],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}
]
]
}
Then, you hook this into your CI pipeline. For GitHub Actions, it’s dead simple:
# .github/workflows/release.yml
name: Release
on:
push:
branches: [ main ]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Critical for semantic-release to see the full commit history
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }} # Only if publishing to npm
The pitfall here is obvious: your team must adopt Conventional Commits. If your commit history is a mess of “fix stuff” and “oops,” semantic-release will either fail or do something terrifying.
Implementing changesets: For the Control Enthusiast
Install the core package:
npm install --save-dev @changesets/cli
Then, initialize it to set up the .changeset directory:
npx changeset init
Now, when Jane adds a new feature, she runs npx changeset. She’ll be prompted to select the type of change (which bumps what), and to write a summary for the changelog. This creates a file in .changeset/ that looks like this:
---
"your-package-name": minor
---
Add a fantastic new widget to the core API.
This widget allows users to flobulate their data with 20% more efficiency.
These files are committed to git. When you’ve merged a few PRs and are ready to release, you run two commands:
# This figures out the new version, updates package.json, and consumes the changeset files
npx changeset version
# Then, you publish to npm and create a GitHub release
npx changeset publish
Of course, you automate this in CI too. A common pattern is a GitHub Action that runs on pushes to main and automatically versions and publishes, but only if there are changeset files present.
# .github/workflows/release.yml
name: Release
on:
push:
branches: [ main ]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npx changeset version
- run: npx changeset publish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
The pitfall with changesets is process: developers have to remember to create them. It becomes a cultural thing, like writing tests. You can enforce it with a PR label check in CI if you’re serious.
The TypeScript-specific Gotcha: Build and Publish what’s Built
Here’s the thing both approaches share: you are a TypeScript project, but you publish JavaScript. Your CI pipeline must build your package before running the release tool. The release tool should run against the built dist/ directory, not your source src/. This seems obvious but I’ve seen it go wrong more times than I can count.
Your publish step in CI should look like this:
- Checkout code
- Install dependencies (
npm ci) - Build the project (
npm run build) - Run the release tool (
semantic-releaseorchangeset publish)
changeset publish by default will run npm publish from the root. If your package.json’s files field points to dist, that’s fine—it’ll pack up the dist directory. But double-check that your build script is run first in your CI config. semantic-release is the same; it just runs the publish for you after the version bump.
So, which one? If your team thrives on strict process and wants git history to be the source of truth, semantic-release is your zen master. If you prefer explicit, PR-level control and a visible paper trail of what’s going out, changesets is your best friend. Either one is a monumental upgrade over the sweating, manual npm version && npm publish dance.