| name | migrate-to-pnpm-11 |
| version | 0.1.0 |
| description | Migrate a JavaScript/TypeScript project from npm, yarn, or older pnpm (≤10) to pnpm 11, adopting its supply-chain-hardened defaults (minimumReleaseAge, allowBuilds, blockExoticSubdeps, strictDepBuilds, verifyDepsBeforeRun). Use when the user says "migrate to pnpm 11", "upgrade pnpm", "switch to pnpm", or wants to harden a project against npm supply-chain attacks. |
| allowed-tools | Bash, Read, Edit, Write, Grep, Glob |
/migrate-to-pnpm-11
Migrate a project to pnpm 11 and adopt its supply-chain security defaults.
Why pnpm 11
pnpm 11 (released 2026-04-28) turns on five supply-chain defaults that were opt-in or absent before:
| Setting | Default in v11 | Purpose |
|---|
minimumReleaseAge | 1440 (24 h) | Refuse to install package versions younger than the cooldown window. Blocks the typical "publish-malware → wait for CI to pull it" window. |
blockExoticSubdeps | true | Transitive deps cannot come from git URLs or arbitrary tarballs. |
strictDepBuilds | true | Any dependency with a postinstall/build script that is not on the allowBuilds allowlist fails the install with ERR_PNPM_IGNORED_BUILDS. |
verifyDepsBeforeRun | install | pnpm run <script> re-checks the lockfile before running. |
optimisticRepeatInstall | true | Skip work when the lockfile is unchanged. |
Plus: lockfile entries are re-validated against minimumReleaseAge even if the lockfile was committed under a weaker policy, Git-hosted deps get integrity pinning, and pnpm audit signatures verifies ECDSA registry signatures.
Prerequisites
- Node.js ≥ 22. pnpm 11 is pure ESM and drops Node 18/20.
- A clean working tree (or be willing to commit/stash before migrating — the migration touches lockfiles,
package.json, .npmrc, and may create pnpm-workspace.yaml).
Git workflow
This skill does not run git commit, git push, or gh pr directly. After the migration is verified, summarize the changes for the user and let them (or a separate commit/PR workflow) handle the commit.
Before editing any files, create a feature branch. The migration touches lockfiles, package.json, .npmrc, pnpm-workspace.yaml, the Dockerfile, and CI configs — too much for a stray commit on main.
git status
git checkout -b chore/migrate-to-pnpm-11
If the working tree isn't clean, stop and ask the user how to proceed (stash, commit elsewhere, or abort). Do not migrate on top of unrelated in-progress work.
Suggested commit/PR title: chore: migrate to pnpm 11 with supply-chain defaults.
Workflow
Step 1 — Detect the starting point
Run these in parallel and capture each result. Do not assume — check.
node -v
corepack -v 2>/dev/null || echo "no corepack"
cat package.json | grep -E '"packageManager"|"engines"' || true
ls -1 package-lock.json yarn.lock pnpm-lock.yaml 2>/dev/null
ls -1 pnpm-workspace.yaml .npmrc 2>/dev/null
From the output, classify the project as one of:
- npm —
package-lock.json exists, no pnpm/yarn lockfile.
- yarn (classic / berry) —
yarn.lock exists. Check .yarnrc.yml to tell berry from classic.
- pnpm < 10 —
pnpm-lock.yaml with lockfileVersion < 9, or packageManager: pnpm@<10.
- pnpm 10 —
lockfileVersion: '9.0' and packageManager: pnpm@10.x.
Also detect: workspace (pnpm-workspace.yaml with packages:, or package.json workspaces), monorepo tooling (Turborepo, Nx, Changesets), and CI files that pin a package manager (.github/workflows/*.yml, Dockerfile, .tool-versions, .nvmrc).
If Node < 22: stop and tell the user. Suggest nvm install 22 && nvm use 22 (or mise/asdf equivalents). Update .nvmrc / .tool-versions / engines.node as part of the migration.
Step 2 — Install pnpm 11 via corepack
Prefer corepack so the version is pinned per-project rather than globally.
corepack enable
corepack prepare pnpm@latest --activate
pnpm -v
Then pin in package.json:
{
"packageManager": "pnpm@11.0.0",
"engines": { "node": ">=22" }
}
Use the exact installed version (pnpm -v) rather than latest.
Step 3 — Convert the lockfile (path-specific)
Run from the repo root. For workspaces, run at the workspace root only.
From npm:
pnpm import
rm package-lock.json
rm -rf node_modules
From yarn (classic or berry):
pnpm import
rm yarn.lock .yarnrc .yarnrc.yml 2>/dev/null
rm -rf node_modules .yarn .pnp.* 2>/dev/null
From pnpm < 10: delete the old lockfile so pnpm 11 regenerates at lockfile v9. Do not carry forward a v6/v7 lockfile.
rm pnpm-lock.yaml
rm -rf node_modules
From pnpm 10: keep the lockfile as-is. Lockfile v9 is unchanged between 10 and 11.
Step 4 — Migrate configuration files
This is where the most subtle breakage happens. .npmrc is auth/registry only in pnpm 11. Every pnpm-specific setting moves into pnpm-workspace.yaml (even for non-workspace projects — the file is required as the settings home). The pnpm field in package.json is also no longer read.
Read the project's current .npmrc, pnpm-workspace.yaml, and package.json's pnpm field. Move every non-auth, non-registry key into pnpm-workspace.yaml.
| Old location | Old key | New location | New key |
|---|
.npmrc or package.json#pnpm | only-built-dependencies / onlyBuiltDependencies | pnpm-workspace.yaml | allowBuilds (allowlist map) |
| same | onlyBuiltDependenciesFile | same | allowBuilds (inline the file's contents) |
| same | neverBuiltDependencies | same | allowBuilds (omit entries, or set to false) |
| same | ignoredBuiltDependencies | same | allowBuilds (set to false) |
| same | ignoreDepScripts | same | allowBuilds: {} (empty allowlist = block all) |
| same | allowNonAppliedPatches | same | allowUnusedPatches |
| same | ignorePatchFailures | same | removed — no replacement; fix the patch |
package.json#pnpm.auditConfig | ignoreCves | pnpm-workspace.yaml | auditConfig.ignoreGhsas (translate CVE→GHSA via GitHub Advisory DB) |
| Env vars | npm_config_* | env / CI | pnpm_config_* |
Stay in .npmrc (do not move): registry=, //registry.npmjs.org/:_authToken=..., @scope:registry=, network proxy keys, engine-strict. Most other things move.
Step 5 — Write a hardened pnpm-workspace.yaml
Create or update the file. Keep defaults explicit so they survive future pnpm changes and so the security posture is visible in code review.
minimumReleaseAge: 1440
blockExoticSubdeps: true
strictDepBuilds: true
verifyDepsBeforeRun: install
allowBuilds:
For projects that legitimately need fresher releases (e.g. internal monorepo packages published moments before install), prefer raising minimumReleaseAge for the registry and using a per-package override rather than disabling it globally.
Step 6 — First install, expect ERR_PNPM_IGNORED_BUILDS
pnpm install
With strictDepBuilds: true and an empty allowBuilds, the install will fail listing every dependency that wants to run a build script. For each one:
- Look up the package on npm — what does its postinstall actually do?
- If it is a known legitimate native build (e.g.
esbuild, @swc/core, sharp, better-sqlite3, node-gyp-based deps the project actually uses), add it to allowBuilds as true.
- If it looks unnecessary or suspicious, leave it blocked (
false or omitted) — the package will still install, just without running its script. Verify the app still works.
Re-run pnpm install until it succeeds.
Step 7 — Verify
pnpm install --frozen-lockfile
pnpm audit
pnpm audit signatures
pnpm run build && pnpm test
If pnpm audit signatures reports unsigned packages, surface them to the user but do not auto-pin around them.
Step 8 — Update CI, Docker, and tooling
Every CI provider has its own Node-install task and its own cache idiom. Don't generalize from the GitHub Actions example to GitLab or vice versa — follow the matching subsection below. Do not invent CI config from scratch; copy the snippet, adapt the names.
Three things to keep in mind across all providers:
- Node ≥ 22 in every place Node is pinned (CI config, Dockerfile,
.nvmrc, engines.node).
- Cache the pnpm store, not
node_modules. pnpm uses a content-addressed store; node_modules is just symlinks into it, so caching node_modules caches the symlinks rather than the content. Key the cache on pnpm-lock.yaml's hash.
- Install pnpm before using it. Corepack (shipped with Node since 16) is the simplest path —
corepack enable && corepack prepare pnpm@11 --activate. The packageManager field in package.json then pins the exact version.
8a. Detect what's in use
ls -1 \
.github/workflows \
.gitlab-ci.yml \
.circleci/config.yml \
bitbucket-pipelines.yml \
azure-pipelines.yml \
Jenkinsfile \
.drone.yml \
.buildkite/pipeline.yml .buildkite/pipeline.yaml \
.travis.yml \
2>/dev/null
ls -1 \
Dockerfile Dockerfile.* \
docker-compose.yml docker-compose.yaml compose.yml compose.yaml \
.dockerignore \
.devcontainer/devcontainer.json .devcontainer.json \
2>/dev/null
grep -RnE "npm (ci|install|run)\b|yarn (install|add|run)\b|pnpm@(7|8|9|10)\b|node:(16|18|20)\b|setup-node@v[0-3]\b" \
.github .gitlab-ci.yml .circleci bitbucket-pipelines.yml azure-pipelines.yml \
Jenkinsfile .drone.yml .buildkite .travis.yml \
Dockerfile* docker-compose*.yml compose*.yml \
Makefile justfile .tool-versions .nvmrc package.json \
2>/dev/null
8b. GitHub Actions
Replace actions/setup-node blocks with the following. pnpm/action-setup must run before actions/setup-node so cache: pnpm can find the binary.
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 11
run_install: false
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm audit signatures
- run: pnpm test
Corepack-only alternative (no third-party action): replace the pnpm/action-setup step with - run: corepack enable before setup-node. The packageManager field in package.json then dictates the version.
8c. GitLab CI
Cache the store, not node_modules. The cache key auto-invalidates on lockfile changes.
image: node:22
variables:
PNPM_HOME: "${CI_PROJECT_DIR}/.pnpm-store"
cache:
key:
files:
- pnpm-lock.yaml
paths:
- .pnpm-store
before_script:
- corepack enable
- corepack prepare pnpm@11 --activate
- pnpm config set store-dir "$PNPM_HOME"
test:
script:
- pnpm install --frozen-lockfile
- pnpm audit signatures
- pnpm test
8d. CircleCI
version: 2.1
jobs:
test:
docker:
- image: cimg/node:22.13
steps:
- checkout
- run:
name: Enable corepack + pnpm
command: |
sudo corepack enable
corepack prepare pnpm@11 --activate
pnpm config set store-dir ~/.pnpm-store
- restore_cache:
keys:
- v1-pnpm-store-{{ checksum "pnpm-lock.yaml" }}
- v1-pnpm-store-
- run: pnpm install --frozen-lockfile
- save_cache:
key: v1-pnpm-store-{{ checksum "pnpm-lock.yaml" }}
paths:
- ~/.pnpm-store
- run: pnpm audit signatures
- run: pnpm test
The fallback cache key (v1-pnpm-store- with no checksum) restores the most recent store when the exact lockfile hash isn't cached; pnpm still verifies content hashes.
8e. Bitbucket Pipelines
The built-in node cache targets node_modules (wrong for pnpm). Define a custom store cache.
image: node:22
definitions:
caches:
pnpm-store: $HOME/.pnpm-store
pipelines:
default:
- step:
caches: [pnpm-store]
script:
- corepack enable
- corepack prepare pnpm@11 --activate
- pnpm config set store-dir $HOME/.pnpm-store
- pnpm install --frozen-lockfile
- pnpm audit signatures
- pnpm test
8f. Azure Pipelines
trigger: [main]
pool:
vmImage: ubuntu-latest
variables:
PNPM_STORE_PATH: $(Pipeline.Workspace)/.pnpm-store
steps:
- task: NodeTool@0
inputs:
versionSpec: '22.x'
- script: |
corepack enable
corepack prepare pnpm@11 --activate
pnpm config set store-dir $(PNPM_STORE_PATH)
displayName: Install pnpm 11
- task: Cache@2
inputs:
key: 'pnpm | "$(Agent.OS)" | pnpm-lock.yaml'
path: $(PNPM_STORE_PATH)
displayName: Cache pnpm store
- script: pnpm install --frozen-lockfile
- script: pnpm audit signatures
- script: pnpm test
8g. Jenkinsfile (declarative)
The named Docker volume persists the store across builds on the same agent.
pipeline {
agent {
docker {
image 'node:22'
args '-v pnpm-store:/root/.local/share/pnpm/store'
}
}
environment {
COREPACK_ENABLE_DOWNLOAD_PROMPT = '0'
}
stages {
stage('Setup pnpm') {
steps {
sh '''
corepack enable
corepack prepare pnpm@11 --activate
pnpm -v
'''
}
}
stage('Install') { steps { sh 'pnpm install --frozen-lockfile' } }
stage('Verify') { steps { sh 'pnpm audit signatures' } }
stage('Test') { steps { sh 'pnpm test' } }
}
}
8h. Drone CI
kind: pipeline
type: docker
name: default
steps:
- name: test
image: node:22
volumes:
- name: pnpm-store
path: /root/.local/share/pnpm/store
commands:
- corepack enable
- corepack prepare pnpm@11 --activate
- pnpm install --frozen-lockfile
- pnpm audit signatures
- pnpm test
volumes:
- name: pnpm-store
host: { path: /tmp/pnpm-store }
8i. Buildkite
steps:
- label: ":pnpm: test"
plugins:
- docker#v5.11.0:
image: node:22
mount-buildkite-agent: false
volumes:
- "buildkite-pnpm-store:/root/.local/share/pnpm/store"
commands:
- corepack enable
- corepack prepare pnpm@11 --activate
- pnpm install --frozen-lockfile
- pnpm audit signatures
- pnpm test
8j. Travis CI
Travis is in maintenance mode for most projects; if this migration is also a good moment to move to another provider, take it. If staying:
language: node_js
node_js: 22
cache:
directories:
- $HOME/.pnpm-store
before_install:
- corepack enable
- corepack prepare pnpm@11 --activate
- pnpm config set store-dir $HOME/.pnpm-store
install: pnpm install --frozen-lockfile
script:
- pnpm audit signatures
- pnpm test
8k. Dockerfile — patch existing first, then consider the patterns
Patch existing Dockerfiles before adding new ones. The most common post-migration Docker failure looks like this:
[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: <pkg>@x.y.z, ...
Build Failed: ... exit code: 1
Root cause: pnpm-workspace.yaml is where pnpm 11's allowBuilds allowlist lives, and existing Dockerfiles typically only copy package.json and the lockfile into the build context. Without the workspace file, pnpm 11 sees every dep with a build script as "unaddressed" and fails (new v11 default behavior, not configurable per-stage).
Action — find and fix every install stage:
grep -nE '^COPY .*(package\.json|pnpm-lock\.yaml)' Dockerfile Dockerfile.* 2>/dev/null
For each COPY package.json pnpm-lock.yaml ./ (or similar) line in a stage that runs pnpm install / pnpm fetch, add pnpm-workspace.yaml:
- COPY package.json pnpm-lock.yaml ./
+ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
If you have separate deps and deps_prod (or build and prod) stages, both need the workspace file — the allowlist applies to --prod installs too. Also bump any corepack prepare pnpm@<old> lines to pnpm@11.x so the pin matches the packageManager field (the field overrides the pin at install time, but a stale pin is confusing in review).
If the existing Dockerfile is broadly fine after that two-line patch, stop here — don't rewrite it from one of the patterns below. Only use the patterns when there's no existing Dockerfile or it needs a structural rewrite.
Pattern menu for new Dockerfiles:
--mount=type=cache requires BuildKit (default on Docker 23+). Declare it explicitly with # syntax=docker/dockerfile:1.7. Prefer node:22-slim over node:22-alpine unless you specifically need musl — Alpine + node-gyp + native deps adds friction.
Pattern 1 — single-stage (dev images, simple apps):
# syntax=docker/dockerfile:1.7
FROM node:22-slim
ENV PNPM_HOME=/pnpm
ENV PATH=$PNPM_HOME:$PATH
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
COPY . .
CMD ["pnpm", "start"]
Pattern 2 — multi-stage with pnpm fetch (production):
pnpm fetch populates the store from the lockfile alone, so the dependency layer rebuilds only when pnpm-lock.yaml changes — not when source changes.
# syntax=docker/dockerfile:1.7
FROM node:22-slim AS base
ENV PNPM_HOME=/pnpm
ENV PATH=$PNPM_HOME:$PATH
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
RUN corepack enable
WORKDIR /app
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml* ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm fetch --prod
FROM base AS build
COPY . .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile --offline && \
pnpm run build
FROM node:22-slim AS runtime
ENV PNPM_HOME=/pnpm
ENV PATH=$PNPM_HOME:$PATH
RUN corepack enable
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./package.json
USER node
CMD ["node", "dist/index.js"]
Pattern 3 — monorepo deploy with pnpm deploy:
Extracts a self-contained, hoisted node_modules for one workspace package, with no workspace symlinks. Ready to copy into a slim runtime stage.
# syntax=docker/dockerfile:1.7
FROM node:22-slim AS base
ENV PNPM_HOME=/pnpm
ENV PATH=$PNPM_HOME:$PATH
RUN corepack enable
WORKDIR /repo
FROM base AS build
COPY . .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
RUN pnpm --filter=@my-org/api build
RUN pnpm deploy --filter=@my-org/api --prod /out
FROM node:22-slim AS runtime
WORKDIR /app
COPY --from=build /out .
USER node
CMD ["node", "dist/index.js"]
8l. docker-compose
The named pnpm-store volume persists the store across docker compose up cycles so local dev installs stay fast. Use docker compose (the v2 plugin), not legacy docker-compose.
services:
app:
image: node:22
working_dir: /app
environment:
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
PNPM_HOME: /pnpm
PATH: /pnpm:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
volumes:
- .:/app
- node_modules:/app/node_modules
- pnpm-store:/pnpm/store
command: sh -c "corepack enable && pnpm install && pnpm run dev"
volumes:
node_modules:
pnpm-store:
8m. .dockerignore
The lockfile and workspace config must stay in the build context. If a .dockerignore exists, read it and verify none of these patterns are excluded — bare * rules and broad globs are the usual offenders:
grep -E '(^|/)(pnpm-workspace\.yaml|pnpm-lock\.yaml|package\.json|\*\.yaml|\*\.json|\*$)' .dockerignore 2>/dev/null
If any of those files would be excluded, add explicit un-ignore rules (!pnpm-workspace.yaml, etc.) or narrow the offending glob. The Dockerfile patch in 8k is useless if .dockerignore strips the file out of the build context before COPY ever sees it.
Reference .dockerignore for a new project — exclude outputs and local-only files, keep dep manifests:
# Always exclude
node_modules
.git
.pnpm-store
dist
build
coverage
.next
.turbo
.cache
# Local-only files
.env
.env.local
.env.*.local
*.log
# Don't exclude — these MUST be in the build context:
# package.json
# pnpm-lock.yaml
# pnpm-workspace.yaml
If .npmrc holds an auth token, don't copy it into the image. Mount as a BuildKit secret:
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
--mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
Build with docker build --secret id=npmrc,src=$HOME/.npmrc ..
8n. devcontainer.json
{
"name": "App",
"image": "mcr.microsoft.com/devcontainers/javascript-node:22",
"features": {
"ghcr.io/devcontainers/features/node:1": { "version": "22" }
},
"postCreateCommand": "corepack enable && corepack prepare pnpm@11 --activate && pnpm install",
"containerEnv": {
"COREPACK_ENABLE_DOWNLOAD_PROMPT": "0"
},
"mounts": [
"source=pnpm-store-${devcontainerId},target=/home/node/.local/share/pnpm/store,type=volume"
]
}
8o. Cross-cutting updates regardless of provider
.nvmrc / .tool-versions — bump to Node 22.
engines.node in package.json — ">=22".
- CI env vars — rename every
npm_config_* to pnpm_config_*.
- Scripts that call
npx — prefer pnpm dlx. dlx honors allowBuilds and strictDepBuilds, so a dlx command needing build scripts will fail unless allowlisted.
Makefile / justfile targets calling npm/yarn — switch to pnpm.
8p. Sanity check after pushing
The first post-migration CI run should show all of:
- Node 22.x or newer.
- pnpm 11.x via corepack or
pnpm/action-setup.
pnpm install --frozen-lockfile succeeding with no ERR_PNPM_IGNORED_BUILDS.
pnpm audit signatures running and either passing or returning a known list.
- Meaningful cache hit on the second run.
If any are missing, re-run the detection grep — the pipeline got partially upgraded.
Step 9 — Summarize and stop
Summarize the changes for the user — files touched, packages added to allowBuilds, any findings from pnpm audit signatures, and any Dockerfile / CI edits. Do not run git commit, git push, or gh pr yourself. Let the user decide how to commit (their own workflow, a separate agent, or by hand).
Gotchas
pnpm install -g with no arguments is removed. Per-global-package isolation now — each pnpm add -g <pkg> gets its own dir under PNPM_HOME/bin.
pnpm server is gone. If the project's tooling used it, find an alternative.
- Removed commands:
access, bugs, edit, issues, owner, prefix, profile, pkg, repo, set-script, team, token. Most have native equivalents or are no longer needed.
useNodeVersion / executionEnv.nodeVersion removed. Use devEngines.runtime or external version managers (nvm/mise/asdf).
managePackageManagerVersions, packageManagerStrict, packageManagerStrictVersion all replaced by pmOnFail.
- Git-hosted deps now get integrity pinning. The first install after migration writes
gitHosted: true entries — subsequent installs detect tampering. Don't be alarmed by the lockfile diff.
minimumReleaseAge and CI cache: if CI restores a lockfile from cache that was resolved before v11's policy, pnpm re-validates and may refuse to install. This is intended.
Quick reference: file diff after a typical migration
package.json — packageManager set, engines.node bumped, pnpm field removed.
.npmrc — trimmed to auth/registry lines only (or deleted if it only held pnpm settings).
pnpm-workspace.yaml — created or expanded with hardening keys and allowBuilds.
pnpm-lock.yaml — regenerated at lockfile v9 (no change if already on pnpm 10).
package-lock.json / yarn.lock — deleted.
.nvmrc / .tool-versions — Node 22.
- CI files — package manager and Node version updated.