How CKEditor migrated a multi-repository setup from Yarn Classic to pnpm
CKEditor recently switched from Yarn Classic to pnpm as our package manager. There are many such stories, but this story may be a bit different due to CKEditor’s rather unusual, multi-repository setup.
CKEditor 5 and its development tools are organized in a few repositories. Some of them are open-source, some are private. Some contain only a single package, while others are monorepos. Being able to work with multiple repositories simultaneously is crucial for CKEditor, given how much impact a single change in one repository can have on the whole multi-repo setup.
Quick reminder or introduction for newcomers: Yarn and pnpm are tools that help with distribution and dependency management in JavaScript projects. Yarn installs packages in a traditional “node_modules” structure, focusing on speed and reliability, while pnpm uses a shared global store and links packages more efficiently to save disk space and enforce stricter dependency rules.
Why did we switch?
We decided to migrate from Yarn Classic to pnpm to regain control over dependency management while improving performance, reliability, and long-term maintainability. Yarn Classic had started to show its age: installs were slow, CI was flaky, and modern features were missing. pnpm, on the other hand, offered a clear upgrade path: fast installs, strict dependency resolution, and modern security features to mitigate supply-chain risks. It offered all this while still supporting our complex multi-repository workflow.
Before diving into the migration itself, it’s worth breaking down the concrete problems we were facing and why Yarn Classic could no longer meet our needs.
Limitations of the Yarn Classic workflow
Project structure
Before the migration, our setup looked something like this:
ckeditor5/
├── external/
│ ├── ckeditor5-commercial/
│ ├── ckeditor5-dev/
│ ├── umberto/
│ └── ...
└── ...
The ckeditor5 monorepo with its open-source packages was the root of CKEditor’s development environment, and the external/ directory housed closed-source ckeditor5-commercial and other optional development-related repositories. For day-to-day development, most developers usually only need ckeditor5 and ckeditor5-commercial, while other repositories are only necessary when working on dev tools.
Lockfiles
Each repository in the external/ directory affected the yarn.lock file, which pins dependencies to exact resolved versions, ensuring that every installation produces the same dependency tree across machines and over time. This meant that if you cloned the ckeditor5 repository without any additional repositories in external/, your yarn.lock file would be different from someone who had some or all of them cloned. For this reason, the CKEditor team didn’t commit lockfiles in any of the repositories.
The nice thing about this was that a simple yarn install command was enough to install and link all the available packages, regardless of which repositories were cloned locally. It also meant that we always installed the latest compatible versions of dependencies, which theoretically ensured that published packages work in the most up-to-date environments.
However, this approach had downsides, especially in terms of consistency across environments. We couldn’t guarantee that the code we ran locally would work the same way on CI or on a colleague’s machine.
This wasn’t just a theoretical problem. There were multiple times the environment was broken due to a transitive dependency breaking things, sometimes just before release.
Old habits die hard
When I joined the company, I wanted to change this setup. But it remained the status quo for years. Inertia happens in every company, and CKEditor was no different. Plus, changing this would require a lot more effort than occasionally fixing broken environments or restarting CI jobs. That effort needed to be applied elsewhere.
Still, we were looking for an excuse to finally make the change. The opportunity came from an unexpected direction.
Introducing CKEditor 5 LTS
Recently, the company announced CKEditor 5 LTS (Long Term Support): a special edition of CKEditor 5 that will receive critical bug fixes and security updates for an extended period. This edition is targeted at customers who prioritize stability and long-term maintenance.
To deliver on this promise, we needed to ensure that we could easily backport fixes and maintain a stable codebase on the LTS branch. However, without lockfiles, it was almost guaranteed that the LTS branch would eventually break due to some of the ~2000 dependencies in our development monorepos updating or introducing unintentional breaking changes. Having lockfiles would allow us to freeze the dependency tree, ensuring that we can go back and apply fixes months or years after the initial release.
Why Yarn Classic was no longer enough
Yarn Classic was a great package manager that pushed the JavaScript ecosystem forward. When it came out, it solved many problems and limitations of npm at the time. However, after “controversial” changes in Yarn 2 (Berry), many developers, including us, decided to stick with Yarn Classic. Over time, as it started to get fewer and fewer updates, it began to show its age. Slow installs, frequent errors, flakiness on CI, and lack of modern features made working with Yarn Classic less enjoyable. This stood in contrast to the great experience we had with more modern package managers in other projects.
While only introducing lockfiles would have been enough to solve the LTS problem, we knew that basing our migration on Yarn Classic would lock us into an outdated package manager for years to come. We decided to take the opportunity and switch!
The migration to pnpm
There wasn’t much debate about which package manager to choose. pnpm was the obvious choice for us due to its speed, great developer experience, and modern features for mitigating supply chain attacks. We’ve already used it in other projects and were very happy with it.
pnpm workspace configuration
Switching to a different package manager was relatively straightforward. We ended up with a configuration similar to this:
packages:
- ...
onlyBuiltDependencies:
- ...
minimumReleaseAge: 4320 # 3 days
minimumReleaseAgeExclude:
- ...
shellEmulator: true
shamefullyHoist: true
preferFrozenLockfile: true
linkWorkspacePackages: true
ignoreWorkspaceCycles: true
To allow external contributors to work with CKEditor’s open-source repositories, we needed these repositories to work independently without requiring the external directory. However, with the old project structure, the lockfile would include packages from the closed-source external/ckeditor5-commercial monorepo. Even worse, having other repositories in the external/ directory would also change the lockfile. This couldn’t be the case. We needed a solution that allowed consistent lockfiles, regardless of what other repositories were present in that directory.
After some trial and error, we came up with a solution that involved changing the project structure slightly and using small scripts with pnpm hooks to adjust the installation process.
Changing the project structure
The new structure looks like this:
ckeditor5-commercial/
├── external/
│ ├── ckeditor5/
│ └── ...
└── ...
We made the ckeditor5-commercial monorepo the root of the project. In it, we expect that external/ckeditor5 is always present (with the help of mrgit). This way, the lockfile for ckeditor5-commercial stays consistent.
Since the ckeditor5 repository has its own lockfile, the downside is that whenever we change dependencies in ckeditor5, we need to run pnpm install in both monorepos. We also need to keep the pnpm-workspace.yaml files in sync. However, this is a small price to pay.
Handling optional packages
The issue with ckeditor5 and ckeditor5-commercial is solved. However, we still wanted to be able to work with other optional monorepos in the external directory when needed, but without affecting the lockfiles. The question was: How could we make pnpm link all packages together without altering the lockfile?
Our solution was to update our reinstall script. Originally, it removed all node_modules and ran yarn install. We always run it when switching branches or after pulling changes to ensure that dependencies are up to date.
We modified it to do the following:
Identify which monorepos track their
pnpm-lock.yamlfiles in Git.Check if there are any uncommitted changes in those lockfiles and exit with an error message if there are.
Remove all
node_modulesdirectories.Run
pnpm installin all optional monorepos.Run
pnpm installin the rootckeditor5-commercialmonorepo, which also installs dependencies in theckeditor5repository.Finally, revert any changes made to the tracked lockfiles to ensure they remain unchanged.
This is the entire script for those who may find it useful:
import { globSync } from 'node:fs';
import { styleText } from 'node:util';
import { execSync } from 'node:child_process';
import { join, relative, dirname } from 'node:path';
const cwd = process.cwd();
const pnpmFile = cwd => relative( cwd, join( import.meta.dirname, 'pnpm-hooks.cjs' ) );
const log = message => console.log( styleText( [ 'bold', 'green' ], message ) );
const error = message => console.log( styleText( [ 'bold', 'red' ], message ) );
const exec = ( command, cwd ) => execSync( command, { encoding: 'utf-8', stdio: 'inherit', cwd } );
// 1. Identify all repositories that track their `pnpm-lock.yaml` files.
const trackedLocks = globSync( [ 'package.json', 'external/*/package.json' ], { cwd } )
.map( path => join( cwd, dirname( path ) ) )
.filter( path => {
try {
execSync( `git -C ${ path } ls-files --cached --error-unmatch -- pnpm-lock.yaml`, { stdio: 'ignore' } );
return true;
} catch {
return false;
}
} );
// 2. Warn the user and exit if any `pnpm-lock.yaml` has changes. Otherwise, the changes would be lost.
trackedLocks.forEach( path => {
try {
execSync( 'git diff --exit-code pnpm-lock.yaml', { cwd: path } );
} catch {
error(
`Detected changes in "${ relative( cwd, join( path, 'pnpm-lock.yaml' ) ) }". Please commit or discard them before continuing.`
);
process.exit( 1 );
}
} );
// 3. Remove old "node_modules" directories.
log( 'Removing all "node_modules" directories...' );
exec( 'pnpx -y rimraf@latest --glob "**/node_modules"', cwd );
// 4. Install dependencies in all "external/*" packages (except "external/ckeditor5").
globSync( 'external/*', { cwd } )
.filter( path => !path.endsWith( 'external/ckeditor5' ) )
.forEach( path => {
const dir = join( cwd, path );
log( `Installing "${ path }"...` );
exec( `pnpm --pnpmfile ${ pnpmFile( dir ) } install --stream`, dir );
} );
// 5. Install dependencies in the root directory (including "external/ckeditor5").
log( 'Installing "commercial" and "external/ckeditor5" repositories...' );
exec( `pnpm --pnpmfile ${ pnpmFile( cwd ) } install --stream`, cwd );
// 6. Revert any changes in the lock files to keep them unchanged.
trackedLocks.forEach( path => {
log( `Reverting changes in "${ relative( cwd, join( path, 'pnpm-lock.yaml' ) ) }"...` );
exec( 'git checkout -- pnpm-lock.yaml', path );
} );
log( 'All dependencies installed successfully.' );
The above steps would install all dependencies correctly, but wouldn’t link the packages between the optional repositories and the two main monorepos. For example, ckeditor5 monorepo would continue using the published version of @ckeditor/ckeditor5-dev-utils instead of the local one from external/ckeditor5-dev/packages/ckeditor5-dev-utils.
However, if you look closely, you’ll see that we run pnpm install with a --pnpmfile option that points to a custom pnpm hook. This is where the magic happens.
This hook modifies the pnpm configuration during installation to add overrides for all packages found in the packages/* and external/* directories, linking them locally instead of using the published versions. It also ensures that packages from the currently installing monorepo are excluded from this linking process, as pnpm’s workspace:* protocol already handles that.
This is the pnpm-hooks.cjs source:
const { globSync } = require( 'node:fs' );
const { dirname, join } = require( 'node:path' );
module.exports = {
hooks: {
// Ensures that all packages from `packages/*` and `external/*` directories are properly linked using overrides.
updateConfig( config ) {
const cwd = join( __dirname, '..', '..' );
const patterns = [
'', // The root directory.
'packages/*',
'external/*',
'external/*/packages/*'
].map( pattern => join( cwd, pattern, 'package.json' ) );
/**
* Ignore all packages from the currently installing project. This is to prevent packages from
* the same monorepo from being linked, which should already be handled by pnpm workspaces.
*/
const options = {
cwd,
exclude: [
join( config.dir, 'package.json' ),
...( config.packages || [] ).map( pattern => join( config.dir, pattern ) )
]
};
config.overrides = globSync( patterns, options ).reduce( ( acc, path ) => {
const { name } = require( path );
acc[ name ] = 'link:' + dirname( path );
return acc;
}, config.overrides || {} );
return config;
}
}
};
Using overrides, unfortunately, affects the lockfiles, which is why we needed to revert the changes after installation in the reinstall script.
It’s worth noting that both scripts run before any external dependencies are installed, so they can only use native Node APIs. Fortunately, recent versions of Node.js introduced many new features that make it so that you can do quite a lot without needing to pull in any third-party libraries. An example of this is the globSync function that we used in the above scripts, which is available in the fs module since Node 22.
Conclusion
We are more than happy with the migration to pnpm. The installation process is much faster, with cold installs on CI taking about 16 seconds compared to 80 seconds with Yarn Classic.
Everything feels snappier and more reliable. The developer experience is much better, and we can finally ensure consistent environments across all machines and CI thanks to lockfiles. All of this while still allowing each repository to work independently, ensuring that external contributors can continue working with our open-source code.
The solution isn’t perfect. Having to run pnpm install in two monorepos when updating dependencies and keeping pnpm-workspace.yaml files in sync is a bit of a hassle. Having to use overrides and revert lockfile changes is also not ideal. However, these are small trade-offs for the benefits we get.
