Skip to main content

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.tspackageManager 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:

yarnpnpm
CI installyarn install --immutablepnpm install --frozen-lockfile
download cache.yarn zip cachenone — installing from the registry is cheaper than moving the store
node_modules cachemutable slot per build dirnone (measured: the archive costs more than the install it saves)
docker prod installyarn workspaces focus --productionpnpm install --prod --frozen-lockfile --filter <pkg>...
audit jobyarn npm audit … --all --recursivepnpm audit --prod --audit-level critical
build/start defaultsyarn build / yarn startpnpm 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 .nvmrc of 20 downgrades a node-22 image and pnpm 11 dies mid-install with ERR_UNKNOWN_BUILTIN_MODULE: No such built-in module: node:sqlite. Either bump .nvmrc to 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 onlypnpm.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.

  1. 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 by shamefully-hoist.
  2. Install scripts are not run unless approved. pnpm install prints the list it skipped → add them to allowBuilds (pnpm 11; onlyBuiltDependencies list on pnpm 10). Missing prisma, esbuild, sharp, playwright, husky, … show up as "works locally, fails in the image".
  3. Root prepare runs in the production install (docker), where devDependencies are absent: "prepare": "husky || true".
  4. Quote your globs. yarn berry's built-in shell expands **; pnpm's sh does not. node build.mjs ./entrypoints/**/*.ts silently builds fewer files → a container that boots without its entrypoint. Quote the pattern and let the tool expand it.
  5. pnpm run prints a banner to stdout — any script whose output is piped needs pnpm --silent run ….
  6. 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/.bin is 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 bpnpm --filter a b; yarn dlxpnpm dlx. The CLIs become pnpm 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 allowBuilds entry
  • 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:

  1. Delete pnpm-lock.yaml, pnpm-workspace.yaml, and the node_modules trees.
  2. Move packages: back into package.json workspaces, overridesresolutions, patchedDependencies → yarn's patch protocol, allowBuilds → (yarn runs build scripts by default; nothing needed).
  3. yarn install, then re-run the whole local verification of step 4 — the version drift is the risk here, not the tooling.
  4. Set packageManager: "yarn@x.y.z", drop packageManager: "pnpm" from catladder.ts if it was set, run yarn catenv, commit the regenerated files.
  • catladder-builds — the build config, pnpm gotchas per build type, workspace builds
  • catladder-config — catladder.ts structure, packageManager, regeneration
  • catladder-pipelines — caching, job images, debugging CI
  • catladder-migrate-ci-backend — moving between GitLab CI and GitHub Actions
  • catladder-migrate-release-method — switching between semantic-release and changesets