· via dev.to (home feed)
bol's Yarn-to-pnpm Backstage migration exposes CI cache and worktree pitfalls
bol's five-year-old Backstage platform moved from Yarn 4 to pnpm and ran into broken CI caches, a 1.42 GB archive, and 350 lines of custom worktree symlink code that had to go.

A migration that only looked finished
Writing on dev.to, bol developer Bogdan Nechyporenko recounts moving the company's internal Backstage platform from Yarn 4 to pnpm. The platform has run for more than five years and has accumulated its own conventions around it: CI setup, local startup tooling, container images and helpers built around Git worktrees.
Worktrees are central to how bol works. The team runs parallel coding agents, each in its own worktree, so one agent can fix a bug while another handles an improvement elsewhere without branches fighting over a single working directory. That workflow made the package manager part of the architecture, and pnpm's pitch — a shared content-addressable store with safe reuse across checkouts — looked like the better fit.
The visible switch was quick: pin pnpm 12.3.0, commit the lockfile, replace Yarn commands, update contributor guides. Then CI ran.
CI downloaded 4,226 packages on every run
The first pipeline downloaded 4,226 packages from scratch. So did the next one. Downloads took roughly 18 seconds, but native node-gyp builds added several minutes, and the pipeline behaved as though no cache existed.
One did exist — it was pointed at the wrong place. According to the post, the container image carried a global pnpm configuration sending the store to /builds/.pnpm-store, while GitLab tried to archive .pnpm-store inside the project checkout. pnpm wrote outside the archived directory, so every following job started cold.
The fix was to set the store directory explicitly in CI and print the effective value so the job log proves it. The broader lesson: never reason about a package-manager cache from configuration files alone; ask the running job where its store actually is. The team also stopped automatically retrying script failures, since a frozen-lockfile mismatch fails identically six times and bills six runners along the way.
A working cache became the bottleneck
Once the store was cached, a new problem surfaced. The archive now held the pnpm store plus every node_modules tree: 1.42 GB across roughly 601,000 files, pulled by four jobs. The cached node_modules were not buying the expected time either, because installs rebuilt the dependency layout and native modules regardless.
Removing node_modules paths from the cache cut the archive to roughly 400 MB and 244,000 files — about 72% smaller by size and 59% fewer files — saving an estimated six minutes per pipeline. The team also switched installs to --frozen-lockfile --prefer-offline, made transfer progress visible in logs, and chose faster cache compression. Nechyporenko points out that pnpm's own CI documentation warns caching the store is not guaranteed to speed up installation; the right policy depends on the runner, network and workload.
pnpm met 350 lines of custom symlink logic
Local development held the hardest surprise. The worktree bootstrap contained roughly 350 lines of custom symlink-farm logic built around Yarn's node_modules structure, mirroring third-party dependencies from the main checkout and redirecting workspace packages to each worktree's own source. pnpm links packages through a virtual store, and where the mirroring code expected ordinary directories, the team started seeing ENOTDIR failures.
Rather than teach the symlink farm pnpm's layout, they deleted it. The central helper shrank from 370 lines to 31, and the whole bootstrap became a single pnpm install --frozen-lockfile. Each checkout gets a pnpm-managed install while content is still reused through the shared store.
There is a tradeoff: the replacement verifier is much lighter, checking for node_modules/.pnpm instead of proving every workspace package resolves correctly. Nechyporenko would still like an integration check that edits a plugin in a secondary worktree and confirms the running application uses that source.
Old machines remembered the old world
Clean checkouts worked. Some existing developer machines did not, because they still carried a project-local .pnpm-store from the earlier setup. After local development moved to the global store, pnpm relinked roughly 4,800 packages on repeated starts — about four minutes for something that should not have required an install at all.
The fix was a one-time startup migration: detect the obsolete local store, remove it together with the root node_modules and the dependency hash, then run one clean install. Later starts skip installation almost entirely. Nechyporenko is precise about this: pnpm did not install 4,800 packages in zero seconds; the startup code learned when no install was needed. Cleanup also had to be environment-specific, since CI deliberately keeps a project-local store for GitLab to archive while local development treats that same directory as stale.
Why it matters
Package-manager migrations in mature monorepos are rarely just about lockfiles and commands. This one surfaced implicit assumptions baked into container images, CI caching and years of custom tooling. It also serves as an early map for teams whose package manager has become load-bearing infrastructure: parallel coding agents across Git worktrees multiply throughput, but they make dependency layout, cache placement and store hygiene part of the platform's architecture — and migrations have to plan for the messy state on existing machines, not just fresh clones.
- #pnpm
- #yarn
- #backstage
- #ci-cd
- #monorepo
- #developer-experience