Skip to content

Replace Lerna with npm workspaces, Turborepo and Changesets - #38

Merged
stefanoverna merged 2 commits into
mainfrom
chore/replace-lerna-with-npm-workspaces-turborepo-changesets
Aug 25, 2026
Merged

Replace Lerna with npm workspaces, Turborepo and Changesets#38
stefanoverna merged 2 commits into
mainfrom
chore/replace-lerna-with-npm-workspaces-turborepo-changesets

Conversation

@stefanoverna

Copy link
Copy Markdown
Member

Replaces Lerna 4 with npm workspaces + Turborepo + Changesets, following
datocms/plugins-sdk and datocms/cli. This is the third and last of the three
monorepos.

lerna publish commits, tags and pushes to GitHub before it publishes to npm,
so a failure on the npm side left a tag pointing at a version nobody could
install. And there was no changelog at all. Both are fixed here; the first
release on the new tooling is still a human's call.

lerna bootstrap — removed in Lerna 7 — was still wired into both the build
script and CI.

The decisions, and why

Independent versioning, and this time it will actually happen. The
declared model and the practice disagreed: lerna.json said independent while
all ten packages sat at 6.0.0. That is not a contradiction to resolve by
picking a side — it is a switch that never took effect. Commit 5974555 (25 May,
three months ago) made it, and said why:

Stops forced major bumps of unchanged packages when only one package ships a
breaking change (e.g. the recent html-to-structured-text v6, which dragged 8
sibling packages to 6.0.0 for nothing).

From the next lerna publish onward each package versions on its own and tags
become <package>@x.y.z.

There has been no release since, so the ten identical 6.0.0s are the residue
of the fixed mode that came before that commit, not evidence of practice under
it. Changesets is independent by default, so this preserves both the declaration
and the intent. A changesets fixed group would have restored exactly the
lockstep 5974555 set out to stop.

Rehearsed rather than reasoned about, on a throwaway copy:

changeset result
patch on to-markdown to-markdown 6.0.1. The other nine untouched, and only one CHANGELOG.md written.
minor on utils (the root of the graph) utils 6.1.0. Nothing else moves — see below.
major on utils utils 7.0.0, the other nine to 6.0.1, and the internal ranges rewritten transitively (to-markdown is two levels down and got ^7.0.0).

Tags become per package (datocms-structured-text-utils@6.1.0), the
changesets default, the scheme the other two monorepos now use, and — not
incidentally — the scheme 5974555 was expecting. The 109 historical vX.Y.Z
tags stay exactly where they are; nothing is deleted or moved. git describe
will start answering differently
, which is user-visible and the same in all
three repos now.

publish-next uses changeset publish --tag next (the current versions
under the next dist-tag), not changeset pre enter. Prerelease mode is a flag
someone can forget to exit; this repo has no long-lived prerelease line. Real
prerelease versions are still available by running npx changeset pre enter next
first, and npm run publish refuses to start while .changeset/pre.json exists.

The release script

bin/publish.mjs. Everything fallible — build, tests — runs before anything
irreversible, and changeset publish publishes to npm first and then tags only
the packages npm accepted, so a tag cannot outlive a failed publish. There is no
rollback because every step is idempotent: the publish skips versions already on
the registry, tagging skips existing tags, each GitHub release skips itself. An
interrupted release is resumed by re-running it.

Each release now also gets GitHub release notes — one per tag, taken verbatim
from the changelog section changesets just wrote.

One change against the plugins-sdk original, forced by independent versioning:
the commit subject. That script spelled the full name@version list into the
subject, which is fine for two packages moving in lockstep and not fine here — a
release touching all ten came to 442 characters. The list moved to the commit
body, and the subject keeps a short exact form: release: <name>@<version> for a
single package, release: v6.0.1 when everything landed on one version,
release: 10 packages otherwise.

The per-package lockfiles were already broken

Deleted, replaced by the single root lockfile. They were not carrying anything
worth preserving — four of them pinned an internal dependency outside the range
its own manifest declared:

package declared lockfile pinned
generic-html-renderer datocms-structured-text-utils@^6.0.0 1.1.1
to-plain-text …@^6.0.0 (and the renderer) 1.1.1
dastdown …@^6.0.0 5.1.11
html-to-structured-text …@^6.0.0 5.1.16
to-markdown no lockfile at all

So several packages were compiling against stale copies of utils pulled from
the registry. Under workspaces the internal dependencies are symlinks and the
packages finally build against each other.

Captured every declared dependency's resolution per package before deleting, and
diffed after. Everything stayed inside its declared range. The movers worth
naming: @datocms/cma-client 5.4.9/5.4.10 → 5.8.0 (a devDependency, types only —
this is the same bump that broke the importers in datocms/cli, and here
dastdown's cma-client-compat suite covers it), lodash-es 4.17.23 → 4.18.1,
and some @types/*.

Behaviour change worth knowing

Changesets bumps a dependent only when the new version falls outside its declared
range (@changesets/assemble-release-plan). Every internal range here is
^6.0.0, so a minor in datocms-structured-text-utils will no longer
republish the other nine
the way lerna version did — rehearsal 2 above. A
major still cascades, because the range genuinely breaks. Less churn, and
consumers still resolve the new utils. Exact Lerna parity would need
___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH.updateInternalDependents, and
the name is the argument against it.

Contributors need Node 22

.nvmrc is new, at 22, and the CI matrix moves from 18/20/22 to 22/24.
@changesets/cli@3 declares ^22.11 || ^24 || >=26 and on Node 20 dies with
enableCompileCache is not a function, which says nothing about versions.
Verified precisely: on Node 20 npm ci, the build and the tests all still work
(with EBADENGINE warnings) — it is only the release tooling that will not run.
Node 18 has been EOL since April 2025 and Node 20 since April 2026.
Published packages are unaffected.

The second commit: formatting

Separate commit, easy to skip. 19 source files had drifted out of Prettier
style, because the pre-commit hook is pretty-quick --staged and only ever
formats what someone happened to touch. Two things kept it that way:

  • .prettierignore now excludes .turbo. Prettier does not read .gitignore,
    so the moment this PR introduced Turborepo, npm run prettier began rewriting
    the JSON manifests inside Turborepo's own cache. This one is a bug this PR
    would otherwise have shipped.
  • the prettier script now covers js,mjs,md,yml as well as ts,tsx,json,
    matching what the pre-commit hook already formats. The two globs disagreeing is
    why prettier.config.js was itself unformatted.

Diagnosis: no drift to clean up

  • 109 local tags == 109 remote tags, no difference in either direction;
  • every package's local version matches its latest on npm (all ten at 6.0.0);
  • nothing published-but-untagged, tagged-but-unpublished, or abandoned.

Verification

Run locally on Node 22.22.3 / npm 10.9.8. Everything below was actually run.

  1. npm ci from the regenerated lockfile — clean, and all ten packages symlinked
    into the root node_modules.
  2. Cold build, every dist/ deleted first — 10/10, every package emits both
    dist/cjs and dist/esm plus declarations.
  3. Warm build — FULL TURBO.
  4. Order check: dist/ wiped, turbo run build --filter=datocms-structured-text-to-markdown --force
    builds utils, then generic-html-renderer, then to-markdown, and nothing
    else — the three-level chain in the right order.
  5. npm test — 12 suites, 448 tests, 84 snapshots, all passing. It needs no
    credentials and hits no live services, so the release gate is one anyone can
    actually pull.
  6. No published artifact changes. Before the formatting commit, all ten
    packages built a dist/ byte-identical to their published 6.0.0 tarballs,
    which is why the migration carries no changeset. After it, the diffs are
    whitespace only: reformatted src/ (which is in files), shifted sourcemaps,
    and one wrapped line surviving into contentful-to-structured-text's emitted
    handlers.js. Inspected by eye; no semantic change.
  7. Release rehearsal on an rsync'd throwaway copy with node_modules cloned
    in: the three changeset version runs in the table above, plus the real
    publishPlan() from bin/publish.mjs against the live registry (10 entries,
    parsed correctly), plus the release-notes body it would post, read by eye.
  8. Preflight refusals, each triggered on purpose: unknown option, --tag
    with no value, .changeset/pre.json present, dirty tree, wrong branch. All
    five abort with their own message before mutating anything.
  9. The first-release trap: no package in this repo has a CHANGELOG.md yet,
    so the release-notes lookup finds nothing on the very first release. Confirmed
    it falls back to Released `name@version`. instead of failing.
  10. npx prettier --check across the repo — clean.

Not verified

  • Nothing was published, tagged or released. changeset publish was never
    run, here or in the rehearsal.
  • The CI workflow itself has not run on GitHub; the Node 24 leg in particular has
    only been reasoned about, not executed. Everything above is Node 22 locally.
  • gh release create was never executed — only the body it would receive was
    generated and read.

Left alone, deliberately

  • packages/to-markdown/REVIEW.md still refers to lerna.json. It is a dated
    review note about a past state of the repo, not live documentation, so I did
    not rewrite it. Say the word if you would rather it go.
  • The root README's package list is missing dastdown, to-markdown and
    slate-utils, and CLAUDE.md said "9 packages" when there are ten. I fixed
    the count and the package inventory in CLAUDE.md since I was rewriting its
    commands anyway, but left the README list alone as out of scope.

Lerna 4 (2021) with `lerna bootstrap`, a command removed in Lerna 7, was
still wired into the build script and CI. Two things it got wrong:

- `lerna publish` commits, tags and pushes to GitHub *before* it publishes
  to npm, so a failure on the npm side left a tag pointing at a version
  nobody could install, and no way forward except by hand;
- there was no changelog at all.

`bin/publish.mjs` replaces it. Everything fallible — build, tests — runs
before anything irreversible, and `changeset publish` publishes to npm
first and then tags only the packages npm actually accepted. There is no
rollback because every step is idempotent: the publish skips versions
already on the registry, tagging skips existing tags, and each GitHub
release skips itself. An interrupted release is resumed by re-running it.

Versioning stays independent, which is what "Switch lerna to independent
versioning" (5974555) asked for and what this repo has never actually
done: the ten packages all sitting at 6.0.0 are the residue of the fixed
mode that came before it. A changesets `fixed` group would have restored
exactly the lockstep that commit set out to stop. Rehearsed: a patch on
to-markdown moves to-markdown alone.

Tags become per package (`datocms-structured-text-utils@6.1.0`), the
changesets default and the scheme the other two monorepos now use. The
109 historical `vX.Y.Z` tags are left exactly where they are, but
`git describe` will start answering differently.

Each release now also gets GitHub release notes, one per tag, taken
verbatim from the changelog section changesets just wrote.

The per-package lockfiles are deleted; one root lockfile covers the
workspace. They were already broken — `generic-html-renderer` and
`to-plain-text` pinned `datocms-structured-text-utils@1.1.1` against a
`^6.0.0` declaration, `dastdown` 5.1.11, `html-to-structured-text`
5.1.16, and `to-markdown` had no lockfile at all. Under workspaces the
internal dependencies are symlinks, so the packages finally build
against each other rather than against stale copies from the registry.

Every declared dependency stayed inside its declared range, and all ten
packages emit a `dist/` byte-identical to their published 6.0.0
tarballs, so nothing here needs a changeset.

CI drops the `lerna bootstrap` step and moves to Node 22/24: both 18 and
20 are past end-of-life, and `@changesets/cli` declares
`^22.11 || ^24 || >=26` — on Node 20 it dies with `enableCompileCache is
not a function`, which says nothing about versions. `npm ci`, the build
and the tests still work there; the release tooling does not. Published
packages are unaffected.
…urbo's cache

19 source files had drifted out of Prettier style. The pre-commit hook
runs `pretty-quick --staged`, so only files someone happened to touch
were ever formatted, and the rest accumulated.

Two things kept it that way, both fixed here:

- `.prettierignore` now excludes `.turbo`. Prettier does not read
  `.gitignore`, so as soon as the previous commit introduced Turborepo,
  `npm run prettier` started rewriting the JSON manifests inside
  Turborepo's own local cache.
- the `prettier` script now covers `js,mjs,md,yml` as well as
  `ts,tsx,json`, which is what the pre-commit hook already formats. The
  two globs disagreeing is why `prettier.config.js` was itself
  unformatted.

No behaviour change. The tests pass unchanged, snapshots included. Note
that `files` includes `src`, so this does alter the published tarballs:
`src/` is reformatted, the sourcemaps shift, and in
`contentful-to-structured-text` one wrapped source line survives into
the emitted `handlers.js`. All whitespace — no changeset, it rides along
with the next real release.
@stefanoverna
stefanoverna merged commit 4200669 into main Aug 25, 2026
2 checks passed
@stefanoverna
stefanoverna deleted the chore/replace-lerna-with-npm-workspaces-turborepo-changesets branch August 25, 2026 14:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant