Replace Lerna with npm workspaces, Turborepo and Changesets - #38
Merged
stefanoverna merged 2 commits intoAug 25, 2026
Merged
Conversation
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
deleted the
chore/replace-lerna-with-npm-workspaces-turborepo-changesets
branch
August 25, 2026 14:04
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces Lerna 4 with npm workspaces + Turborepo + Changesets, following
datocms/plugins-sdkanddatocms/cli. This is the third and last of the threemonorepos.
lerna publishcommits, 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 thebuildscript and CI.
The decisions, and why
Independent versioning, and this time it will actually happen. The
declared model and the practice disagreed:
lerna.jsonsaidindependentwhileall ten packages sat at
6.0.0. That is not a contradiction to resolve bypicking a side — it is a switch that never took effect. Commit 5974555 (25 May,
three months ago) made it, and said why:
There has been no release since, so the ten identical
6.0.0s are the residueof 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
fixedgroup would have restored exactly thelockstep 5974555 set out to stop.
Rehearsed rather than reasoned about, on a throwaway copy:
patchonto-markdownto-markdown6.0.1. The other nine untouched, and only oneCHANGELOG.mdwritten.minoronutils(the root of the graph)utils6.1.0. Nothing else moves — see below.majoronutilsutils7.0.0, the other nine to 6.0.1, and the internal ranges rewritten transitively (to-markdownis two levels down and got^7.0.0).Tags become per package (
datocms-structured-text-utils@6.1.0), thechangesets default, the scheme the other two monorepos now use, and — not
incidentally — the scheme 5974555 was expecting. The 109 historical
vX.Y.Ztags stay exactly where they are; nothing is deleted or moved.
git describewill start answering differently, which is user-visible and the same in all
three repos now.
publish-nextuseschangeset publish --tag next(the current versionsunder the
nextdist-tag), notchangeset pre enter. Prerelease mode is a flagsomeone can forget to exit; this repo has no long-lived prerelease line. Real
prerelease versions are still available by running
npx changeset pre enter nextfirst, and
npm run publishrefuses to start while.changeset/pre.jsonexists.The release script
bin/publish.mjs. Everything fallible — build, tests — runs before anythingirreversible, and
changeset publishpublishes to npm first and then tags onlythe 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-sdkoriginal, forced by independent versioning:the commit subject. That script spelled the full
name@versionlist into thesubject, 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 asingle package,
release: v6.0.1when everything landed on one version,release: 10 packagesotherwise.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:
generic-html-rendererdatocms-structured-text-utils@^6.0.0to-plain-text…@^6.0.0(and the renderer)dastdown…@^6.0.0html-to-structured-text…@^6.0.0to-markdownSo several packages were compiling against stale copies of
utilspulled fromthe 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-client5.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 heredastdown'scma-client-compatsuite covers it),lodash-es4.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 aminorindatocms-structured-text-utilswill no longerrepublish the other nine the way
lerna versiondid — rehearsal 2 above. Amajorstill cascades, because the range genuinely breaks. Less churn, andconsumers still resolve the new
utils. Exact Lerna parity would need___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH.updateInternalDependents, andthe name is the argument against it.
Contributors need Node 22
.nvmrcis new, at 22, and the CI matrix moves from 18/20/22 to 22/24.@changesets/cli@3declares^22.11 || ^24 || >=26and on Node 20 dies withenableCompileCache 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
EBADENGINEwarnings) — 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 --stagedand only everformats what someone happened to touch. Two things kept it that way:
.prettierignorenow excludes.turbo. Prettier does not read.gitignore,so the moment this PR introduced Turborepo,
npm run prettierbegan rewritingthe JSON manifests inside Turborepo's own cache. This one is a bug this PR
would otherwise have shipped.
prettierscript now coversjs,mjs,md,ymlas well asts,tsx,json,matching what the pre-commit hook already formats. The two globs disagreeing is
why
prettier.config.jswas itself unformatted.Diagnosis: no drift to clean up
lateston npm (all ten at 6.0.0);Verification
Run locally on Node 22.22.3 / npm 10.9.8. Everything below was actually run.
npm cifrom the regenerated lockfile — clean, and all ten packages symlinkedinto the root
node_modules.dist/deleted first — 10/10, every package emits bothdist/cjsanddist/esmplus declarations.FULL TURBO.dist/wiped,turbo run build --filter=datocms-structured-text-to-markdown --forcebuilds
utils, thengeneric-html-renderer, thento-markdown, and nothingelse — the three-level chain in the right order.
npm test— 12 suites, 448 tests, 84 snapshots, all passing. It needs nocredentials and hits no live services, so the release gate is one anyone can
actually pull.
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 infiles), shifted sourcemaps,and one wrapped line surviving into
contentful-to-structured-text's emittedhandlers.js. Inspected by eye; no semantic change.node_modulesclonedin: the three
changeset versionruns in the table above, plus the realpublishPlan()frombin/publish.mjsagainst the live registry (10 entries,parsed correctly), plus the release-notes body it would post, read by eye.
--tagwith no value,
.changeset/pre.jsonpresent, dirty tree, wrong branch. Allfive abort with their own message before mutating anything.
CHANGELOG.mdyet,so the release-notes lookup finds nothing on the very first release. Confirmed
it falls back to
Released `name@version`.instead of failing.npx prettier --checkacross the repo — clean.Not verified
changeset publishwas neverrun, here or in the rehearsal.
only been reasoned about, not executed. Everything above is Node 22 locally.
gh release createwas never executed — only the body it would receive wasgenerated and read.
Left alone, deliberately
packages/to-markdown/REVIEW.mdstill refers tolerna.json. It is a datedreview 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.
dastdown,to-markdownandslate-utils, andCLAUDE.mdsaid "9 packages" when there are ten. I fixedthe count and the package inventory in
CLAUDE.mdsince I was rewriting itscommands anyway, but left the README list alone as out of scope.