Migrating the package manager (yarn ↔ pnpm)
catladder supports yarn (classic and berry) and pnpm for the node build family, and autodetects which one a project uses. So the catladder side of this migration is nearly free — the work is in the repository: lockfile, workspace file, scripts, and the dependencies that only ever resolved because yarn hoisted them.
Typical motivation: pnpm is substantially faster in CI (a real project saw lint 584s → 187s, app build 470s → 228s, docker builds 2–3× faster on warm caches).
Do this on a branch and expect several iterations. The first pipeline will not be green. Plan for it instead of promising a one-shot migration.
What catladder detects, and what it generates
Detection order (packageManager in catladder.ts → packageManager
field in package.json → lockfile → yarn fallback):
// catladder.ts — only needed to override the detection
packageManager: "pnpm",
Prefer the packageManager field in the root package.json
("packageManager": "pnpm@11.6.0") — it drives local dev, corepack and
catladder at once, and catladder installs that version in the docker
build. Corepack's +sha512… integrity suffix is fine; catladder strips
it before using the version as an npm spec.
Once detected, catladder switches all of this automatically — no config change needed:
| yarn | pnpm | |
|---|---|---|
| CI install | yarn install --immutable | pnpm install --frozen-lockfile |
| download cache | .yarn zip cache | none — installing from the registry is cheaper than moving the store |
| node_modules cache | mutable slot per build dir | none (measured: the archive costs more than the install it saves) |
| docker prod install | yarn workspaces focus --production | pnpm install --prod --frozen-lockfile --filter <pkg>... |
| audit job | yarn npm audit … --all --recursive | pnpm audit --prod --audit-level critical |
| build/start defaults | yarn build / yarn start | pnpm build / pnpm start |
The audit job is the one place where the two are not equivalent in
practice: it is easy to have a yarn pipeline whose audit checked nothing
(before catladder 5 the generated command lacked --all --recursive), so
the pnpm job can turn up long-standing vulnerabilities. Read them as
pre-existing, not as caused by the migration.
Because the cache keys differ per package manager, the first pnpm pipeline runs cold (and rebuilds job images). Judge speed on the second run, not the first.
Step 0 — check the blockers
- node version — pnpm 11 requires node ≥ 22.13, pnpm 10 only
≥ 18.12. Check
.nvmrc(and any explicit node version in job images): the jobs switch with nvm, so an.nvmrcof20downgrades a node-22 image and pnpm 11 dies mid-install withERR_UNKNOWN_BUILTIN_MODULE: No such built-in module: node:sqlite. Either bump.nvmrcto 22 in the same change, or pin"packageManager": "pnpm@10.x". build: { type: "meteor" }— yarn-only in catladder; generation fails outright. That component blocks the migration.- Yarn PnP (
nodeLinker: pnp,.pnp.cjs) — the migration is bigger than a lockfile swap: everything that relied on PnP resolution changes. Say so before starting. - Yarn plugins (
.yarn/plugins,yarn workspaces focus,yarn-plugin-*) — no pnpm equivalent; each needs a decision. - catladder version — pnpm support landed in catladder 5. On an older version, upgrade first.
docker: { yarnRebuildEnabled }in a build config — yarn-only; it is silently ignored under pnpm. Remove it as cleanup.
Step 1 — inventory the yarn-isms
Grep the repo, not just catladder.ts. Every one of these needs a
decision before the lockfile changes:
grep -rn "yarn " --include=package.json --include="*.ts" --include="*.md" --include="*.yml" --include="*.yaml" --include=Dockerfile* .
Look at: package.json scripts, catladder.ts
(buildCommand, startCommand, postInstall, lint/test/audit
command, deploy command/script, customJobs), Dockerfiles,
husky hooks, lint-staged, .github/-adjacent tooling, and the README.
The mapping table is in
references/yarn-to-pnpm-differences.md
— read it before rewriting scripts; several of the entries there
(root-script fallback, shell globs, the pnpm run banner) have caused
silent, hard-to-debug breakage in production images.
Step 2 — write pnpm-workspace.yaml first
pnpm import requires the workspace file to exist already, or it
generates a lockfile for the root package only.
Translate, in one file:
packages: # from package.json "workspaces"
- "packages/*"
- "apps/*"
overrides: # from package.json "resolutions"
some-pkg: 1.2.3
patchedDependencies: # from yarn's .yarn/patches + resolutions patch: entries
some-pkg@1.2.3: patches/some-pkg.patch
allowBuilds: # deps whose install scripts may run (see step 4)
esbuild: true
prisma: true
minimumReleaseAge: 0 # pnpm 11 defaults to 1440 (min); 0 keeps yarn's behavior
On pnpm 11+ nearly all settings live in pnpm-workspace.yaml only —
pnpm.overrides in package.json and non-auth settings in .npmrc are
ignored (pnpm 10 read them; this reversal silently drops overrides).
.npmrc keeps auth and registry settings.
The yarn patches themselves (.yarn/patches/*.patch) can be reused as
files; move them somewhere neutral (patches/) and reference them from
patchedDependencies.
Step 3 — import the lockfile
pnpm import # yarn.lock -> pnpm-lock.yaml, keeping resolved versions
rm -rf yarn.lock node_modules **/node_modules .yarn/cache .yarn/install-state.gz
pnpm install
pnpm import is what keeps this migration honest: it preserves the
versions yarn had resolved instead of re-resolving every range. Do not
skip it and run a bare pnpm install — that silently upgrades hundreds
of transitive dependencies at the same time as the package manager
changes, and you will not know which change broke what.
Then set the packageManager field, and update .gitignore: .yarn/*
entries out (keep .yarn/patches only if you left the patches there).
Nothing pnpm-specific needs ignoring — the store lives outside the repo.
Step 4 — fix the fallout locally
Work through these before touching CI; every one of them fails the same way in the pipeline, only slower.
- Phantom dependencies — pnpm does not hoist. Every package your
code imports must be in its own
package.json. This is the single biggest source of failures, and the nastiest ones surface only in the docker image (a bundler happily resolved the hoisted copy at build time; the prod install does not ship it). Fix by declaring, never byshamefully-hoist. - Install scripts are not run unless approved.
pnpm installprints the list it skipped → add them toallowBuilds(pnpm 11;onlyBuiltDependencieslist on pnpm 10). Missing prisma, esbuild, sharp, playwright, husky, … show up as "works locally, fails in the image". - Root
prepareruns in the production install (docker), where devDependencies are absent:"prepare": "husky || true". - Quote your globs. yarn berry's built-in shell expands
**; pnpm'sshdoes not.node build.mjs ./entrypoints/**/*.tssilently builds fewer files → a container that boots without its entrypoint. Quote the pattern and let the tool expand it. pnpm runprints a banner to stdout — any script whose output is piped needspnpm --silent run ….- No root-script fallback: yarn berry falls back to the root
package's scripts, pnpm does not. Inline shared scripts into each
workspace (the root
node_modules/.binis on the script PATH).
Then, still locally: full build, typecheck, lint and test — and
docker build at least one generated Dockerfile and boot the image.
That catches phantom dependencies far faster than a pipeline round-trip.
Step 5 — regenerate the pipeline
Nothing in catladder.ts needs to change beyond the yarn-isms from
step 1 (and, optionally, an explicit packageManager: "pnpm").
pnpm catenv
Commit the regenerated .catladder-generated/, .gitlab-ci.yml and
.github/workflows/catladder-* with the rest — see catladder-config.
Review the diff: install commands, cache keys/paths, the default
build/lint/test commands and the docker install lines should have
flipped to pnpm everywhere. Anything still saying yarn is a command
you spelled out yourself in catladder.ts (step 1), not a default.
Step 6 — verify in CI
- The first pipeline is cold and rebuilds job images — slow by design.
- Expect the failures to arrive in waves: install → build → docker → deploy. A green build job says nothing about the images.
- If a docker job fails on
--frozen-lockfile, the cause is almost always a file missing from the build context (a workspace manifest, a patch file) or a lockfile that was not regenerated after a package.json edit. - Deploy at least one component to a review/dev environment and check it actually boots — bundlers hide missing dependencies until runtime.
- Compare timings against the second run.
Step 7 — hand over to the team
Tell the user what changes for everyone:
yarn <x>→pnpm <x>;yarn workspace a b→pnpm --filter a b;yarn dlx→pnpm dlx. The CLIs becomepnpm catenv/pnpm catladder.- new dependencies must be declared where they are imported — the hoisting safety net is gone
- a dependency with an install script needs an
allowBuildsentry - update the README / contributing docs and any developer onboarding script in the same MR
The other direction: pnpm → yarn
Mirrored, with one loss to state up front: there is no importer back.
yarn import only reads npm lockfiles, so yarn berry has to re-resolve
every range — the migration necessarily bumps transitive versions.
Do it deliberately:
- Delete
pnpm-lock.yaml,pnpm-workspace.yaml, and thenode_modulestrees. - Move
packages:back into package.jsonworkspaces,overrides→resolutions,patchedDependencies→ yarn's patch protocol,allowBuilds→ (yarn runs build scripts by default; nothing needed). yarn install, then re-run the whole local verification of step 4 — the version drift is the risk here, not the tooling.- Set
packageManager: "yarn@x.y.z", droppackageManager: "pnpm"fromcatladder.tsif it was set, runyarn catenv, commit the regenerated files.
Related skills
catladder-builds— thebuildconfig, pnpm gotchas per build type, workspace buildscatladder-config— catladder.ts structure,packageManager, regenerationcatladder-pipelines— caching, job images, debugging CIcatladder-migrate-ci-backend— moving between GitLab CI and GitHub Actionscatladder-migrate-release-method— switching between semantic-release and changesets