Skip to content

Commit ca72620

Browse files
authored
ci: upgrade pnpm to v11 (#17169)
# Overview Ports pnpm `10.27.0` → `11.9.0` onto the `3.x` line (from #17114, adapted for 3.x divergence). pnpm v11 ignores `package.json#pnpm` and reads `--ignore-workspace` as "skip `pnpm-workspace.yaml` entirely." So root config moves into `pnpm-workspace.yaml`, and every previously `--ignore-workspace`'d install becomes a standalone workspace root. ## Key Changes #### Migrate root pnpm config to `pnpm-workspace.yaml` Moves `overrides`, the build allowlist (`onlyBuiltDependencies` → `allowBuilds`), and `verifyDepsBeforeRun: false` out of `package.json` (which v11 ignores) into `pnpm-workspace.yaml`. Bumps `packageManager` and `.tool-versions` to `11.9.0`; removes the root `engines` block to match `main`. #### Make `test/` a standalone workspace root `setupProd.ts` writes `test/pnpm-workspace.yaml` (tarball overrides + `dangerouslyAllowAllBuilds`) instead of mutating `package.json#pnpm`, so the prod-test flow installs without `--ignore-workspace`. #### Write build approvals when scaffolding `create-payload-app` writes an `allowBuilds` block into the scaffolded `pnpm-workspace.yaml` before install, so new projects install under v11 without `ERR_PNPM_IGNORED_BUILDS`. #### Port template-build tooling to standalone roots `build-template-with-local-pkgs.ts` and `generate-template-variations.ts` write a transient standalone `pnpm-workspace.yaml` instead of passing `--ignore-workspace`. The pack step runs with `--all` so every workspace dependency (e.g. `@payloadcms/admin-bar`) has a local tarball for its `workspace:*` spec to remap onto, and the template build allowlist mirrors the root `allowBuilds`. Widens template `engines.pnpm` to `^9 || ^10 || ^11`, and bumps the blank template's `tsx` to `4.22.4` (the 4.21.0 ESM loader made pnpm's `.pnpmfile.mjs` probe fatal under `--import=tsx/esm`). #### CI Fixes Playwright version extraction (v11's multi-line `pnpm ls` broke the `GITHUB_ENV` write) and adds a `check-template-build-approvals` job that scaffolds the blank template and installs it under v11 to catch allowlist drift. #### Catalogs Migrate from overrides to pnpm catalogs ## Design Decisions - **Template builds enumerate `allowBuilds` mirroring the root list:** rather than `dangerouslyAllowAllBuilds`, template builds reuse the monorepo's allowlist so a single set governs both, keeping build-script approval explicit and auditable. - **Drift guard packs local source:** it installs the current packed packages, not the published versions, so a newly added dependency that ships an unapproved build script fails the guard with the same `ERR_PNPM_IGNORED_BUILDS` a user would hit. ## References / Links - Upstream: #17114 - pnpm `verifyDepsBeforeRun` deadlock: pnpm/pnpm#8865
1 parent d128fde commit ca72620

23 files changed

Lines changed: 656 additions & 283 deletions

.github/workflows/main.yml

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ jobs:
2525
needs_build: ${{ steps.filter.outputs.needs_build }}
2626
needs_tests: ${{ steps.filter.outputs.needs_tests }}
2727
templates: ${{ steps.filter.outputs.templates }}
28+
build_approvals: ${{ steps.filter.outputs.build_approvals }}
2829
steps:
2930
# https://github.com/actions/virtual-environments/issues/1187
3031
- name: tune linux network
@@ -51,11 +52,18 @@ jobs:
5152
- 'package.json'
5253
templates:
5354
- 'templates/**'
55+
build_approvals:
56+
- '.github/workflows/main.yml'
57+
# Any dependency-graph change (packages/* or the blank workspace member) lands in
58+
# the root lockfile; that, plus the allowlist itself, is what can introduce drift.
59+
- 'pnpm-lock.yaml'
60+
- 'packages/create-payload-app/src/lib/configure-pnpm-builds.ts'
5461
- name: Log filter results
5562
run: |
5663
echo "needs_build: ${{ steps.filter.outputs.needs_build }}"
5764
echo "needs_tests: ${{ steps.filter.outputs.needs_tests }}"
5865
echo "templates: ${{ steps.filter.outputs.templates }}"
66+
echo "build_approvals: ${{ steps.filter.outputs.build_approvals }}"
5967
6068
lint:
6169
runs-on: ubuntu-24.04
@@ -303,8 +311,9 @@ jobs:
303311

304312
- name: Store Playwright's Version
305313
run: |
306-
# Extract the version number using a more targeted regex pattern with awk
307-
PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --depth=0 | awk '/@playwright\/test/ {print $2}')
314+
# `exit` after the first match: v11's `pnpm ls` lists the dep across every workspace
315+
# project, and a multi-line value would break the GITHUB_ENV write.
316+
PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --depth=0 | awk '/@playwright\/test/ {print $2; exit}')
308317
echo "Playwright's Version: $PLAYWRIGHT_VERSION"
309318
echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV
310319
@@ -420,16 +429,17 @@ jobs:
420429

421430
- name: Build Template
422431
run: |
423-
pnpm run script:pack --dest templates/${{ matrix.template }}
432+
pnpm run script:pack --all --dest templates/${{ matrix.template }}
424433
pnpm run script:build-template-with-local-pkgs ${{ matrix.template }} $DB_CONNECTION
425434
env:
426435
NODE_OPTIONS: --max-old-space-size=8096
427436
DB_CONNECTION: ${{ steps.db.outputs.POSTGRES_URL || steps.db.outputs.MONGODB_URL }}
428437

429438
- name: Store Playwright's Version
430439
run: |
431-
# Extract the version number using a more targeted regex pattern with awk
432-
PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --depth=0 | awk '/@playwright\/test/ {print $2}')
440+
# `exit` after the first match: v11's `pnpm ls` lists the dep across every workspace
441+
# project, and a multi-line value would break the GITHUB_ENV write.
442+
PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --depth=0 | awk '/@playwright\/test/ {print $2; exit}')
433443
echo "Playwright's Version: $PLAYWRIGHT_VERSION"
434444
echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV
435445
@@ -465,6 +475,21 @@ jobs:
465475
MONGODB_URL: ${{ steps.db.outputs.MONGODB_URL }}
466476
NEXT_TELEMETRY_DISABLED: 1
467477

478+
# Fails if create-payload-app's build-script allowlist drifts from the blank template's deps:
479+
# scaffolds blank, installs it standalone under pnpm v11, and asserts no ERR_PNPM_IGNORED_BUILDS.
480+
check-template-build-approvals:
481+
runs-on: ubuntu-24.04
482+
needs: [changes, build]
483+
if: needs.changes.outputs.build_approvals == 'true'
484+
steps:
485+
- uses: actions/checkout@v5
486+
487+
- name: Node setup
488+
uses: ./.github/actions/setup
489+
490+
- name: Check create-payload-app build approvals
491+
run: pnpm --filter scripts check-template-build-approvals
492+
468493
tests-type-generation:
469494
runs-on: ubuntu-24.04
470495
needs: [changes, build]

.tool-versions

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
pnpm 10.27.0
1+
pnpm 11.9.0
22
nodejs 24.13.0

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,4 +250,4 @@ Examples:
250250
- LLMS.txt: <https://payloadcms.com/llms.txt>
251251
- LLMS-FULL.txt: <https://payloadcms.com/llms-full.txt>
252252
- Node version: ^18.20.2 || >=20.9.0
253-
- pnpm version: ^10.27.0
253+
- pnpm version: ^11.9.0

package.json

Lines changed: 9 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,8 @@
9292
"lint:scss": "stylelint \"packages/ui/src/**/*.scss\" \"templates/**/*.scss\"",
9393
"obliterate-playwright-cache-macos": "rm -rf ~/Library/Caches/ms-playwright && find /System/Volumes/Data/private/var/folders -type d -name 'playwright*' -exec rm -rf {} +",
9494
"prepare": "husky && pnpm turbo build --filter @payloadcms/typescript-plugin",
95-
"prepare-run-test-against-prod": "pnpm bf && rm -rf test/packed && rm -rf test/node_modules && rm -rf app && rm -f test/pnpm-lock.yaml && pnpm run script:pack --all --no-build --dest test/packed && pnpm runts test/setupProd.ts && cd test && pnpm i --ignore-workspace && cd ..",
96-
"prepare-run-test-against-prod:ci": "rm -rf test/packed && rm -rf test/node_modules && rm -rf app && rm -f test/pnpm-lock.yaml && pnpm run script:pack --all --no-build --dest test/packed && pnpm runts test/setupProd.ts && cd test && pnpm i --ignore-workspace && cd ..",
95+
"prepare-run-test-against-prod": "pnpm bf && rm -rf test/packed && rm -rf test/node_modules && rm -rf app && rm -f test/pnpm-lock.yaml && pnpm run script:pack --all --no-build --dest test/packed && pnpm runts test/setupProd.ts && cd test && pnpm i && cd ..",
96+
"prepare-run-test-against-prod:ci": "rm -rf test/packed && rm -rf test/node_modules && rm -rf app && rm -f test/pnpm-lock.yaml && pnpm run script:pack --all --no-build --dest test/packed && pnpm runts test/setupProd.ts && cd test && pnpm i && cd ..",
9797
"publish-prerelease": "pnpm --filter releaser publish-prerelease",
9898
"reinstall": "pnpm clean:all && pnpm install",
9999
"release": "pnpm --filter releaser release --tag latest",
@@ -202,10 +202,10 @@
202202
"axe-core": "4.11.0",
203203
"chalk": "^4.1.2",
204204
"comment-json": "^4.2.3",
205-
"copyfiles": "2.4.1",
205+
"copyfiles": "catalog:",
206206
"create-payload-app": "workspace:*",
207-
"cross-env": "7.0.3",
208-
"dotenv": "16.4.7",
207+
"cross-env": "catalog:",
208+
"dotenv": "catalog:",
209209
"drizzle-kit": "0.31.7",
210210
"drizzle-orm": "0.44.7",
211211
"escape-html": "^1.0.3",
@@ -226,8 +226,8 @@
226226
"postcss": "^8.4.49",
227227
"postcss-scss": "^4.0.9",
228228
"prettier": "3.5.3",
229-
"react": "19.2.6",
230-
"react-dom": "19.2.6",
229+
"react": "catalog:",
230+
"react-dom": "catalog:",
231231
"rimraf": "6.0.1",
232232
"sharp": "0.32.6",
233233
"shelljs": "0.8.5",
@@ -239,42 +239,10 @@
239239
"tstyche": "3.5.0",
240240
"tsx": "4.22.4",
241241
"turbo": "^2.5.4",
242-
"typescript": "5.7.3",
242+
"typescript": "catalog:",
243243
"vitest": "4.1.2",
244244
"wrangler": "~4.61.1",
245245
"zod": "4.3.6"
246246
},
247-
"packageManager": "pnpm@10.27.0",
248-
"engines": {
249-
"node": "^18.20.2 || >=20.9.0",
250-
"pnpm": "^10.27.0"
251-
},
252-
"pnpm": {
253-
"onlyBuiltDependencies": [
254-
"@parcel/watcher",
255-
"@sentry/cli",
256-
"@swc/core",
257-
"@vercel/git-hooks",
258-
"better-sqlite3",
259-
"bufferutil",
260-
"esbuild",
261-
"mongodb-memory-server",
262-
"sharp",
263-
"unrs-resolver",
264-
"utf-8-validate",
265-
"workerd"
266-
],
267-
"overrides": {
268-
"@types/request>form-data": "^2.5.6",
269-
"amazon-cognito-identity-js>js-cookie": "^3.0.7",
270-
"copyfiles": "$copyfiles",
271-
"cross-env": "$cross-env",
272-
"dotenv": "$dotenv",
273-
"graphql": "16.8.1",
274-
"mcp-handler>@modelcontextprotocol/sdk": "1.27.1",
275-
"react": "$react",
276-
"react-dom": "$react-dom",
277-
"typescript": "$typescript"
278-
}
279-
}
247+
"packageManager": "pnpm@11.9.0"
280248
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import fse from 'fs-extra'
2+
import path from 'path'
3+
4+
import type { PackageManager } from '../types.js'
5+
6+
/*
7+
* Build scripts pnpm v11 must be allowed to run for the projects create-payload-app scaffolds;
8+
* otherwise the install fails with ERR_PNPM_IGNORED_BUILDS. esbuild/sharp/unrs-resolver cover the
9+
* default templates; workerd is required by with-cloudflare-d1.
10+
* TODO(follow-up): the CI drift guard only verifies the blank template — extend it across every
11+
* getValidTemplates() entry so this single list can't drift per-template.
12+
*/
13+
const ALLOWED_BUILDS = ['esbuild', 'sharp', 'unrs-resolver', 'workerd']
14+
15+
/**
16+
* pnpm v11 fails installs that have unapproved dependency build scripts, reading approvals only from
17+
* pnpm-workspace.yaml (the `pnpm` field in package.json is ignored). create-next-app now scaffolds a
18+
* placeholder `allowBuilds` block (sharp/unrs-resolver left unset) plus an `ignoredBuiltDependencies`
19+
* denylist, so we must merge rather than skip: force Payload's build scripts to `true` and remove them
20+
* from the denylist. No-op for non-pnpm package managers.
21+
*/
22+
export async function ensurePnpmBuildApprovals(args: {
23+
packageManager: PackageManager
24+
projectDir: string
25+
}): Promise<void> {
26+
const { packageManager, projectDir } = args
27+
if (packageManager !== 'pnpm') {
28+
return
29+
}
30+
31+
const workspacePath = path.join(projectDir, 'pnpm-workspace.yaml')
32+
const existing = (await fse.pathExists(workspacePath))
33+
? await fse.readFile(workspacePath, 'utf-8')
34+
: ''
35+
36+
await fse.writeFile(workspacePath, upsertBuildApprovals(existing))
37+
}
38+
39+
type WorkspaceBlock = {
40+
key: null | string
41+
lines: string[]
42+
}
43+
44+
/**
45+
* Merges Payload's required build approvals into a pnpm-workspace.yaml string. Pure and idempotent:
46+
* sets each required package to `true` under `allowBuilds` (adding the block if missing) and strips
47+
* those packages from any `ignoredBuiltDependencies` denylist. Top-level keys are detected by a
48+
* zero-indent `key:`, with their two-space children grouped beneath them.
49+
*/
50+
export function upsertBuildApprovals(content: string): string {
51+
const blocks = toTopLevelBlocks(content)
52+
53+
const ignored = blocks.find((block) => block.key === 'ignoredBuiltDependencies')
54+
if (ignored) {
55+
ignored.lines = ignored.lines.filter((line, index) => {
56+
if (index === 0) {
57+
return true
58+
}
59+
const name = /^\s*-\s*'?([^'\s]+)'?\s*$/.exec(line)?.[1]
60+
return !(name && ALLOWED_BUILDS.includes(name))
61+
})
62+
}
63+
64+
const allow = blocks.find((block) => block.key === 'allowBuilds')
65+
if (allow) {
66+
for (const name of ALLOWED_BUILDS) {
67+
const pattern = new RegExp(`^\\s+'?${escapeRegExp(name)}'?:`)
68+
const index = allow.lines.findIndex((line) => pattern.test(line))
69+
if (index >= 0) {
70+
allow.lines[index] = ` ${name}: true`
71+
} else {
72+
allow.lines.push(` ${name}: true`)
73+
}
74+
}
75+
} else {
76+
blocks.push({
77+
key: 'allowBuilds',
78+
lines: ['allowBuilds:', ...ALLOWED_BUILDS.map((name) => ` ${name}: true`)],
79+
})
80+
}
81+
82+
const output = blocks.flatMap((block) => block.lines).join('\n')
83+
return output.endsWith('\n') ? output : `${output}\n`
84+
}
85+
86+
function toTopLevelBlocks(content: string): WorkspaceBlock[] {
87+
const lines = content.length ? content.replace(/\n$/, '').split('\n') : []
88+
const blocks: WorkspaceBlock[] = []
89+
90+
for (const line of lines) {
91+
const key = /^[^\s#]/.test(line) ? /^([^:]+):/.exec(line)?.[1] : undefined
92+
const current = blocks[blocks.length - 1]
93+
if (key !== undefined) {
94+
blocks.push({ key, lines: [line] })
95+
} else if (current) {
96+
current.lines.push(line)
97+
} else {
98+
blocks.push({ key: null, lines: [line] })
99+
}
100+
}
101+
102+
return blocks
103+
}
104+
105+
function escapeRegExp(value: string): string {
106+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
107+
}

packages/create-payload-app/src/lib/create-project.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { tryInitRepoAndCommit } from '../utils/git.js'
1818
import { debug, error, info, warning } from '../utils/log.js'
1919
import { configurePayloadConfig } from './configure-payload-config.js'
2020
import { configurePluginProject } from './configure-plugin-project.js'
21+
import { ensurePnpmBuildApprovals } from './configure-pnpm-builds.js'
2122
import { downloadExample } from './download-example.js'
2223
import { downloadSkill } from './download-skill.js'
2324
import { downloadTemplate } from './download-template.js'
@@ -54,6 +55,8 @@ async function installDeps(args: {
5455
installCmd = 'bun install'
5556
}
5657

58+
await ensurePnpmBuildApprovals({ packageManager, projectDir })
59+
5760
try {
5861
await execa.command(installCmd, {
5962
cwd: path.resolve(projectDir),

packages/create-payload-app/src/lib/init-next.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { CliArgs, DbType, NextAppDetails, NextConfigType, PackageManager }
1414
import { copyRecursiveSync } from '../utils/copy-recursive-sync.js'
1515
import { debug as origDebug, warning } from '../utils/log.js'
1616
import { moveMessage } from '../utils/messages.js'
17+
import { ensurePnpmBuildApprovals } from './configure-pnpm-builds.js'
1718
import { installPackages } from './install-packages.js'
1819
import { wrapNextConfig } from './wrap-next-config.js'
1920

@@ -233,6 +234,8 @@ async function installDeps(projectDir: string, packageManager: PackageManager, d
233234
// Match graphql version of @payloadcms/next
234235
packagesToInstall.push('graphql@^16.8.1')
235236

237+
await ensurePnpmBuildApprovals({ packageManager, projectDir })
238+
236239
return await installPackages({ packageManager, packagesToInstall, projectDir })
237240
}
238241

0 commit comments

Comments
 (0)