Changesets for Versioning: Monorepo Release Management
Ship monorepo packages without losing your mind. A practical walkthrough of Changesets — from setup to automated npm releases, changelog generation, and CI.
Why Monorepo Releases Are Actually Hard
Managing a single npm package is straightforward. Bump the version, run npm publish, push the tag. Done. But the moment you have five packages that depend on each other — a design system, a few utility libs, maybe a CLI — that simple flow collapses fast. Who bumped what? Does @myorg/ui@2.4.0 need a new @myorg/tokens release too? Is that a patch or a minor?
This is exactly the problem Changesets was built for. Released around 2019 and popularized heavily through 2021–2022 as monorepos exploded in the JS ecosystem, Changesets gives you a structured way to track *intent* before a release — not just the diff. You write a tiny markdown file describing what changed and why, and the toolchain handles the rest.
Honestly, most teams I've seen skip this and either use a single lockstep version for all packages (which forces unnecessary major bumps) or manually edit package.json files across repos in a frantic pre-release scramble. Neither scales. Changesets solves the coordination problem without requiring a PhD in release engineering.
Worth noting: Changesets isn't just for massive monorepos. Even two packages benefit from the discipline it brings — you get a proper CHANGELOG.md, consistent semver, and a Git history that actually tells a story.
Installing and Configuring Changesets
Getting started is one command. Assuming you're in a pnpm or npm workspaces monorepo (Turborepo, Nx, whatever):
``bash
pnpm add -D @changesets/cli
pnpm changeset init
`
That creates a .changeset/ folder with a config.json`. Open it. The defaults are sensible, but there are two options you'll definitely want to know about.
The baseBranch key defaults to "main" — change it if your repo uses master or a release branch. More importantly, linked lets you declare packages that should always version together. If @myorg/ui and @myorg/react are always released in lockstep (they share an API surface), put them in a linked group and Changesets will keep them at the same version.
``json
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"linked": [["@myorg/ui", "@myorg/react"]],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}
``
The updateInternalDependencies field is subtle but important. Set it to "patch" and Changesets will automatically bump the dependency range in package.json when a sibling package is released — even if you didn't explicitly write a changeset for the consumer. Set it to "minor" if you're more conservative. In practice, "patch" is what most teams want.
One more thing — if you're on Yarn Berry, the workspace protocol (workspace:*) plays nicely with this. pnpm handles it out of the box. npm workspaces work too but the DX is marginally rougher.
Writing Changesets Day to Day
Here's the actual workflow. You open a PR, make your changes, and before you push you run:
``bash
pnpm changeset
`
The CLI walks you through an interactive prompt. Which packages changed? Is it a patch, minor, or major? Write a one-line summary. Done. A .changeset/random-words-here.md file gets committed with your PR.
`markdown
---
"@myorg/ui": minor
---
Add <FloatingPanel> component with configurable anchor and dismiss behavior.
``
That file lives in the repo alongside the code change. When someone reviews the PR, they can read exactly what the intended version impact is — no digging through CHANGELOG.md diffs or changelog commit messages. The changeset file *is* the intent.
The prompts can get slow if you have 30 packages and only touched one. That's a known friction point. Fix it with pnpm changeset --empty to create a blank changeset quickly, then edit the file by hand. Takes about 15 seconds once you know the format.
Quick aside: you can skip the interactive prompt entirely in CI or scripting contexts by passing --no-interactive — but that only works for automated flows, not human-driven PR changesets. For the daily developer loop the interactive prompt is actually the right default.
The Version and Publish Flow
Once you've accumulated changesets across several PRs and you're ready to cut a release, there are two commands.
First, pnpm changeset version. This reads all the .changeset/*.md files, figures out the correct semver bump for every package, updates every package.json, generates CHANGELOG.md entries, and deletes the changeset files. Commit the result.
``bash
pnpm changeset version
git add .
git commit -m "chore: version packages"
``
Then publish:
``bash
pnpm changeset publish
`
This runs npm publish (or your equivalent) for every package that has a new version, tags the Git commit, and pushes the tags. If you've got an .npmrc` with an auth token, it just works.
The two-step design is intentional. You get a chance to review the version bump PR before anything hits npm. A lot of teams automate changeset version via GitHub Actions on merge to main, creating a "Version Packages" PR automatically. You merge that PR when you're ready to ship — not before. That gives you a natural release gate without a manual checklist.
Honestly, this pattern is where Changesets really shines over alternatives like semantic-release. With semantic-release everything is automatic on merge, which is great until a breaking change accidentally goes out at 2 AM because someone used the wrong commit message prefix. Changesets makes the release *intentional* at every step.
Automating Releases with GitHub Actions
Here's a real workflow file. It runs on push to main, and either opens/updates the "Version Packages" PR or publishes if that PR is already merged.
``yaml
# .github/workflows/release.yml
name: Release
on:
push:
branches:
- main
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v3
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
registry-url: 'https://registry.npmjs.org'
- run: pnpm install --frozen-lockfile
- name: Create Release Pull Request or Publish
uses: changesets/action@v1
with:
publish: pnpm changeset publish
version: pnpm changeset version
commit: 'chore: version packages'
title: 'chore: version packages'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
``
The changesets/action GitHub Action handles the branching logic for you. When there are uncommitted changesets, it opens a PR titled "chore: version packages". When you merge that PR, it detects no pending changesets and runs the publish step instead. One workflow, two behaviors.
Make sure your NPM_TOKEN is a granular access token scoped to publish on the specific packages — not a legacy automation token if you can avoid it. As of 2024 npm deprecated the legacy token type in favor of granular ones with explicit package and IP scope. 4 hours debugging an auth failure taught me that the hard way.
If you use scoped packages like @myorg/ui, add "access": "public" in your config.json (shown earlier) or the publish step will fail silently with a 402 because npm treats scoped packages as private by default unless you have paid org access.
One more thing — the fetch-depth: 0 on the checkout action is not optional. Changesets needs the full Git history to figure out which packages changed since the last tag. Shallow clones break the version detection.
Pre-release Channels and Canary Builds
Sometimes you want to ship a beta or a canary before the stable release. Changesets has first-class support for pre-release mode.
``bash
# Enter pre-release mode
pnpm changeset pre enter beta
# Now changeset version produces 1.2.0-beta.0, 1.2.0-beta.1, etc.
pnpm changeset version
pnpm changeset publish --tag beta
# Exit pre-release mode when ready for stable
pnpm changeset pre exit
``
When you're in pre mode, Changesets accumulates all your changesets but releases them under the pre-release version tag. Consumers who run npm install @myorg/ui get the stable version. Only those who explicitly do npm install @myorg/ui@beta get the pre-release. This is the right behaviour for any public package.
The pre-release state is stored in .changeset/pre.json. Commit it. If you're on a long-running beta (say, a major redesign like moving from Tailwind v3 to v4), you'll be accumulating changesets over weeks — that file tracks where you are in the sequence. Don't gitignore it.
In practice, most small teams skip pre-release channels entirely and just ship to stable. That's fine. But if you're building something like a component library where downstream consumers have their own release cycles — think shipping glassmorphism components that other devs depend on — beta channels give consumers time to test before they're forced to upgrade. Worth the setup cost.
Common Gotchas and How to Fix Them
The most common problem: you run pnpm changeset version and nothing happens. That means there are no uncommitted changeset files in .changeset/. Check with ls .changeset/ — if it's just config.json, you forgot to run pnpm changeset during your feature work. You can add changesets retroactively before versioning, just write the markdown manually.
Second most common: packages publish but the wrong ones. This usually means a package is listed in "ignore" in config.json, or the package.json for that package has "private": true. Private packages are intentionally skipped by Changesets — correct for apps, wrong for shared lib packages. Remove private: true from any package you want to publish.
If you're using Turborepo (and you probably should be — see react-monorepo-turborepo for a full guide), make sure your publish command doesn't run through Turbo's task runner. Changesets needs to run publish directly to detect which packages were actually bumped. Use pnpm changeset publish, not turbo run publish.
Finally — CHANGELOG.md files get noisy fast. The default changelog format is fine but verbose. You can swap in @changesets/changelog-github to get PR links and author attribution automatically, which makes changelogs genuinely useful instead of just a list of semver numbers:
``bash
pnpm add -D @changesets/changelog-github
`
Then in config.json:
`json
{ "changelog": ["@changesets/changelog-github", { "repo": "your-org/your-repo" }] }
`
That single change makes your CHANGELOG.md` 10x more useful for everyone reading it.
FAQ
Probably not. Changesets shines in multi-package repos. A single package is fine with manual version bumps or a simpler tool like np.
Yes — the CLI is totally standalone. You can run changeset version and changeset publish locally from any CI or even your terminal. The GitHub Action is just automation sugar.
version updates package.json files and generates changelogs but doesn't touch npm. publish actually runs npm publish for every bumped package. Always run version first.
Run pnpm changeset and select all affected packages, marking each as major. Changesets will bump all of them to the next major version and cross-update their peer dependency ranges.