Back to core workflows Fix dependency resolution Tune package metadata Validate before publishing

Package Manager Version Management

When developers, CI runners and dependency bots each use a different version of npm, pnpm or Yarn, the same repository produces different lockfiles, different node_modules trees and different failures — and nobody can reproduce anybody else's bug. Managing the package manager itself as a pinned, declared dependency of the repository removes that whole class of problems. This section covers how to declare the version with the packageManager field, how Corepack and setup actions enforce it, how to stop the wrong tool being used at all, and how to keep project-level configuration in .npmrc consistent for everyone.

Why the package manager version matters

It is tempting to treat npm or pnpm as part of the environment — whatever is installed on the machine. In practice, lockfile formats, resolution algorithms, peer-dependency handling and defaults change between releases, sometimes between minors. The consequences show up as lockfile churn and CI failures that look unrelated to the change being made:

# CI, after a teammate committed a lockfile from a newer pnpm
 WARN  Ignoring not compatible lockfile at /repo/pnpm-lock.yaml
 ERR_PNPM_NO_LOCKFILE  Cannot install with "frozen-lockfile" because pnpm-lock.yaml is absent

# A pull request that "only" bumped one dependency
 package-lock.json | 4312 ++++++++++++++-----------------
# Yarn Berry, lockfile written by a different major
➤ YN0028: │ -  version: 6
➤ YN0028: │ +  version: 8
➤ YN0028: │ The lockfile would have been modified by this install, which is explicitly forbidden.

Each of these is the package manager version leaking into the repository's state. The lockfile-level fixes are covered in Lockfile Management Strategies; this section fixes the cause.

Concept overview

Version management for the package manager has four parts, and each one closes a different gap. It is part of the broader workflow described in Core JavaScript Package Workflows.

The parts of package manager version management A central packageManager declaration connected to Corepack or setup actions, only-allow enforcement, project .npmrc settings, and CI verification. packageManager field pnpm@9.15.4 in package.json Corepack / setup action runs the declared version engines.pnpm / engines.npm warns or fails on mismatch only-allow blocks the wrong tool project .npmrc shared install settings CI verification asserts versions match
One declaration in package.json drives every environment; the other parts enforce and configure it.
  1. Declaration — the packageManager field in the root package.json names the tool and exact version: "packageManager": "pnpm@9.15.4". See Pinning the Package Manager with Corepack.
  2. Provisioning — Corepack, or CI setup actions that read the field, make sure the declared version is what actually runs. Corepack's signature checks occasionally fail after registry key rotations, covered in Fixing Corepack Signature Verification Errors.
  3. Enforcement — a preinstall guard stops contributors from running npm install in a pnpm repository and creating a stray lockfile; see Enforcing a Single Package Manager with only-allow.
  4. Configuration — a committed project .npmrc makes install behaviour identical everywhere; see Configuring a Project .npmrc for Consistent Installs.

Core initialisation and configuration

Declare the version and let the tooling pick it up:

# Enable Corepack's shims for pnpm and Yarn (Node.js releases that bundle Corepack)
corepack enable

# Pin a version: writes "packageManager" into package.json
corepack use pnpm@9.15.4
{
  "name": "acme-monorepo",
  "private": true,
  "packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0",
  "engines": {
    "node": ">=22.12.0",
    "pnpm": "9.15.4"
  },
  "scripts": {
    "preinstall": "npx only-allow pnpm"
  }
}

The annotated pieces:

  • packageManagername@version, optionally followed by +sha512.<hash> so Corepack verifies the downloaded tarball. corepack use writes the hash for you.
  • engines.pnpm — pnpm checks its own version against this field and refuses to run on a mismatch when engine-strict=true is set in .npmrc; npm warns on engines.npm in the same way.
  • preinstall — rejects installs run with a different tool before they touch anything.

A matching .npmrc keeps behaviour consistent:

engine-strict=true
auto-install-peers=true
save-exact=false

Node.js itself is best pinned alongside: an .nvmrc or .node-version file with the exact version used in CI, read by nvm, fnm, Volta and most CI setup actions.

How the declared version reaches each environment

The packageManager field is inert until something reads it. Corepack is the built-in reader: when enabled, it installs small shims named pnpm and yarn on PATH. Running pnpm install executes the shim, which reads the nearest packageManager field, downloads that exact version into a cache if needed, verifies it, and runs it. Two developers with different globally installed versions therefore run the same pnpm in the repository.

Corepack resolving the declared version A developer runs pnpm; the Corepack shim reads packageManager, downloads and verifies the pinned version if missing, and executes it. Developer Corepack shim Cache / registry pnpm 9.15.4 pnpm install read pack ageM anag er in pack age. json fetch pnpm@9.15.4 if not cached tarball + signature/hash verified exec with original arguments
The shim makes the repository, not the machine, decide which version runs.

Not every environment uses Corepack. Node.js 25 and later no longer bundle it, and some teams prefer version managers such as Volta or mise. The important property is that every path reads the same declaration:

Environment Reads packageManager via
Developer machine Corepack shims, or Volta/mise configured from the field
GitHub Actions pnpm/action-setup (no version input), or corepack enable
Renovate reads the field natively when regenerating lockfiles
Docker builds corepack enable in the image, or npm i -g pnpm@$(node -p ...)

Pinning Node.js alongside the package manager

The package manager runs on Node.js, and the two versions interact: pnpm 10 requires Node.js 18.12 or later, npm 11 requires Node.js 20.17 or later, and some lockfile behaviours depend on the Node.js version that computed them (for example, which platform-specific optional packages are recorded). Pinning one without the other leaves half the problem unsolved.

There are three common approaches, and they can be combined:

  • .nvmrc or .node-version — a one-line file with the exact version, read by nvm, fnm, n, Volta (partially) and CI setup actions through node-version-file. It is the most widely supported option and easy to review.
  • Volta — pins both Node.js and the package manager in package.json under a volta key and switches automatically when you enter the directory. It is convenient for teams that standardise on it, but CI needs Volta installed or a separate reader for the same values.
  • mise (or asdf) — a .tool-versions or mise.toml file pins Node.js, pnpm and other tools together, useful in polyglot repositories that also pin Python, Go or Terraform.

Whichever you choose, keep one source of truth per tool. A repository with .nvmrc saying 22.12 and a volta block saying 20.18 guarantees that someone is running the wrong one.

Monorepo specifics

In a workspace, only the root declaration counts. Package managers run from the workspace root, and a packageManager field inside packages/ui/package.json is ignored by pnpm and Yarn (and can confuse Corepack when someone runs a command from inside that folder). Keep the field in the root manifest only, and make sure tools that operate per package — release scripts, Docker builds for one application — still read the root declaration.

Docker builds of a single workspace package are the most common place this breaks. A Dockerfile that copies only apps/api and runs npm install -g pnpm installs the latest pnpm, not the declared one, and the lockfile it was given may be in a format that version rejects. Copy the root package.json into the build context and derive the version from it:

FROM node:22-slim AS base
WORKDIR /repo
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN corepack enable && corepack install

corepack install downloads the version named in packageManager so later layers run the declared pnpm even without network access. Tools that prune a workspace for Docker, such as turbo prune and pnpm deploy, keep the root manifest for exactly this reason.

Architecture: what differs between versions

Understanding what actually changes between package manager versions explains why pinning matters. Four areas account for almost every incompatibility:

  • Lockfile format. npm lockfile versions 1–3, pnpm lockfile 6.0 and 9.0, and Yarn metadata versions 4 through 8 are not interchangeable. Reading an older format usually works; writing always produces the newer one.
  • Resolution defaults. npm 7 started installing peer dependencies automatically; pnpm changed auto-install-peers and dedupe-peer-dependents defaults across majors; pnpm 10 stopped running dependency lifecycle scripts by default. The same manifest therefore produces different trees.
  • Configuration location. pnpm 10 moved many settings from .npmrc and package.json#pnpm into pnpm-workspace.yaml. Settings in the old location may be ignored by the new version.
  • Commands and flags. Flags are renamed and removed across majors (npm install --only=prod became --omit=dev; Yarn Berry removed yarn global). Scripts written for one version fail on another.

Pinning does not prevent these changes; it makes adopting them a deliberate, reviewable event rather than an accident on someone's laptop.

Execution strategy: upgrading the package manager

Treat a package manager upgrade like any dependency upgrade, in its own pull request:

corepack use pnpm@10.4.1          # updates packageManager (with hash)
pnpm install                       # rewrites the lockfile in the new format if needed
git add package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc
git commit -m "Upgrade pnpm to 10.4.1"

Review three things in that pull request: the lockfile diff should be format-only (no version changes); any configuration that moved location has moved; and CI passes with a clean install. Merge it quickly, because every open branch will conflict with the new lockfile format until it lands. Renovate can automate the upgrade by updating the packageManager field; group it separately from dependency updates so the format change is never mixed with version changes.

A safe package manager upgrade Bump packageManager, reinstall to convert the lockfile, move relocated settings, verify with a frozen install, merge quickly. corepack use pnpm@10.x update packageManager with hash one change per PR pnpm install converts lockfile format if needed move relocated settings e.g. into pnpm-workspace.yaml pnpm install --frozen-lockfile clean CI run proves it merge fast rebase open branches open branches will conflict until merged
Keep the upgrade in its own pull request so the lockfile diff shows only the format change.

Troubleshooting version mismatches

When something behaves differently on two machines, check the tool versions before anything else. Three commands answer most questions:

# What the repository declares
node -p "require('./package.json').packageManager"

# What actually runs, and from where
pnpm --version && command -v pnpm
corepack --version 2>/dev/null || echo "corepack not installed"

If command -v pnpm points at a global install rather than a Corepack shim, the declaration is being ignored on that machine. Typical causes are a globally installed package manager earlier on PATH than the shims, Corepack not enabled after a Node.js upgrade (each Node.js installation needs corepack enable again), or a version manager that installs its own shims. The symptom in a repository is almost always a lockfile diff nobody asked for; the cure is to make the declared version the one that runs, then regenerate the lockfile once.

A second class of mismatch comes from bots and automation. Dependency bots, release tools and IDE integrations may run a bundled package manager. Renovate reads packageManager and uses that version for lockfile updates; if you pin through Volta only, configure Renovate's constraints to match. When a bot's pull requests consistently fail the frozen install while human pull requests pass, the bot is almost certainly using a different version.

Switching package managers

Moving a repository from one package manager to another — most commonly npm or Yarn Classic to pnpm — is the largest version-management change a team makes. The mechanics are covered in Migrating from Yarn 1 to pnpm Workspaces; from a version-management perspective, three steps keep it controlled. Convert the lockfile with the new tool's import command (pnpm import reads package-lock.json and yarn.lock) so resolved versions carry over. Change packageManager, the only-allow guard, CI setup and Docker images in the same pull request, so no environment is left on the old tool. And delete the old lockfile in that pull request too; a stale package-lock.json next to pnpm-lock.yaml invites someone to run npm install and start the drift again.

Security and isolation

The package manager runs arbitrary code from the network on every install, so its own provenance matters. Pinning with a +sha512 hash means Corepack refuses a tampered or unexpected download. Corepack also verifies npm registry signatures for the package manager tarballs it fetches, which is why an outdated Corepack can fail after the registry rotates its signing keys. In CI, avoid npm install -g pnpm without a version, which installs whatever is latest at that moment; use the declared version so a compromised or broken release cannot enter your pipeline silently.

Configuration is a security surface too. .npmrc can set registries and tokens; commit only non-secret settings and read tokens from environment variables (//registry.npmjs.org/:_authToken=${NPM_TOKEN}). Settings such as ignore-scripts=true or pnpm's onlyBuiltDependencies allowlist belong in the committed project configuration so they apply to every install, as covered in Blocking Malicious Install Scripts with --ignore-scripts.

CI/CD integration

A workflow that derives everything from the repository's declarations:

name: ci
on: [pull_request]
jobs:
  install:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # Node.js from .nvmrc, pnpm from packageManager — no versions hard-coded here
      - uses: actions/setup-node@v4
        with:
          node-version-file: .nvmrc
      - uses: pnpm/action-setup@v4          # reads "packageManager" when no version is given
      - uses: actions/setup-node@v4
        with:
          node-version-file: .nvmrc
          cache: pnpm                       # caches the store keyed on pnpm-lock.yaml

      - name: Verify tool versions match declarations
        run: |
          test "$(pnpm --version)" = "$(node -p "require('./package.json').packageManager.split('@')[1].split('+')[0]")"
          test "v$(node --version | cut -c2-)" = "v$(cat .nvmrc | tr -d 'v')"

      - run: pnpm install --frozen-lockfile
      - run: pnpm -r run build

Step by step: Node.js comes from .nvmrc; pnpm comes from packageManager via the setup action; a verification step fails the job if either differs from the declaration (catching a misconfigured runner image); the frozen install proves the lockfile matches; the build proceeds. No version number appears in the workflow file, so upgrades touch only the repository's declarations.

Checklist for a new repository

Setting all of this up takes about fifteen minutes on a new repository and saves hours of confusion later. In order:

  1. Choose the package manager and run corepack use <pm>@<version> to write packageManager with its hash.
  2. Add .nvmrc with the exact Node.js version your CI and production images use.
  3. Add engines.node and engines.<pm> to the root manifest, and engine-strict=true to the project .npmrc.
  4. Add a preinstall guard with only-allow so other package managers are rejected.
  5. Commit a project .npmrc (or pnpm-workspace.yaml settings) with every resolution-affecting option, and no secrets.
  6. Configure CI to read versions from these files, with a step that verifies them.
  7. Add the package manager to your dependency bot's configuration so upgrades arrive as their own pull requests.

Each step is small, and together they mean that "which version of pnpm are you on?" is never again the first question in a bug report.

Pitfalls

Mistake Impact Remediation
No packageManager field Each machine uses its global version; lockfile churn corepack use <pm>@<version> and commit
Version hard-coded in CI workflow CI drifts from developers after an upgrade Let setup actions read packageManager
Mixing npm and pnpm in one repo Two lockfiles, inconsistent trees only-allow in preinstall; delete the stray lockfile
Settings in personal ~/.npmrc Different resolution per developer Commit resolution settings in the project
Upgrading the manager inside a feature PR Unreviewable lockfile diff Upgrade in a dedicated pull request

Guides in this topic

Every guide below solves one concrete task or error within Package Manager Version Management. Start with the one whose symptom matches what you are seeing:

Frequently Asked Questions

Is Corepack required to use the packageManager field? No. The field is a plain declaration. Corepack is one reader; pnpm's setup action, Renovate, Volta and mise also read it. What matters is that every environment honours it.

What happens on Node.js versions that no longer ship Corepack? Install Corepack from npm (npm install -g corepack) and enable it, or use a version manager that reads packageManager. The declaration in the repository does not change.

Should libraries declare packageManager too? Yes, for their own development. The field only affects work inside the repository; it has no effect on consumers who install the published package.

Can different packages in a monorepo use different package managers? No. A workspace has one package manager and one lockfile. Declare it once at the root; nested packageManager fields in workspace packages are ignored or cause confusion.

How often should we upgrade the package manager? Patch and minor releases can follow your normal dependency cadence — Renovate can open them automatically. Major upgrades deserve a planned pull request and a read of the release notes, because defaults, lockfile formats and configuration locations change at majors.

Does the hash in packageManager need updating by hand? No. corepack use computes and writes it. If you edit the version manually, either remove the hash (Corepack then trusts the registry signature alone) or rerun corepack use to regenerate it.

What if a contributor cannot use Corepack at all? They can install the exact declared version manually (npm install -g pnpm@9.15.4). The engines.pnpm check with engine-strict=true makes sure a wrong version fails loudly rather than silently rewriting the lockfile.

Related

Core JavaScript Package Workflows