Skip to main content

Changesets: versioning a monorepo without remembering anything

· 11 min read
Pere Pages
Software Engineer
Paper notes pegged to a clothesline that leads down to a stack of four parcels; dotted arrows link the parcels to each other, and three of them wear a fresh ribbon

Releasing several packages from one repository means answering the same three questions every time: what changed, how big was it, and which other packages need to move because of it. Changesets lets you answer them once, in the same commit as the change, and works the rest out on release day.

The mental model: write the note now, do the arithmetic later

Changesets splits a release into two moments that usually get tangled together. While you work, you leave a small note saying which packages a change touches and how big it is. On release day, the tool reads every note that has piled up and turns them into version numbers and changelog entries.

Everything else in this post is detail hanging off that split. The notes are plain markdown files committed next to the code. The arithmetic follows semantic versioning (semver), the major.minor.patch scheme where a fix bumps the last number, a new feature the middle one and a breaking change the first. And the tool is deliberately narrow: it decides versions and writes changelogs, and that is all it has to do here. Publishing is someone else's job.

The example running through the post is a small design system in a pnpm workspace with four packages, all starting at 0.1.0:

  • @acme/tokens holds the design tokens: colours, spacing, radii.
  • @acme/css is the plain Cascading Style Sheets (CSS) build, and depends on tokens.
  • @acme/react is the component library, and also depends on tokens.
  • @acme/icons is the icon set, and depends on nothing.

The tool itself is one dev dependency, @changesets/cli (a command-line interface, CLI), plus a .changeset/ folder holding its config.json and, between releases, the notes.

The flow in four steps

A release is four steps, and only the first one happens more than once.
  1. Record the change. You add something a consumer will notice — a new component, a new token, a breaking rename — and run pnpm changeset. It asks which packages are affected, whether each bump is a patch, a minor or a major, and for one sentence to put in the changelog. It writes a small markdown file into .changeset/, and you commit that file together with the work. Notes pile up across as many commits as it takes.
  2. Version the packages. On release day you run pnpm version-packages. Changesets reads every file in .changeset/, bumps each named package, writes the sentences into each package's CHANGELOG.md, and deletes the notes. You commit the result as something like "Version packages: 0.2.0".
  3. Tag and push. You tag the commit v0.2.0 and push the tag. A continuous integration (CI) workflow, publish.yml, packs each package and publishes every version that isn't on the npm registry yet.
  4. Nothing. There is no changelog to write and no dependency range to edit by hand. Both were derived from the notes.

The two commands are thin aliases in the root package.json, so nobody has to remember the tool's own subcommands:

{
"scripts": {
"changeset": "changeset",
"version-packages": "changeset version"
}
}

What a changeset actually is

A changeset is a markdown file with a tiny header. The header, written in YAML (the indentation-based config format), lists packages and bump sizes; the body is the changelog sentence.

---
"@acme/react": minor
"@acme/tokens": minor
---

Add the Sheet component and the elevation tokens it uses

The CLI gives the file a random name such as .changeset/brave-lions-dance.md. Rename it to sheet.md if you prefer; the name carries no meaning.

Storing the note as a file is a design choice, and the project explains it: a file can be edited after the fact and survives squashing or rewriting commits, which a convention based on commit messages does not. It also means the note goes through code review with the change it describes, so "is this really only a patch?" gets asked while everyone still remembers what the change was.

The bump size is the only judgment call, and it is the ordinary semver one:

BumpMeaning for a consumerDesign-system example
patchA fix; nothing new to learnCorrect a focus-ring colour
minorSomething new; old code keeps workingAdd a Sheet component or a token
majorOld code may breakRename a token or remove a prop

Release day, worked through

Suppose one note is waiting at release time: the sheet.md above, giving react and tokens a minor each. Running pnpm version-packages produces this:

PackageBeforeAfterWhy
@acme/tokens0.1.00.2.0Named in the changeset as minor
@acme/react0.1.00.2.0Named in the changeset as minor
@acme/css0.1.00.1.1Nobody named it — it depends on tokens
@acme/icons0.1.00.1.0Untouched, and depends on nothing that moved

The first two rows behave as expected: each package got the bump its note asked for. Had several notes named the same package, Changesets would have applied only the biggest bump among them — two minors and a patch make one minor release — while still writing one changelog line per note.

The interesting rows are css, which moved without anyone asking, and icons, which didn't move although a release happened. Both are consequences of how the packages keep track of each other.

How the packages track each other

Three mechanisms cooperate, each owned by a different tool: pnpm fills in the dependency ranges, Changesets bumps the dependents, and the repository's versioning policy decides what stays still.

workspace:^ — the range you never type

Inside the repo, css and react don't say which version of tokens they need. They say workspace:^:

{
"name": "@acme/react",
"version": "0.2.0",
"dependencies": {
"@acme/tokens": "workspace:^"
},
"devDependencies": {
"@acme/icons": "workspace:*"
}
}

workspace:^ means "the tokens package in this repo, whatever version it is right now". That string never reaches a consumer. When pnpm packs the package for publishing, it replaces the workspace protocol with a real range built from the version at that moment. tokens is 0.2.0 when react@0.2.0 is packed, so the published manifest says:

{
"dependencies": {
"@acme/tokens": "^0.2.0"
}
}

The workspace:* on the devDependencies line would become an exact 0.2.0-style pin by the same rule, but consumers never install another package's dev dependencies, so it doesn't matter what it turns into.

This is a recent convenience. pnpm has supported the workspace:^ shorthand since the 6.2 releases, and Changesets has understood it since @changesets/cli 2.18.0; on anything older, write the full workspace:^0.1.0 form instead.

Dependents get a patch, so nobody ships a stale range

css moved because its published range stopped being true. css@0.1.0 went to npm depending on @acme/tokens@^0.1.0. Below 1.0.0, a caret range pins the minor version: ^0.1.0 accepts 0.1.5 but not 0.2.0. So the new tokens release fell outside what the published css accepts, even though the css in the repo had been building against those tokens for weeks.

Changesets checks for exactly this. When a bump takes a package out of a dependent's range, the dependent gets a patch release of its own, and its changelog says why:

## 0.1.1

### Patch Changes

- Updated dependencies
- @acme/tokens@0.2.0

That patch is the whole point: installing css@0.1.1 pulls in the tokens it was actually built against. react depended on tokens too, but it was already getting a minor, so no extra bump was needed — it only gained the same "Updated dependencies" line.

A config option covers the quieter case where the new version stays inside the range. updateInternalDependencies in .changeset/config.json decides how eagerly internal ranges are rewritten:

{
"changelog": "@changesets/cli/changelog",
"commit": false,
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}

With "patch", the default, a dependent that is being released always points at the newest version of its internal dependencies, even when only a patch separates them. With "minor", ranges are left alone until the dependency moves by at least a minor, which lets consumers share one copy of a package across more of your releases. For a small design system, "patch" is the safer choice: the versions people install together are the versions that were tested together.

Independent versions, not one shared number

Each package has its own version. After the release the repo holds tokens@0.2.0, css@0.1.1, react@0.2.0 and icons@0.1.0, and that spread is correct, not untidy.

icons didn't change and depends on nothing that did, so it wasn't bumped and wasn't republished; publish.yml finds 0.1.0 already on the registry and skips it. The workflow is short because pnpm's recursive publish only ever uploads versions the registry doesn't have:

name: publish
on:
push:
tags: ['v*']
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: pnpm/action-setup@v2
with:
version: 6
- uses: actions/setup-node@v2
with:
node-version: 16
registry-url: 'https://registry.npmjs.org'
- run: pnpm install --frozen-lockfile
- run: pnpm -r build
- run: pnpm -r publish --access public --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

With independent versions the v0.2.0 tag is a release marker, not a claim that every package is at 0.2.0. The compatibility contract between the packages is their dependency ranges, not matching numbers. A consumer who installs react@0.2.0 gets a compatible tokens because the range says so, whatever number icons happens to be on.

If you'd rather have related packages share their bumps, Changesets has a linked option for that. It trades accuracy for tidiness: packages get releases that contain no change of their own.

The one rule that keeps it honest

If a change matters to a consumer, write a changeset in the same commit. If it doesn't, don't.

ChangeChangeset?
New component, new token, new propYes
Bug fix in shipped codeYes
Breaking rename or removalYes
Docs, tests, storiesNo
CI config, repo tooling, internal refactorsNo

The rule matters because of how literal release day is: everything sitting in .changeset/ at that moment becomes the next version, and nothing else does. A forgotten note means a change ships with no version bump and no changelog line, or doesn't ship at all because nothing told the package to move. A needless note means a release whose changelog tells consumers about something they can't observe.

The short version

  • Changesets separates recording a change (a markdown note, committed with the work) from releasing it (one command that turns the notes into versions and changelogs).
  • pnpm changeset while you work; pnpm version-packages on release day; tag and push; the CI workflow publishes whatever npm doesn't have yet.
  • Several notes for one package collapse into the single biggest bump, with one changelog line each.
  • workspace:^ keeps internal ranges out of your hands: pnpm writes the real range at pack time.
  • A dependent whose published range no longer fits gets an automatic patch, so every published package pulls in the internal versions it was built against.
  • Versions are independent. Unchanged packages stay put and aren't republished; the ranges carry the compatibility, not the numbers.