DEV Community

Cover image for From Lerna to Changesets
Matti Bar-Zeev
Matti Bar-Zeev

Posted on

From Lerna to Changesets

If you've been following the saga of my Pedalboard monorepo, you'll know that Lerna and I have a... complicated relationship. I originally reached for it in my No BS monorepo series to handle the two things monorepos constantly need: versioning and publishing. It worked, then it stopped being maintained, then it rose from the ashes as a phoenix, and here we are - still using it. But lately I've been looking at Changesets and wondering if it's time to finally make the switch.

What Lerna Actually Does in Pedalboard (at This Point)

This is worth being honest about, because Lerna's scope in this project has been narrowing for a while.
What's left for Lerna is captured in two blocks of lerna.json:

{
    "npmClient": "pnpm",
    "useNx": false,
    "command": {
        "publish": {
            "ignoreChanges": ["ignored-file", "*.md"],
            "message": "chore(release): publish %s"
        },
        "version": {
            "message": "chore(release): version",
            "allowBranch": "master",
            "conventionalCommits": true
        }
    },
    "packages": ["packages/*"],
    "version": "independent"
}
Enter fullscreen mode Exit fullscreen mode

That's it. Lerna's entire job, at this point:

  1. Detect which packages changed since the last release
  2. Bump their versions based on conventional commit messages (patch/minor/major)
  3. Tag the release in git
  4. Publish to npm via lerna publish --yes --no-verify-access

The root package.json confirms this - the only mention of lerna is a single script:

"publish:lerna": "lerna publish --yes --no-verify-access"
Enter fullscreen mode Exit fullscreen mode

A fully-grown monorepo tool, reduced to a very specific, yet very important, job: release management.

I wrote about this philosophical evolution in Rethinking the "One Ring To Rule Them all" Monorepo manager, the idea that one tool shouldn't do everything - each tool should own its lane. Lerna owns the release lane. But is it the best tool for that lane?

Enter Changesets

Changesets (technically @changesets/cli) takes a different approach to the same problem. Instead of reading conventional commit messages and auto-detecting what changed, it asks developers to be intentional: when you make a change, you run changeset and write a brief description of what changed and what kind of semver bump it warrants (patch, minor, major). That creates a small markdown file in a .changeset/ directory.

When it's time to release, you run changeset version to consume those files and bump the appropriate package versions, then changeset publish to push to npm.

The appeal, for me, is the explicitness. With Lerna's conventional commits approach, version bumps are inferred from commit messages - which means they're only as reliable as your commit discipline. If someone sneaks a breaking change in with a fix: prefix (we've all been there, don't lie), Lerna will happily publish a patch. Changesets sidesteps this by making the bump intent part of the PR itself.

Initializing Changesets

Enough theory. The initialization is one command:

pnpm dlx @changesets/cli init
Enter fullscreen mode Exit fullscreen mode

This creates a .changeset/ directory with a README.md and, more importantly, a config.json:

{
  "$schema": "https://unpkg.com/@changesets/config@3.1.3/schema.json",
  "changelog": "@changesets/cli/changelog",
  "commit": false,
  "fixed": [],
  "linked": [],
  "access": "restricted",
  "baseBranch": "main",
  "updateInternalDependencies": "patch",
  "ignore": []
}
Enter fullscreen mode Exit fullscreen mode

Not bad for a starting point, but two things need fixing immediately.

Two config issues, right off the bat

baseBranch: "main" - Pedalboard's branch is master, not main. Changesets uses this to determine what's changed since the last release. Wrong base means changeset status compares against the wrong thing:

"baseBranch": "master"
Enter fullscreen mode Exit fullscreen mode

access: "restricted" - This controls the npm access level. "restricted" means private packages, which is a sensible safe default, but all of Pedalboard's packages are public on npm:

"access": "public"
Enter fullscreen mode Exit fullscreen mode

Two lines. Two things I'm glad I noticed before pushing a release and wondering why nothing published.

The other defaults are fine: "commit": false means Changesets won't auto-commit the version bumps (CI handles that), and "updateInternalDependencies": "patch" means when a package is bumped, any internal packages that depend on it get a patch bump too.

A Good Time to Clean Up Dead Scripts

While looking at what Lerna does, I also noticed a publish:lerna:skip-git script sitting in package.json:

"publish:lerna:skip-git": "lerna publish --yes --no-verify-access --no-git-tag-version --no-push --loglevel=silly"
Enter fullscreen mode Exit fullscreen mode

The flags tell the story - --no-git-tag-version --no-push were there to let you test publishing locally without polluting git history. A debug escape hatch.

A quick search across the repo and all the blog posts confirmed it: nobody ever called it. Not the GitHub Actions workflow, not any other script, not a single post. It was just sitting there.

I considered whether to create a Changesets equivalent, but Changesets already separates versioning and publishing into two distinct commands, and with "commit": false in the config, changeset version doesn't touch git at all. If I ever need a no-tags publish, changeset publish --no-git-tag is one command away. No need to enshrine it as a named script until it earns its place.

Deleted. One less thing to explain.

Pulling Out Lerna Completely

With the scripts gone, time to finish the job.

Remove the lerna devDependency:

pnpm remove lerna -w
Enter fullscreen mode Exit fullscreen mode

That uninstalls it from the workspace root and cleans up pnpm-lock.yaml. Satisfying.

Delete lerna.json:

The config file that's been in this repo since the very beginning - independent versioning, conventional commits, allowBranch: master - none of it is relevant anymore. Deleted.

At this point, Lerna is fully gone from the project. No dependency, no config, no scripts. The repo doesn't know it ever existed. Changesets now owns the release lane.

Installing Changesets Properly

Using pnpm dlx to run Changesets is fine for a quick init, but not something you want in CI or for contributors who clone the repo. Time to make it a first-class devDependency:

pnpm add @changesets/cli -D -w
Enter fullscreen mode Exit fullscreen mode

The -w flag installs it at the workspace root, making it available across all packages. With it installed, the changeset binary lands in node_modules/.bin, so pnpm changeset just works.

For the versioning step, I added a named script - something that reads clearly in CI and in conversation. I considered just version, but that's a reserved npm lifecycle hook that runs before pnpm version. version:changeset is explicit and unambiguous:

"version:changeset": "changeset version"
Enter fullscreen mode Exit fullscreen mode

Running pnpm run version:changeset consumes all pending changeset files, bumps the affected package versions, and updates the CHANGELOGs. And I mean consume - the changeset files are deleted as part of the process. They're a one-time intent, not a log. Once applied, they're gone. If the changeset files aren't committed to git before you run this, there's no way to get them back. Lesson learned the slightly painful way.

A Mindset Shift: Intent Over Detection

This is where Changesets genuinely thinks differently from Lerna, and it took me a moment to internalize.

With Lerna's conventional commits approach, version bumps were detected - Lerna would look at what changed in git and infer a bump from the commit message. Automatic. You made commits, Lerna figured out the rest.

Changesets doesn't detect anything. It only knows what you tell it. When you run pnpm changeset, it asks you to explicitly select which packages are affected and what kind of bump they warrant. No git inspection, no inference. Pure intent.

The practical consequence: infra-only changes don't need a changeset at all. This whole migration, removing Lerna, adding Changesets, updating scripts, touched the root package.json and the CI config. None of the published packages changed. No new features, no bug fixes, nothing that affects consumers. So no changeset is needed, and no packages will be bumped. That's correct behaviour.

This is very different from Lerna, where a commit like chore: migrate from Lerna to Changesets touching the root would still be scanned and might trigger bumps depending on how it interpreted the scope.

The flip side is the cascade effect. When you do create a changeset for a package that others depend on, Changesets will automatically bump those dependents too - controlled by "updateInternalDependencies": "patch" in the config. So if @pedalboard/hooks gets a minor bump, @pedalboard/components (which depends on it) gets a patch bump to update its dependency range. It cascades through the graph.

Explicit intent in, correct cascade out. Once you accept that the changeset file is the release decision - not a side effect of a commit - the whole model clicks.

The Publish Script

With versioning covered, publishing needs its own script:

"publish:changeset": "changeset publish"
Enter fullscreen mode Exit fullscreen mode

changeset publish does three things in sequence: checks which packages have a version that hasn't been published to npm yet, publishes those, and creates and pushes a git tag for each one (e.g. @pedalboard/hooks@1.2.3).

That last part, the git tag push, means the CI runner needs a git identity configured before this script runs. The existing workflow already has this from the Lerna days:

- run: |
    git config --local user.name 'github-actions[bot]'
    git config --local user.email 'github-actions[bot]@users.noreply.github.com'
Enter fullscreen mode Exit fullscreen mode

That step stays. Changesets needs it for the same reason Lerna did.

Enforcing the Changeset Discipline in CI

One thing Lerna's conventional commits approach gave for free: it simply wouldn't bump a package if no relevant commit touched it. With Changesets, the responsibility is on the developer. Which raises a fair question: what stops someone from opening a PR that modifies a package and forgetting to create a changeset?

Changesets has an answer: changeset status --since=origin/master exits non-zero if any packages have changed without a covering changeset file. You can wire it into CI as a gate that fails early, before lint and tests even run. For now I'm leaving it out - I will take care of it in the future.

Updating the GitHub Actions Workflow

With all the pieces in place, the final step is the workflow itself. The complete updated npm-publish.yml:

name: Build and publish

on:
    push:
        branches:
            - master
    workflow_dispatch:
    release:
        types: [created]

jobs:
    build:
        runs-on: ubuntu-latest
        steps:
            - uses: actions/checkout@v4
              with:
                  fetch-depth: 0
            - uses: pnpm/action-setup@v4
            - uses: actions/setup-node@v4
              with:
                  node-version: 20
            - run: pnpm install
            - run: pnpm run lint:since
            - run: pnpm run test:since
            - run: pnpm run build

            # Publish to NPM
            - uses: actions/setup-node@v4
              with:
                  node-version: 20
                  registry-url: https://registry.npmjs.org/
            - run: |
                  git config --local user.name 'github-actions[bot]'
                  git config --local user.email 'github-actions[bot]@users.noreply.github.com'
            # Don't run custom Git hooks
            - run: git config --local core.hooksPath .git/hooks
            - run: pnpm run version:changeset
            - run: git add -A
            # Skip commit and push if version:changeset produced no changes (e.g. no pending changesets)
            - run: |
                  git diff --staged --quiet || (git commit -m "chore(release): version" && git push)
            - run: pnpm run publish:changeset
              env:
                  NODE_AUTH_TOKEN: ${{ secrets.npm_token }}
Enter fullscreen mode Exit fullscreen mode

A few things worth noting in the publish sequence. version:changeset must run before publish:changeset - it's what consumes the changeset files, bumps package.json versions, and updates the CHANGELOGs. Without it, changeset publish has nothing new to publish (it only publishes versions not yet on npm).

Because version:changeset modifies files, those changes need to be committed and pushed back to the repo before publishing. The single run step handles both: git diff --staged --quiet exits zero if nothing changed, short-circuiting the rest - so commit and push are skipped entirely if there were no pending changesets. If there were changes, it commits and pushes in one go. The | block scalar is needed because YAML would misinterpret the || shell operator as a nested mapping otherwise.

Everything else - the git identity config, the hook bypass, the fetch-depth: 0 for full git history, the master-only trigger - carries over unchanged. Lerna is gone, Changesets is in.

Wrapping Up

This migration started as a question - "is Lerna still the right tool for this one job?" - and ended with a cleaner, more intentional release flow.

Here's what changed:

  • lerna and lerna.json are gone
  • @changesets/cli is a proper devDependency
  • Two new scripts in package.json: version:changeset, publish:changeset
  • One line changed in the GitHub Actions workflow

But the bigger takeaway is the mental model shift. Lerna inferred release intent from git history. Changesets makes you declare it explicitly. That sounds like more work, and honestly - it is, slightly. But it's the right work. The changeset file becomes part of your PR, reviewed alongside the code, describing the user-facing impact. The version bump is a deliberate decision, not an inference.

Would I recommend this migration for every monorepo? If your release discipline is solid and conventional commits work for you, Lerna (or its successors) might be fine. But if you want version bumps to be explicit, reviewable, and decoupled from commit message conventions - Changesets is worth the switch.

As always, the full code is in the Pedalboard repository on GitHub. Have you made this migration yourself, or are you sticking with Lerna? I'd love to hear how others are handling it.


Related posts:

Top comments (0)