pnpm
pnpm is the recommended package manager of choice for TypeScript projects at SEEK.
This topic details how to use pnpm with skuba.
Background
skuba serves as a wrapper for numerous developer tools such as TypeScript, Vitest, Oxfmt & ESLint, abstracting the dependency management of those packages across SEEK projects. When you are using skuba, you do not need to declare these packages as direct devDependencies. In our previously-recommended package manager, Yarn, these packages and others are automatically hoisted to create a flattened dependency tree.
{
"devDependencies": {
"skuba": "7.2.0"
}
}
node_modules
βββ oxfmt
βββ skuba
βββ vitest
βββ other-skuba-deps
However, this behaviour can lead to some silly bugs when updating packages.
pnpm in skuba
pnpm addresses the hoisting issue with a symlinked structure. Each package is guaranteed to resolve compatible versions of its dependencies, rather than whichever versions were incidentally hoisted.
This behaviour is a double-edged sword for a toolkit like skuba. Dependencies like Oxfmt and ESLint end up nested in a node_modules/skuba/node_modules subdirectory, where most editor and developer tooling integrations will not know to look.
node_modules
βββ skuba -> ./.pnpm/skuba@7.2.0
βββ .pnpm
βββ skuba@7.2.0
β βββ node_modules
β βββ oxfmt -> ../../oxfmt@0.64.0
βββ oxfmt@0.64.0
βββ node_modules
βββ other-dep -> <store>/other-dep
pnpm-workspace.yaml
pnpm allows us to specify dependencies to hoist via command line or pnpm-workspace.yaml. The number of package patterns we need to hoist may fluctuate over time, so specifying hoist patterns via command line would be difficult to maintain.
The skuba-maintained pnpm-workspace.yaml (previously .npmrc) currently instructs pnpm to hoist certain dependencies.
publicHoistPattern:
- '@eslint/*' # Managed by skuba
- '@types*' # Managed by skuba
- esbuild # Managed by skuba
- eslint # Managed by skuba
- oxfmt # Managed by skuba
- tsconfig-seek # Managed by skuba
- '@vitest/*' # Managed by skuba
- vitest # Managed by skuba
From the previous example, this will produce the following node_modules layout, allowing external integrations to find oxfmt in node_modules/oxfmt as before.
node_modules
βββ oxfmt -> ./.pnpm/oxfmt@0.64.0
βββ skuba -> ./.pnpm/skuba@7.2.0
βββ .pnpm
βββ skuba@7.2.0
β βββ node_modules
β βββ oxfmt -> ../../oxfmt@0.64.0
βββ oxfmt@0.64.0
βββ node_modules
βββ other-dep -> <store>/other-dep
Security controls
Beyond hoisting, skuba configures several pnpm security features in pnpm-workspace.yaml.
Minimum release age
minimumReleaseAge: 4320 prevents installing packages published less than 72 hours ago (4320 minutes). This protects against malicious packages published in the window before an npm compromise is discovered.
Additional trusted packages can be excluded from this restriction using minimumReleaseAgeExclude:
minimumReleaseAgeExclude:
- '@seek/*' # Managed by skuba
- skuba # Managed by skuba
- my-trusted-package
Allow builds
allowBuilds is an explicit allowlist of packages permitted to run lifecycle scripts (e.g. postinstall). pnpm will block build scripts from any package not listed here, reducing the risk of malicious code execution during pnpm install.
Common entries managed by skuba include native addons such as esbuild, dd-trace, and @datadog/* packages. If your project depends on other packages that require build scripts, add them to your own allowBuilds section.
allowBuilds:
esbuild: true # Managed by skuba
my-trusted-package: true
Upgrading to pnpm v11
skuba moves projects from pnpm v10 to pnpm v11 through an upgrade patch. Running pnpm skuba format applies pnpmβs pnpm-v10-to-v11 codemod, which rewrites the mechanical configuration changes, then reapplies the skuba-managed settings in pnpm-workspace.yaml.
The rest of this section covers the changes that the codemod cannot make for you.
Node.js 22 or newer is required
pnpm v11 drops Node.js 18, 19, 20 and 21, and is distributed as pure ESM. Run skuba migrate node24 first if your project is still on Node.js 20.
Build scripts must be allowlisted
strictDepBuilds now defaults to true, so pnpm install fails rather than warns when a dependency wants to run a lifecycle script that is not in allowBuilds.
allowBuilds also replaces the older onlyBuiltDependencies, onlyBuiltDependenciesFile, neverBuiltDependencies, ignoredBuiltDependencies and ignoreDepScripts settings, which have all been removed. Unlike those settings, it is a map of package name patterns to booleans rather than a list.
Other supply chain defaults are now on
| Setting | New default |
|---|---|
blockExoticSubdeps | true |
minimumReleaseAge | 1440 |
optimisticRepeatInstall | true |
verifyDepsBeforeRun | install |
skuba raises minimumReleaseAge to 4320 (72 hours) and sets trustPolicy: no-downgrade. See Security controls for how to grant exemptions.
skuba picks the managed settings to write based on the pnpm major version your project pins through packageManager or devEngines.packageManager, so a project that stays on pnpm v10 keeps its v10 settings. A project that pins no pnpm version at all is left alone.
.npmrc is auth and registry only
pnpm no longer reads other settings from .npmrc, package.json#pnpm, or npm_config_* environment variables. Move any remaining settings into pnpm-workspace.yaml with camelCase keys, and rename any npm_config_* variables you set in CI, Dockerfiles or shell profiles to pnpm_config_*.
Removed settings
packageManagerStrictVersion,packageManagerStrictandmanagePackageManagerVersionsare replaced bypmOnFail, which skuba sets toerror. TheCOREPACK_ENABLE_STRICTenvironment variable is no longer honoured.ignorePatchFailuresis gone; a patch that fails to apply now throws.allowNonAppliedPatchesis renamed toallowUnusedPatches.auditConfig.ignoreCvesis renamed toauditConfig.ignoreGhsas, so eachCVE-YYYY-NNNNNentry needs to be swapped for itsGHSA-xxxx-xxxx-xxxxequivalent.pnpm serveris gone,pnpm install -grequirespnpm add -g <pkg>, andpnpm link <pkg>only accepts paths.
Scripts shadow built-in commands
If your package.json declares a script named clean, setup, deploy or rebuild, pnpm <name> now runs your script instead of the built-in command. Use pnpm pm <name> when you want the built-in.
pnpmfiles can be ESM
.pnpmfile.mjs takes priority over .pnpmfile.cjs when both are present, and only one is loaded. pnpm-plugin-skuba now ships an ESM pnpmfile.mjs, so bump it to its latest version alongside the pnpm upgrade.
Lambdas and NodejsFunction
skuba format skips the pnpm v11 upgrade in projects that use NodejsFunction from aws-cdk-lib/aws-lambda-nodejs, whether it is imported directly or through the aws_lambda_nodejs namespace:
Patch skipped: Migrate pnpm v10 to v11 - aws-cdk-lib NodejsFunction cannot bundle its dependencies under pnpm v11
Such projects stay on pnpm v10 until they move off the construct, and keep receiving the pnpm v10 managed settings.
NodejsFunction bundles at cdk synth time, and when you pass bundling.nodeModules it runs pnpm install in its output directory to produce a real node_modules. Before that install, CDK unconditionally writes an empty pnpm-workspace.yaml into the output directory so that pnpm does not walk up to your repository root.
Under pnpm v10βs defaults this was harmless. Now that strictDepBuilds is on, any dependency with a lifecycle script has to appear in allowBuilds β and those entries live in the very pnpm-workspace.yaml that CDK has just overwritten:
ERR_PNPM_INSTALL_SCRIPTS_NOT_ALLOWED cpu-features@1.x.x is not allowed to run install scripts
The construct offers no hook late enough to fix this. commandHooks.beforeInstall runs before CDK writes the empty pnpm-workspace.yaml, so anything it writes is discarded (aws/aws-cdk#37898). pnpm makes this harder still by exporting its strict build and trust policy settings to child processes as environment variables without the matching allowBuilds and trustPolicyExclude allowlists (pnpm/pnpm#10988), so a nested install inherits the strictness but not the exemptions.
Rather than reimplement allowBuilds handling in an afterBundling hook, bundle your Lambdas yourself with Rolldown.lambdaAsset and hand the output directory to aws_lambda.Code.fromAsset. The plugin stages your real pnpm-workspace.yaml, .npmrc, pnpmfile, patches directory and lockfile into the output directory before installing, so pnpm v11βs defaults are satisfied. It also decouples bundling from cdk synth: your Lambda is built once by skuba build.
SEEKers can hand this migration off to the CDK Lambda bundling migration skill, which replaces NodejsFunction with Rolldown.lambdaAsset and is also available via the seek-ai-toolkit.
Migrating from Yarn 1.x to pnpm
This migration guide assumes that your project was scaffolded with a skuba template.
-
Install skuba 11.0.0 or greater
-
Add a
packageManagerkey topackage.json"packageManager": "pnpm@11.8.0", -
Install pnpm
corepack enable && corepack install(Check the install guide for alternate methods)
-
Create
pnpm-workspace.yamlSkip this step if your project does not use Yarn workspaces.
packages: # all packages in direct subdirectories of packages/ - 'packages/*'(Optional) If your sub-package
package.jsons reference one another using the syntaxfoo: *, you can replace these references with the workspace protocol using the syntaxfoo: workspace:*. -
Run
pnpm add --config pnpm-plugin-skuba -
Run
pnpm import && rm yarn.lockThis converts
yarn.locktopnpm-lock.yaml. -
Include additional hoisting settings in
pnpm-workspace.yamlfor ServerlessSkip this step if your project does not use Serverless. It can also be skipped for Serverless projects that use
esbuildbundling.configDependencies: pnpm-plugin-skuba: 2.0.0+sha512-nhxd9TdhOOXJ1bcQaqtDiI02gbxhJ8lTw3ZzSHDJPqIbbtnABQT7nLKqLX2zKi7tbfRI8+QSgL3eR2d/QFOLew== + + # Required for Serverless packaging + nodeLinker: hoisted + shamefullyHoist: true -
Run
rm -rf node_modules && pnpm installThis will ensure your local workspace will not have any lingering hoisted dependencies from
yarn.If you have a monorepo, delete all sub-package
node_modulesdirectories. -
Run
pnpm skuba lintAfter running
pnpm install, you may notice that some module imports no longer work. This is intended behaviour as these packages are no longer hoisted by default. Explicitly declare these asdependenciesordevDependenciesinpackage.json.For example:
Cannot find module 'foo'. Did you mean to set the 'moduleResolution' option to 'nodenext', or to add aliases to the 'paths' option? ts(2792)Run
pnpm install footo resolve this error. -
Modify
DockerfileorDockerfile.dev-depsFROM --platform=arm64 node:20-alpine AS dev-deps + RUN --mount=type=bind,source=package.json,target=package.json \ + corepack enable pnpm && corepack install + RUN --mount=type=bind,source=package.json,target=package.json \ + pnpm config set store-dir /root/.pnpm-store WORKDIR /workdir - COPY package.json yarn.lock ./ - COPY packages/foo/package.json packages/foo/ - RUN --mount=type=secret,id=npm,dst=/workdir/.npmrc \ - yarn install --frozen-lockfile --ignore-optional --non-interactive + RUN --mount=type=bind,source=package.json,target=package.json \ + --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \ + --mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \ + --mount=type=secret,id=npm,dst=/root/.npmrc,required=true \ + pnpm fetchMove the
dstof the ephemeral.npmrcfrom/workdir/.npmrcto/root/.npmrc, and use a bind mount in place ofCOPYto mountpnpm-lock.yaml.pnpm fetchdoes not requirepackage.jsonto be copied to resolve packages; trivial updates topackage.jsonlike a change inscriptswill no longer result in a cache miss.pnpm fetchis also optimised for monorepos and does away with the need to copy nestedpackage.jsons. However, this command only serves to populate a local package store and stops short of installing the packages, the implications of which are covered in the next step.If using the newer
GET_NPM_TOKENenvironment variable, your fetch command should instead look like:RUN --mount=type=bind,source=package.json,target=package.json \ --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \ --mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \ --mount=type=secret,id=npm,dst=/root/.npmrc,required=true \ --mount=type=secret,id=NPM_TOKEN,env=NPM_TOKEN,required=true \ pnpm fetchReview
Dockerfile.dev-depsfrom the newkoa-rest-apitemplate as a reference point. -
Replace
yarnwithpnpminDockerfileAs
pnpm fetchdoes not actually install packages, run a subsequentpnpm install --offlinebefore any command which may reference a dependency. Swap outyarncommands forpnpmcommands, and drop the unnecessaryAS depsstage.- FROM ${BASE_IMAGE} AS deps - - RUN yarn install --ignore-optional --ignore-scripts --non-interactive --offline --production - - ### - FROM ${BASE_IMAGE} AS build COPY . . - RUN yarn build + RUN pnpm install --offline + RUN pnpm build + RUN pnpm prune --prod ### FROM --platform=arm64 gcr.io/distroless/nodejs20-debian12 AS runtime WORKDIR /workdir COPY --from=build /workdir/lib lib - COPY --from=deps /workdir/node_modules node_modules + COPY --from=build /workdir/node_modules node_modules ENV NODE_ENV=production -
Modify plugins in
.buildkite/pipeline.ymlFollowing the Dockerfile changes, apply the analogous changes to the Buildkite pipeline.
We are using an updated caching syntax on
package.jsonwhich caches only on thepackageManagerkey. This requires the seek-oss/docker-ecr-cache plugin version to be >= 2.2.0.If using the older
private-npmsetup:seek-oss/private-npm#v1.3.0: env: NPM_READ_TOKEN + output-path: /tmp/-Β seek-oss/docker-ecr-cache#v2.1.0: + seek-oss/docker-ecr-cache#v3.0.0: cache-on: - - package.json - - yarn.lock + - package.json#.packageManager + - pnpm-lock.yaml + - pnpm-workspace.yaml dockerfile: Dockerfile.dev-deps - secrets: id=npm,src=.npmrc + secrets: id=npm,src=/tmp/.npmrcIf using the newer
GET_NPM_TOKENenvironment variable to abstract awayaws-sm/private-npm, your pipeline docker-ecr-cache plugin should look like:- seek-oss/docker-ecr-cache#v3.0.0: cache-on: - pnpm-workspace.yaml - package.json#.packageManager - pnpm-lock.yaml dockerfile: Dockerfile.dev-deps secrets: - id=npm,src=/var/lib/buildkite-agent/.npmrc - NPM_TOKEN -
Run
pnpm install --offlineand replaceyarnwithpnpmin.buildkite/pipeline.yml- label: π§ͺ Test & Lint commands: + - echo '--- pnpm install --offline' + - pnpm install --offline - - echo '+++ yarn test:ci' - - yarn test:ci - - echo '--- yarn lint' - - yarn lint + - echo '+++ pnpm test:ci' + - pnpm test:ci + - echo '--- pnpm lint' + - pnpm lint -
Search for other references to
yarnin your project. Replace these withpnpmwhere necessary.For example, you may have the lockfile listed in
.github/CODEOWNERS:- yarn.lock + pnpm-lock.yaml
FAQ
Q: Iβm running into ERR_PNPM_CANNOT_DEPLOYβ A deploy is only possible from inside a workspace
A: pnpm deploy is a reserved command. Use pnpm run deploy instead.
Q: Iβm seeing ERR_PNPM_RECURSIVE_EXEC_FIRST_FAILβ Command "<NAME>" not found in my pipeline
A: Ensure pnpm install --offline is referenced earlier within pipeline step as shown in step 14.
Q: Iβm seeing ERR_PNPM_RECURSIVE_EXEC_FIRST_FAILβ Command "workspace" not found in my pipeline
A: pnpm workspace <PACKAGE_NAME> does not work. Replace it with the --filter flag.
Contributing
This guide is not comprehensive just yet, and it may not account for certain intricacies of your project.
If you run into an issue that is not documented here, please start a discussion or contribute a change so others can benefit from your findings. This page may be edited on GitHub.