From aaa87281ef2f0cb0e13512209a9df6caf1a401e0 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Mon, 22 Jun 2026 11:23:23 -0400 Subject: [PATCH 01/16] ci: upgrade pnpm to v11 --- .tool-versions | 2 +- CLAUDE.md | 2 +- package.json | 30 +----------------------------- pnpm-workspace.yaml | 27 +++++++++++++++++++++++++++ templates/blank/package.json | 2 +- templates/website/package.json | 2 +- 6 files changed, 32 insertions(+), 33 deletions(-) diff --git a/.tool-versions b/.tool-versions index a1bfa5b5d53..2aef1cf09db 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -pnpm 10.27.0 +pnpm 11.7.0 nodejs 24.15.0 diff --git a/CLAUDE.md b/CLAUDE.md index 99b6dfc0e2b..08fa655ad82 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -266,7 +266,7 @@ Examples: - LLMS.txt: - LLMS-FULL.txt: - Node version: >=24.15.0 -- pnpm version: ^10.27.0 +- pnpm version: ^11.7.0 ## Admin Panel diff --git a/package.json b/package.json index 8655864fc76..b89ee00ba42 100644 --- a/package.json +++ b/package.json @@ -203,33 +203,5 @@ "wrangler": "~4.61.1", "zod": "4.3.6" }, - "packageManager": "pnpm@10.27.0", - "pnpm": { - "onlyBuiltDependencies": [ - "@parcel/watcher", - "@sentry/cli", - "@swc/core", - "@vercel/git-hooks", - "better-sqlite3", - "bufferutil", - "esbuild", - "mongodb-memory-server", - "sharp", - "unrs-resolver", - "utf-8-validate", - "workerd" - ], - "overrides": { - "@types/request>form-data": "^2.5.6", - "amazon-cognito-identity-js>js-cookie": "^3.0.7", - "copyfiles": "$copyfiles", - "cross-env": "$cross-env", - "dotenv": "$dotenv", - "graphql": "16.8.1", - "mcp-handler>@modelcontextprotocol/sdk": "1.27.1", - "react": "$react", - "react-dom": "$react-dom", - "typescript": "$typescript" - } - } + "packageManager": "pnpm@11.7.0" } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b44e0eba8be..7f5df6b3752 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,3 +11,30 @@ symlink: true nodeLinker: isolated hoistWorkspacePackages: false # the default in pnpm v9 is true, but that can break our runtime dependency checks savePrefix: '' + +overrides: + '@types/request>form-data': '^2.5.6' + 'amazon-cognito-identity-js>js-cookie': '^3.0.7' + copyfiles: $copyfiles + cross-env: $cross-env + dotenv: $dotenv + graphql: '16.8.1' + 'mcp-handler>@modelcontextprotocol/sdk': '1.27.1' + react: $react + react-dom: $react-dom + typescript: $typescript + +# Replaces pnpm v10 `onlyBuiltDependencies`: packages permitted to run install scripts. +allowBuilds: + '@parcel/watcher': true + '@sentry/cli': true + '@swc/core': true + '@vercel/git-hooks': true + better-sqlite3: true + bufferutil: true + esbuild: true + mongodb-memory-server: true + sharp: true + unrs-resolver: true + utf-8-validate: true + workerd: true diff --git a/templates/blank/package.json b/templates/blank/package.json index 70f3445344e..edd98e9e684 100644 --- a/templates/blank/package.json +++ b/templates/blank/package.json @@ -50,7 +50,7 @@ }, "engines": { "node": ">=24.15.0", - "pnpm": "^9 || ^10" + "pnpm": "^9 || ^10 || ^11" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/templates/website/package.json b/templates/website/package.json index 144df79a5b2..1530590a14d 100644 --- a/templates/website/package.json +++ b/templates/website/package.json @@ -80,7 +80,7 @@ }, "engines": { "node": ">=24.15.0", - "pnpm": "^9 || ^10" + "pnpm": "^9 || ^10 || ^11" }, "pnpm": { "onlyBuiltDependencies": [ From 437b7faca3a66adafc3d5e2ec5cc768903536c56 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Mon, 22 Jun 2026 11:23:32 -0400 Subject: [PATCH 02/16] chore: migrate test pnpm config to pnpm-workspace.yaml for v11 --- test/package.json | 6 ------ test/pnpm-workspace.yaml | 5 +++++ 2 files changed, 5 insertions(+), 6 deletions(-) create mode 100644 test/pnpm-workspace.yaml diff --git a/test/package.json b/test/package.json index e78ea283382..5a187062455 100644 --- a/test/package.json +++ b/test/package.json @@ -119,11 +119,5 @@ "vitest": "4.1.6", "wrangler": "~4.61.1", "zod": "^4.0.0" - }, - "pnpm": { - "neverBuiltDependencies": [], - "overrides": { - "graphql": "16.8.1" - } } } diff --git a/test/pnpm-workspace.yaml b/test/pnpm-workspace.yaml new file mode 100644 index 00000000000..f5bc4a7ae95 --- /dev/null +++ b/test/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +# Used by the standalone prod-test flow (`cd test && pnpm i --ignore-workspace`), +# where pnpm treats this dir as its own root. v11 ignores `package.json#pnpm`, +# so the graphql override lives here. +overrides: + graphql: '16.8.1' From 34688ed2d15786c0676bd42fb6ed2d3c3abd75ee Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Thu, 25 Jun 2026 10:38:20 -0700 Subject: [PATCH 03/16] chore: bump pnpm to 11.9.0 --- .tool-versions | 2 +- CLAUDE.md | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.tool-versions b/.tool-versions index 2aef1cf09db..7249beb09f5 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -pnpm 11.7.0 +pnpm 11.9.0 nodejs 24.15.0 diff --git a/CLAUDE.md b/CLAUDE.md index 08fa655ad82..120bf99e65f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -266,7 +266,7 @@ Examples: - LLMS.txt: - LLMS-FULL.txt: - Node version: >=24.15.0 -- pnpm version: ^11.7.0 +- pnpm version: ^11.9.0 ## Admin Panel diff --git a/package.json b/package.json index b89ee00ba42..f6db19f8a6e 100644 --- a/package.json +++ b/package.json @@ -203,5 +203,5 @@ "wrangler": "~4.61.1", "zod": "4.3.6" }, - "packageManager": "pnpm@11.7.0" + "packageManager": "pnpm@11.9.0" } From 28f4e440bdd592239da885f1b0e635fffa7456d9 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Thu, 25 Jun 2026 11:26:05 -0700 Subject: [PATCH 04/16] fix: disable verifyDepsBeforeRun to prevent CI install deadlock under pnpm v11 --- pnpm-workspace.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7f5df6b3752..2f9fce1c196 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,6 +11,12 @@ symlink: true nodeLinker: isolated hoistWorkspacePackages: false # the default in pnpm v9 is true, but that can break our runtime dependency checks savePrefix: '' +# pnpm v11 defaults this to `install`, which auto-reinstalls when node_modules looks +# stale before any `pnpm run`/`pnpm exec`. The root `prepare` script nests `pnpm turbo` +# inside the outer install's lifecycle, so that check deadlocks against the in-flight +# install (CI hangs until SIGTERM). Restore the v10 behavior. +# See https://github.com/pnpm/pnpm/issues/8865 +verifyDepsBeforeRun: false overrides: '@types/request>form-data': '^2.5.6' From 00e73ab6241d932bdbf84da8ced2a68301ab57c1 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Thu, 25 Jun 2026 12:36:13 -0700 Subject: [PATCH 05/16] fix: restore build approvals and overrides for v11 in scaffolds and prod-test tooling --- .../src/lib/configure-pnpm-builds.ts | 38 +++++++++++++++++++ .../src/lib/create-project.ts | 3 ++ .../create-payload-app/src/lib/init-next.ts | 3 ++ test/setupProd.ts | 19 +++++----- .../src/build-template-with-local-pkgs.ts | 38 ++++++++++++++----- 5 files changed, 82 insertions(+), 19 deletions(-) create mode 100644 packages/create-payload-app/src/lib/configure-pnpm-builds.ts diff --git a/packages/create-payload-app/src/lib/configure-pnpm-builds.ts b/packages/create-payload-app/src/lib/configure-pnpm-builds.ts new file mode 100644 index 00000000000..9ec9721a332 --- /dev/null +++ b/packages/create-payload-app/src/lib/configure-pnpm-builds.ts @@ -0,0 +1,38 @@ +import fse from 'fs-extra' +import path from 'path' + +import type { PackageManager } from '../types.js' + +// Build scripts pnpm v11 must be allowed to run for a Payload project; otherwise the install +// fails with ERR_PNPM_IGNORED_BUILDS. +const ALLOWED_BUILDS = ['esbuild', 'sharp', 'unrs-resolver'] + +/** + * pnpm v11 fails installs that have unapproved dependency build scripts, and reads build approvals + * only from pnpm-workspace.yaml (the `pnpm` field in package.json is ignored). Ensure the project + * declares an `allowBuilds` block so the install runs the scripts Payload needs and future installs + * succeed. No-op for other package managers or when the project already manages `allowBuilds`. + */ +export async function ensurePnpmBuildApprovals(args: { + packageManager: PackageManager + projectDir: string +}): Promise { + const { packageManager, projectDir } = args + if (packageManager !== 'pnpm') { + return + } + + const workspacePath = path.join(projectDir, 'pnpm-workspace.yaml') + const existing = (await fse.pathExists(workspacePath)) + ? await fse.readFile(workspacePath, 'utf-8') + : '' + + if (existing.includes('allowBuilds:')) { + return + } + + const block = `allowBuilds:\n${ALLOWED_BUILDS.map((name) => ` ${name}: true`).join('\n')}\n` + const content = + existing && !existing.endsWith('\n') ? `${existing}\n${block}` : `${existing}${block}` + await fse.writeFile(workspacePath, content) +} diff --git a/packages/create-payload-app/src/lib/create-project.ts b/packages/create-payload-app/src/lib/create-project.ts index bd7e8a5e1de..ae854406d69 100644 --- a/packages/create-payload-app/src/lib/create-project.ts +++ b/packages/create-payload-app/src/lib/create-project.ts @@ -22,6 +22,7 @@ import { } from '../utils/resolvePackageVersion.js' import { configurePayloadConfig } from './configure-payload-config.js' import { configurePluginProject } from './configure-plugin-project.js' +import { ensurePnpmBuildApprovals } from './configure-pnpm-builds.js' import { downloadExample } from './download-example.js' import { downloadSkill } from './download-skill.js' import { downloadTemplate } from './download-template.js' @@ -58,6 +59,8 @@ async function installDeps(args: { installCmd = 'bun install' } + await ensurePnpmBuildApprovals({ packageManager, projectDir }) + try { await execa.command(installCmd, { cwd: path.resolve(projectDir), diff --git a/packages/create-payload-app/src/lib/init-next.ts b/packages/create-payload-app/src/lib/init-next.ts index e0ba7363701..dbc27dc5890 100644 --- a/packages/create-payload-app/src/lib/init-next.ts +++ b/packages/create-payload-app/src/lib/init-next.ts @@ -18,6 +18,7 @@ import { DEFAULT_PAYLOAD_VERSION_TAG, resolvePackageVersion, } from '../utils/resolvePackageVersion.js' +import { ensurePnpmBuildApprovals } from './configure-pnpm-builds.js' import { installPackages } from './install-packages.js' import { wrapNextConfig } from './wrap-next-config.js' @@ -250,6 +251,8 @@ async function installDeps(args: { // Match graphql version of @payloadcms/next packagesToInstall.push('graphql@^16.8.1') + await ensurePnpmBuildApprovals({ packageManager, projectDir }) + return await installPackages({ packageManager, packagesToInstall, projectDir }) } diff --git a/test/setupProd.ts b/test/setupProd.ts index d5bc8f414d3..9ac09774c45 100644 --- a/test/setupProd.ts +++ b/test/setupProd.ts @@ -84,17 +84,16 @@ export function setupProd() { } } - // now add them all to overrides and pnpm.overrides as well - packageJson.pnpm = packageJson.pnpm || {} - packageJson.pnpm.overrides = packageJson.pnpm.overrides || {} - packageJson.overrides = packageJson.overrides || {} - for (const [packageName, packageVersion] of Object.entries(allDependencies)) { - packageJson.pnpm.overrides[packageName] = packageVersion - packageJson.overrides[packageName] = packageVersion - } - - // write it out + // write out package.json (direct deps now point at local tarballs) fs.writeFileSync(path.resolve(dirname, 'package.json'), JSON.stringify(packageJson, null, 2)) + + // v11 ignores package.json#pnpm, so overrides live here. Pin @payloadcms deps to local tarballs + // (else transitive requests hit the unpublished in-repo beta). graphql mirrors the committed + // baseline; dangerouslyAllowAllBuilds keeps build scripts running under v11's strict default. + const overrides = { graphql: '16.8.1', ...allDependencies } + const overrideLines = Object.entries(overrides).map(([name, spec]) => ` '${name}': '${spec}'`) + const workspaceYaml = `overrides:\n${overrideLines.join('\n')}\ndangerouslyAllowAllBuilds: true\n` + fs.writeFileSync(path.resolve(dirname, 'pnpm-workspace.yaml'), workspaceYaml) } setupProd() diff --git a/tools/scripts/src/build-template-with-local-pkgs.ts b/tools/scripts/src/build-template-with-local-pkgs.ts index 44e9c5e5f0e..4eda581d105 100644 --- a/tools/scripts/src/build-template-with-local-pkgs.ts +++ b/tools/scripts/src/build-template-with-local-pkgs.ts @@ -59,7 +59,6 @@ async function main() { const packageJsonObj = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8')) as { dependencies?: Record devDependencies?: Record - pnpm?: { overrides?: Record } } // Point every workspace dependency the template declares at its local tarball. This also @@ -76,16 +75,19 @@ async function main() { } } - // Force the local tarballs across the entire dependency tree via overrides. Without this, the - // packed tarballs request their sibling @payloadcms packages at the in-repo version (e.g. a beta - // that is not published to npm), and the install fails with ERR_PNPM_NO_MATCHING_VERSION. - packageJsonObj.pnpm = { - ...packageJsonObj.pnpm, - overrides: { ...packageJsonObj.pnpm?.overrides, ...fileSpecByPackageName }, - } - await fs.writeFile(packageJsonPath, JSON.stringify(packageJsonObj, null, 2)) + // v11 ignores package.json#pnpm, so config lives here. overrides pin sibling @payloadcms deps to + // local tarballs (else they resolve to the unpublished in-repo beta); allowBuilds keeps the + // template's build scripts running under v11's strict default. + await fs.writeFile( + path.join(templatePath, 'pnpm-workspace.yaml'), + toWorkspaceYaml({ + allowBuilds: ['esbuild', 'sharp', 'unrs-resolver'], + overrides: fileSpecByPackageName, + }), + ) + execSync('pnpm install --no-frozen-lockfile --ignore-workspace', execOpts) await fs.writeFile( path.resolve(templatePath, '.env'), @@ -165,6 +167,24 @@ async function runBuildWithWarningsCheck(args: { }) } +/** Serializes a minimal pnpm-workspace.yaml; quotes keys/values so scoped names and file: specs stay valid. */ +function toWorkspaceYaml(args: { + allowBuilds: string[] + overrides: Record +}): string { + const { allowBuilds, overrides } = args + + const lines = ['overrides:'] + for (const [name, spec] of Object.entries(overrides)) { + lines.push(` '${name}': '${spec}'`) + } + lines.push('allowBuilds:') + for (const name of allowBuilds) { + lines.push(` ${name}: true`) + } + return `${lines.join('\n')}\n` +} + /** * Derives a package name from a `pnpm pack` tarball filename, e.g. * `payload-4.0.0.tgz` -> `payload` and `payloadcms-db-mongodb-4.0.0.tgz` -> `@payloadcms/db-mongodb`. From c04de4834e6e3de7b2fb88e93460e04d9ede02ec Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Fri, 26 Jun 2026 10:26:26 -0700 Subject: [PATCH 06/16] chore: merge pnpm v11 build approvals into create-next-app workspace yaml --- .../src/lib/configure-pnpm-builds.ts | 84 ++++++++++++++++--- 1 file changed, 74 insertions(+), 10 deletions(-) diff --git a/packages/create-payload-app/src/lib/configure-pnpm-builds.ts b/packages/create-payload-app/src/lib/configure-pnpm-builds.ts index 9ec9721a332..bf400ff6608 100644 --- a/packages/create-payload-app/src/lib/configure-pnpm-builds.ts +++ b/packages/create-payload-app/src/lib/configure-pnpm-builds.ts @@ -8,10 +8,11 @@ import type { PackageManager } from '../types.js' const ALLOWED_BUILDS = ['esbuild', 'sharp', 'unrs-resolver'] /** - * pnpm v11 fails installs that have unapproved dependency build scripts, and reads build approvals - * only from pnpm-workspace.yaml (the `pnpm` field in package.json is ignored). Ensure the project - * declares an `allowBuilds` block so the install runs the scripts Payload needs and future installs - * succeed. No-op for other package managers or when the project already manages `allowBuilds`. + * pnpm v11 fails installs that have unapproved dependency build scripts, reading approvals only from + * pnpm-workspace.yaml (the `pnpm` field in package.json is ignored). create-next-app now scaffolds a + * placeholder `allowBuilds` block (sharp/unrs-resolver left unset) plus an `ignoredBuiltDependencies` + * denylist, so we must merge rather than skip: force Payload's build scripts to `true` and remove them + * from the denylist. No-op for non-pnpm package managers. */ export async function ensurePnpmBuildApprovals(args: { packageManager: PackageManager @@ -27,12 +28,75 @@ export async function ensurePnpmBuildApprovals(args: { ? await fse.readFile(workspacePath, 'utf-8') : '' - if (existing.includes('allowBuilds:')) { - return + await fse.writeFile(workspacePath, upsertBuildApprovals(existing)) +} + +type WorkspaceBlock = { + key: null | string + lines: string[] +} + +/** + * Merges Payload's required build approvals into a pnpm-workspace.yaml string. Pure and idempotent: + * sets each required package to `true` under `allowBuilds` (adding the block if missing) and strips + * those packages from any `ignoredBuiltDependencies` denylist. Top-level keys are detected by a + * zero-indent `key:`, with their two-space children grouped beneath them. + */ +export function upsertBuildApprovals(content: string): string { + const blocks = toTopLevelBlocks(content) + + const ignored = blocks.find((block) => block.key === 'ignoredBuiltDependencies') + if (ignored) { + ignored.lines = ignored.lines.filter((line, index) => { + if (index === 0) { + return true + } + const name = /^\s*-\s*'?([^'\s]+)'?\s*$/.exec(line)?.[1] + return !(name && ALLOWED_BUILDS.includes(name)) + }) } - const block = `allowBuilds:\n${ALLOWED_BUILDS.map((name) => ` ${name}: true`).join('\n')}\n` - const content = - existing && !existing.endsWith('\n') ? `${existing}\n${block}` : `${existing}${block}` - await fse.writeFile(workspacePath, content) + const allow = blocks.find((block) => block.key === 'allowBuilds') + if (allow) { + for (const name of ALLOWED_BUILDS) { + const pattern = new RegExp(`^\\s+'?${escapeRegExp(name)}'?:`) + const index = allow.lines.findIndex((line) => pattern.test(line)) + if (index >= 0) { + allow.lines[index] = ` ${name}: true` + } else { + allow.lines.push(` ${name}: true`) + } + } + } else { + blocks.push({ + key: 'allowBuilds', + lines: ['allowBuilds:', ...ALLOWED_BUILDS.map((name) => ` ${name}: true`)], + }) + } + + const output = blocks.flatMap((block) => block.lines).join('\n') + return output.endsWith('\n') ? output : `${output}\n` +} + +function toTopLevelBlocks(content: string): WorkspaceBlock[] { + const lines = content.length ? content.replace(/\n$/, '').split('\n') : [] + const blocks: WorkspaceBlock[] = [] + + for (const line of lines) { + const key = /^[^\s#]/.test(line) ? /^([^:]+):/.exec(line)?.[1] : undefined + const current = blocks[blocks.length - 1] + if (key !== undefined) { + blocks.push({ key, lines: [line] }) + } else if (current) { + current.lines.push(line) + } else { + blocks.push({ key: null, lines: [line] }) + } + } + + return blocks +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } From 6eb4c9a465ac0f716a6b7cf0d234108dd6c44b15 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Fri, 26 Jun 2026 10:33:18 -0700 Subject: [PATCH 07/16] chore: make local-pkg template its own pnpm workspace root for v11 overrides --- .../src/build-template-with-local-pkgs.ts | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/tools/scripts/src/build-template-with-local-pkgs.ts b/tools/scripts/src/build-template-with-local-pkgs.ts index 4eda581d105..90d73b91585 100644 --- a/tools/scripts/src/build-template-with-local-pkgs.ts +++ b/tools/scripts/src/build-template-with-local-pkgs.ts @@ -77,9 +77,13 @@ async function main() { await fs.writeFile(packageJsonPath, JSON.stringify(packageJsonObj, null, 2)) - // v11 ignores package.json#pnpm, so config lives here. overrides pin sibling @payloadcms deps to - // local tarballs (else they resolve to the unpublished in-repo beta); allowBuilds keeps the - // template's build scripts running under v11's strict default. + // This file makes the template its own pnpm workspace root, shielding it from the monorepo root + // (which lists templates/* as workspace packages) without `--ignore-workspace` — which we cannot + // use, because under v11 it makes pnpm skip this file entirely (overrides and allowBuilds alike). + // overrides pin sibling @payloadcms deps to local tarballs (else they resolve to the unpublished + // in-repo beta -> ERR_PNPM_NO_MATCHING_VERSION); allowBuilds keeps the template's build scripts + // running under v11's strict default; verifyDepsBeforeRun disables the v11 re-install check that + // would otherwise fire on the later `pnpm run` commands. await fs.writeFile( path.join(templatePath, 'pnpm-workspace.yaml'), toWorkspaceYaml({ @@ -88,7 +92,7 @@ async function main() { }), ) - execSync('pnpm install --no-frozen-lockfile --ignore-workspace', execOpts) + execSync('pnpm install --no-frozen-lockfile', execOpts) await fs.writeFile( path.resolve(templatePath, '.env'), // Populate POSTGRES_URL just in case it's needed @@ -100,8 +104,8 @@ BLOB_READ_WRITE_TOKEN=vercel_blob_rw_TEST_asdf`, // Important: run generate:types and generate:importmap first if (templateName !== 'plugin') { // TODO: fix in a separate PR - these commands currently fail in the plugin template - execSync('pnpm --ignore-workspace run generate:types', execOpts) - execSync('pnpm --ignore-workspace run generate:importmap', execOpts) + execSync('pnpm run generate:types', execOpts) + execSync('pnpm run generate:importmap', execOpts) } await runBuildWithWarningsCheck({ cwd: templatePath, allowWarnings }) @@ -129,7 +133,7 @@ async function runBuildWithWarningsCheck(args: { const { allowWarnings, cwd } = args return new Promise((resolve, reject) => { - const buildProcess = spawn('pnpm', ['--ignore-workspace', 'run', 'build'], { + const buildProcess = spawn('pnpm', ['run', 'build'], { cwd, shell: true, }) @@ -167,14 +171,19 @@ async function runBuildWithWarningsCheck(args: { }) } -/** Serializes a minimal pnpm-workspace.yaml; quotes keys/values so scoped names and file: specs stay valid. */ +/** + * Serializes the template's standalone pnpm-workspace.yaml; quotes keys/values so scoped names and + * file: specs stay valid. Omitting `packages` makes the template a single-package workspace root, + * isolating it from the monorepo. `verifyDepsBeforeRun: false` stops v11 from re-installing before + * the `pnpm run` build/generate commands. + */ function toWorkspaceYaml(args: { allowBuilds: string[] overrides: Record }): string { const { allowBuilds, overrides } = args - const lines = ['overrides:'] + const lines = ['verifyDepsBeforeRun: false', 'overrides:'] for (const [name, spec] of Object.entries(overrides)) { lines.push(` '${name}': '${spec}'`) } From f20c90c83f79a61f00bbbe3f41beb83367d151ba Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Fri, 26 Jun 2026 10:41:35 -0700 Subject: [PATCH 08/16] chore: make prod-test dir its own pnpm workspace root for v11 overrides --- package.json | 6 +++--- test/pnpm-workspace.yaml | 8 +++++--- test/setupProd.ts | 12 ++++++++---- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index f6db19f8a6e..7b6539ab721 100644 --- a/package.json +++ b/package.json @@ -92,9 +92,9 @@ "lint:scss": "stylelint \"packages/ui/src/**/*.scss\" \"templates/**/*.scss\"", "obliterate-playwright-cache-macos": "rm -rf ~/Library/Caches/ms-playwright && find /System/Volumes/Data/private/var/folders -type d -name 'playwright*' -exec rm -rf {} +", "prepare": "husky && pnpm turbo build --filter @payloadcms/typescript-plugin", - "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 ..", - "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 ..", - "prepare-run-test-against-prod:ci:frozen": "rm -rf test/packed && rm -rf test/node_modules && rm -rf app && pnpm run script:pack --all --no-build --dest test/packed && pnpm runts test/setupProd.ts && cd test && pnpm i --frozen-lockfile --ignore-workspace && cd ..", + "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 ..", + "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 ..", + "prepare-run-test-against-prod:ci:frozen": "rm -rf test/packed && rm -rf test/node_modules && rm -rf app && pnpm run script:pack --all --no-build --dest test/packed && pnpm runts test/setupProd.ts && cd test && pnpm i --frozen-lockfile && cd ..", "publish-prerelease": "pnpm --filter releaser publish-prerelease", "reinstall": "pnpm clean:all && pnpm install", "release": "pnpm --filter releaser release --tag beta", diff --git a/test/pnpm-workspace.yaml b/test/pnpm-workspace.yaml index f5bc4a7ae95..47cbf00b70d 100644 --- a/test/pnpm-workspace.yaml +++ b/test/pnpm-workspace.yaml @@ -1,5 +1,7 @@ -# Used by the standalone prod-test flow (`cd test && pnpm i --ignore-workspace`), -# where pnpm treats this dir as its own root. v11 ignores `package.json#pnpm`, -# so the graphql override lives here. +# Makes test/ its own pnpm workspace root for the standalone prod-test flow (`cd test && pnpm i`), +# isolating it from the monorepo without `--ignore-workspace` (which, under v11, would make pnpm +# skip this file). setupProd.ts overwrites this with the full tarball overrides before install; +# v11 ignores `package.json#pnpm`, so the graphql override lives here. +verifyDepsBeforeRun: false overrides: graphql: '16.8.1' diff --git a/test/setupProd.ts b/test/setupProd.ts index 9ac09774c45..d328e6b2ae9 100644 --- a/test/setupProd.ts +++ b/test/setupProd.ts @@ -87,12 +87,16 @@ export function setupProd() { // write out package.json (direct deps now point at local tarballs) fs.writeFileSync(path.resolve(dirname, 'package.json'), JSON.stringify(packageJson, null, 2)) - // v11 ignores package.json#pnpm, so overrides live here. Pin @payloadcms deps to local tarballs - // (else transitive requests hit the unpublished in-repo beta). graphql mirrors the committed - // baseline; dangerouslyAllowAllBuilds keeps build scripts running under v11's strict default. + // This file makes test/ its own pnpm workspace root, isolating it from the monorepo (which lists + // `test` as a workspace package) without `--ignore-workspace` — which we cannot use, because under + // v11 it makes pnpm skip this file entirely (overrides and build approvals alike). v11 also ignores + // package.json#pnpm, so config has nowhere else to live. overrides pin @payloadcms deps to local + // tarballs (else transitive requests hit the unpublished in-repo beta); graphql mirrors the + // committed baseline; dangerouslyAllowAllBuilds keeps build scripts running under v11's strict + // default; verifyDepsBeforeRun disables the v11 re-install check on later `pnpm run` commands. const overrides = { graphql: '16.8.1', ...allDependencies } const overrideLines = Object.entries(overrides).map(([name, spec]) => ` '${name}': '${spec}'`) - const workspaceYaml = `overrides:\n${overrideLines.join('\n')}\ndangerouslyAllowAllBuilds: true\n` + const workspaceYaml = `verifyDepsBeforeRun: false\noverrides:\n${overrideLines.join('\n')}\ndangerouslyAllowAllBuilds: true\n` fs.writeFileSync(path.resolve(dirname, 'pnpm-workspace.yaml'), workspaceYaml) } From 2ffa6df50591d79f9b6dfd1f72d104d49172189d Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Mon, 29 Jun 2026 08:40:09 -0400 Subject: [PATCH 09/16] chore: allow pnpm v11 in remaining template engines fields --- templates/_template/package.json | 2 +- templates/plugin/package.json | 2 +- templates/with-cloudflare-d1/package.json | 2 +- templates/with-postgres/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/templates/_template/package.json b/templates/_template/package.json index af808a0c36a..e6a966d3720 100644 --- a/templates/_template/package.json +++ b/templates/_template/package.json @@ -49,7 +49,7 @@ }, "engines": { "node": ">=24.15.0", - "pnpm": "^9 || ^10" + "pnpm": "^9 || ^10 || ^11" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/templates/plugin/package.json b/templates/plugin/package.json index e062e66888a..5578648b9aa 100644 --- a/templates/plugin/package.json +++ b/templates/plugin/package.json @@ -84,7 +84,7 @@ }, "engines": { "node": ">=24.15.0", - "pnpm": "^9 || ^10" + "pnpm": "^9 || ^10 || ^11" }, "publishConfig": { "exports": { diff --git a/templates/with-cloudflare-d1/package.json b/templates/with-cloudflare-d1/package.json index 87811e8d651..f3c9a3fe51d 100644 --- a/templates/with-cloudflare-d1/package.json +++ b/templates/with-cloudflare-d1/package.json @@ -58,7 +58,7 @@ }, "engines": { "node": ">=24.15.0", - "pnpm": "^9 || ^10" + "pnpm": "^9 || ^10 || ^11" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/templates/with-postgres/package.json b/templates/with-postgres/package.json index 9aef8aa8d73..2364ebc5029 100644 --- a/templates/with-postgres/package.json +++ b/templates/with-postgres/package.json @@ -49,7 +49,7 @@ }, "engines": { "node": ">=24.15.0", - "pnpm": "^9 || ^10" + "pnpm": "^9 || ^10 || ^11" }, "pnpm": { "onlyBuiltDependencies": [ From 5a16ace2ec548532ffc06fc29f2595fb407cef0b Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Mon, 29 Jun 2026 09:28:49 -0400 Subject: [PATCH 10/16] ci: take first match for playwright version under pnpm v11 ls --- .github/workflows/main.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d4dfcd8d41b..042764880a7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -350,8 +350,10 @@ jobs: - name: Store Playwright's Version run: | - # Extract the version number using a more targeted regex pattern with awk - PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --depth=0 | awk '/@playwright\/test/ {print $2}') + # Extract the version number using a more targeted regex pattern with awk. + # `exit` after the first match: under pnpm v11, `pnpm ls` at the workspace root lists the + # dep across every workspace project, and a multi-line value breaks the GITHUB_ENV write. + PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --depth=0 | awk '/@playwright\/test/ {print $2; exit}') echo "Playwright's Version: $PLAYWRIGHT_VERSION" echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV @@ -478,8 +480,10 @@ jobs: - name: Store Playwright's Version run: | - # Extract the version number using a more targeted regex pattern with awk - PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --depth=0 | awk '/@playwright\/test/ {print $2}') + # Extract the version number using a more targeted regex pattern with awk. + # `exit` after the first match: under pnpm v11, `pnpm ls` at the workspace root lists the + # dep across every workspace project, and a multi-line value breaks the GITHUB_ENV write. + PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --depth=0 | awk '/@playwright\/test/ {print $2; exit}') echo "Playwright's Version: $PLAYWRIGHT_VERSION" echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV From e4a64cedabe896f367325d7063b28f9330a68acf Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Mon, 29 Jun 2026 10:40:28 -0400 Subject: [PATCH 11/16] chore: allow all builds in template local-pkg build for v11 --- .../src/build-template-with-local-pkgs.ts | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/tools/scripts/src/build-template-with-local-pkgs.ts b/tools/scripts/src/build-template-with-local-pkgs.ts index 90d73b91585..8d8088d056f 100644 --- a/tools/scripts/src/build-template-with-local-pkgs.ts +++ b/tools/scripts/src/build-template-with-local-pkgs.ts @@ -81,13 +81,13 @@ async function main() { // (which lists templates/* as workspace packages) without `--ignore-workspace` — which we cannot // use, because under v11 it makes pnpm skip this file entirely (overrides and allowBuilds alike). // overrides pin sibling @payloadcms deps to local tarballs (else they resolve to the unpublished - // in-repo beta -> ERR_PNPM_NO_MATCHING_VERSION); allowBuilds keeps the template's build scripts - // running under v11's strict default; verifyDepsBeforeRun disables the v11 re-install check that - // would otherwise fire on the later `pnpm run` commands. + // in-repo beta -> ERR_PNPM_NO_MATCHING_VERSION); dangerouslyAllowAllBuilds keeps every template's + // build scripts (sharp, esbuild, unrs-resolver, plus the plugin template's mongodb-memory-server + // and @swc/core) running under v11's strict default without enumerating each one; verifyDepsBeforeRun + // disables the v11 re-install check that would otherwise fire on the later `pnpm run` commands. await fs.writeFile( path.join(templatePath, 'pnpm-workspace.yaml'), toWorkspaceYaml({ - allowBuilds: ['esbuild', 'sharp', 'unrs-resolver'], overrides: fileSpecByPackageName, }), ) @@ -177,20 +177,13 @@ async function runBuildWithWarningsCheck(args: { * isolating it from the monorepo. `verifyDepsBeforeRun: false` stops v11 from re-installing before * the `pnpm run` build/generate commands. */ -function toWorkspaceYaml(args: { - allowBuilds: string[] - overrides: Record -}): string { - const { allowBuilds, overrides } = args +function toWorkspaceYaml(args: { overrides: Record }): string { + const { overrides } = args - const lines = ['verifyDepsBeforeRun: false', 'overrides:'] + const lines = ['verifyDepsBeforeRun: false', 'dangerouslyAllowAllBuilds: true', 'overrides:'] for (const [name, spec] of Object.entries(overrides)) { lines.push(` '${name}': '${spec}'`) } - lines.push('allowBuilds:') - for (const name of allowBuilds) { - lines.push(` ${name}: true`) - } return `${lines.join('\n')}\n` } From b13d8ca556775754c837c897a0361e817637bba1 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Mon, 29 Jun 2026 11:04:52 -0400 Subject: [PATCH 12/16] chore: enumerate template build allowances to avoid v11 onlyBuilt conflict --- .../src/build-template-with-local-pkgs.ts | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/tools/scripts/src/build-template-with-local-pkgs.ts b/tools/scripts/src/build-template-with-local-pkgs.ts index 8d8088d056f..e42e7075fac 100644 --- a/tools/scripts/src/build-template-with-local-pkgs.ts +++ b/tools/scripts/src/build-template-with-local-pkgs.ts @@ -81,13 +81,17 @@ async function main() { // (which lists templates/* as workspace packages) without `--ignore-workspace` — which we cannot // use, because under v11 it makes pnpm skip this file entirely (overrides and allowBuilds alike). // overrides pin sibling @payloadcms deps to local tarballs (else they resolve to the unpublished - // in-repo beta -> ERR_PNPM_NO_MATCHING_VERSION); dangerouslyAllowAllBuilds keeps every template's - // build scripts (sharp, esbuild, unrs-resolver, plus the plugin template's mongodb-memory-server - // and @swc/core) running under v11's strict default without enumerating each one; verifyDepsBeforeRun - // disables the v11 re-install check that would otherwise fire on the later `pnpm run` commands. + // in-repo beta -> ERR_PNPM_NO_MATCHING_VERSION); allowBuilds (a per-package map honored by both v10 + // and v11) keeps each template's build scripts running under v11's strict default. We enumerate + // rather than use dangerouslyAllowAllBuilds because the latter is mutually exclusive with the + // templates' package.json `onlyBuiltDependencies` (the with-vercel templates pin pnpm@10 via + // packageManager, which still reads that field -> ERR_PNPM_CONFIG_CONFLICT_BUILT_DEPENDENCIES). + // verifyDepsBeforeRun disables the v11 re-install check that would otherwise fire on later `pnpm run`. await fs.writeFile( path.join(templatePath, 'pnpm-workspace.yaml'), toWorkspaceYaml({ + // sharp/esbuild/unrs-resolver: all templates; mongodb-memory-server/@swc/core: plugin template. + allowBuilds: ['esbuild', 'sharp', 'unrs-resolver', 'mongodb-memory-server', '@swc/core'], overrides: fileSpecByPackageName, }), ) @@ -177,13 +181,20 @@ async function runBuildWithWarningsCheck(args: { * isolating it from the monorepo. `verifyDepsBeforeRun: false` stops v11 from re-installing before * the `pnpm run` build/generate commands. */ -function toWorkspaceYaml(args: { overrides: Record }): string { - const { overrides } = args +function toWorkspaceYaml(args: { + allowBuilds: string[] + overrides: Record +}): string { + const { allowBuilds, overrides } = args - const lines = ['verifyDepsBeforeRun: false', 'dangerouslyAllowAllBuilds: true', 'overrides:'] + const lines = ['verifyDepsBeforeRun: false', 'overrides:'] for (const [name, spec] of Object.entries(overrides)) { lines.push(` '${name}': '${spec}'`) } + lines.push('allowBuilds:') + for (const name of allowBuilds) { + lines.push(` '${name}': true`) + } return `${lines.join('\n')}\n` } From 56250a064cadb4a880bfbdfea18a4a62fb5993b7 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Mon, 29 Jun 2026 11:23:52 -0400 Subject: [PATCH 13/16] chore: tighten v11 workspace comments --- .github/workflows/main.yml | 10 ++++---- test/pnpm-workspace.yaml | 7 +++--- test/setupProd.ts | 13 +++++------ .../src/build-template-with-local-pkgs.ts | 23 ++++++++----------- 4 files changed, 22 insertions(+), 31 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 042764880a7..8a829c5f3f6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -350,9 +350,8 @@ jobs: - name: Store Playwright's Version run: | - # Extract the version number using a more targeted regex pattern with awk. - # `exit` after the first match: under pnpm v11, `pnpm ls` at the workspace root lists the - # dep across every workspace project, and a multi-line value breaks the GITHUB_ENV write. + # `exit` after the first match: v11's `pnpm ls` lists the dep across every workspace + # project, and a multi-line value would break the GITHUB_ENV write. PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --depth=0 | awk '/@playwright\/test/ {print $2; exit}') echo "Playwright's Version: $PLAYWRIGHT_VERSION" echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV @@ -480,9 +479,8 @@ jobs: - name: Store Playwright's Version run: | - # Extract the version number using a more targeted regex pattern with awk. - # `exit` after the first match: under pnpm v11, `pnpm ls` at the workspace root lists the - # dep across every workspace project, and a multi-line value breaks the GITHUB_ENV write. + # `exit` after the first match: v11's `pnpm ls` lists the dep across every workspace + # project, and a multi-line value would break the GITHUB_ENV write. PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --depth=0 | awk '/@playwright\/test/ {print $2; exit}') echo "Playwright's Version: $PLAYWRIGHT_VERSION" echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV diff --git a/test/pnpm-workspace.yaml b/test/pnpm-workspace.yaml index 47cbf00b70d..2960847cb15 100644 --- a/test/pnpm-workspace.yaml +++ b/test/pnpm-workspace.yaml @@ -1,7 +1,6 @@ -# Makes test/ its own pnpm workspace root for the standalone prod-test flow (`cd test && pnpm i`), -# isolating it from the monorepo without `--ignore-workspace` (which, under v11, would make pnpm -# skip this file). setupProd.ts overwrites this with the full tarball overrides before install; -# v11 ignores `package.json#pnpm`, so the graphql override lives here. +# Makes test/ its own workspace root for the prod-test flow (`cd test && pnpm i`), without +# `--ignore-workspace` (which v11 uses to skip this file). setupProd.ts overwrites this with the +# full tarball overrides at prod-test time; the graphql override is the committed baseline. verifyDepsBeforeRun: false overrides: graphql: '16.8.1' diff --git a/test/setupProd.ts b/test/setupProd.ts index d328e6b2ae9..c5b08c0e201 100644 --- a/test/setupProd.ts +++ b/test/setupProd.ts @@ -87,13 +87,12 @@ export function setupProd() { // write out package.json (direct deps now point at local tarballs) fs.writeFileSync(path.resolve(dirname, 'package.json'), JSON.stringify(packageJson, null, 2)) - // This file makes test/ its own pnpm workspace root, isolating it from the monorepo (which lists - // `test` as a workspace package) without `--ignore-workspace` — which we cannot use, because under - // v11 it makes pnpm skip this file entirely (overrides and build approvals alike). v11 also ignores - // package.json#pnpm, so config has nowhere else to live. overrides pin @payloadcms deps to local - // tarballs (else transitive requests hit the unpublished in-repo beta); graphql mirrors the - // committed baseline; dangerouslyAllowAllBuilds keeps build scripts running under v11's strict - // default; verifyDepsBeforeRun disables the v11 re-install check on later `pnpm run` commands. + /* + * Make test/ its own workspace root (no `--ignore-workspace`, which v11 uses to skip this file). + * Since v11 ignores package.json#pnpm, all config lives here: overrides pin @payloadcms deps to + * local tarballs (graphql mirrors the committed baseline), dangerouslyAllowAllBuilds keeps build + * scripts running, and verifyDepsBeforeRun stops re-installs on later `pnpm run` commands. + */ const overrides = { graphql: '16.8.1', ...allDependencies } const overrideLines = Object.entries(overrides).map(([name, spec]) => ` '${name}': '${spec}'`) const workspaceYaml = `verifyDepsBeforeRun: false\noverrides:\n${overrideLines.join('\n')}\ndangerouslyAllowAllBuilds: true\n` diff --git a/tools/scripts/src/build-template-with-local-pkgs.ts b/tools/scripts/src/build-template-with-local-pkgs.ts index e42e7075fac..32879118e0d 100644 --- a/tools/scripts/src/build-template-with-local-pkgs.ts +++ b/tools/scripts/src/build-template-with-local-pkgs.ts @@ -77,16 +77,12 @@ async function main() { await fs.writeFile(packageJsonPath, JSON.stringify(packageJsonObj, null, 2)) - // This file makes the template its own pnpm workspace root, shielding it from the monorepo root - // (which lists templates/* as workspace packages) without `--ignore-workspace` — which we cannot - // use, because under v11 it makes pnpm skip this file entirely (overrides and allowBuilds alike). - // overrides pin sibling @payloadcms deps to local tarballs (else they resolve to the unpublished - // in-repo beta -> ERR_PNPM_NO_MATCHING_VERSION); allowBuilds (a per-package map honored by both v10 - // and v11) keeps each template's build scripts running under v11's strict default. We enumerate - // rather than use dangerouslyAllowAllBuilds because the latter is mutually exclusive with the - // templates' package.json `onlyBuiltDependencies` (the with-vercel templates pin pnpm@10 via - // packageManager, which still reads that field -> ERR_PNPM_CONFIG_CONFLICT_BUILT_DEPENDENCIES). - // verifyDepsBeforeRun disables the v11 re-install check that would otherwise fire on later `pnpm run`. + /* + * Make the template its own workspace root (no `--ignore-workspace`, which v11 uses to skip this + * file entirely). `allowBuilds` is a per-package map honored by v10 and v11; we enumerate instead + * of `dangerouslyAllowAllBuilds` because the latter conflicts with the templates' package.json + * `onlyBuiltDependencies` (ERR_PNPM_CONFIG_CONFLICT_BUILT_DEPENDENCIES on the pnpm@10-pinned ones). + */ await fs.writeFile( path.join(templatePath, 'pnpm-workspace.yaml'), toWorkspaceYaml({ @@ -176,10 +172,9 @@ async function runBuildWithWarningsCheck(args: { } /** - * Serializes the template's standalone pnpm-workspace.yaml; quotes keys/values so scoped names and - * file: specs stay valid. Omitting `packages` makes the template a single-package workspace root, - * isolating it from the monorepo. `verifyDepsBeforeRun: false` stops v11 from re-installing before - * the `pnpm run` build/generate commands. + * Serializes the standalone pnpm-workspace.yaml. Omitting `packages` makes the template a + * single-package workspace root; `verifyDepsBeforeRun: false` stops v11 re-installing before + * the later `pnpm run` commands. Keys/values are quoted so scoped names and file: specs stay valid. */ function toWorkspaceYaml(args: { allowBuilds: string[] From 437896039378288a2e88434a3bb9133f858b2541 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Mon, 29 Jun 2026 12:22:26 -0400 Subject: [PATCH 14/16] fix: isolate non-workspace template generation without --ignore-workspace for v11 --- .../src/generate-template-variations.ts | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/tools/scripts/src/generate-template-variations.ts b/tools/scripts/src/generate-template-variations.ts index 34d33655e4e..5980bf2a657 100644 --- a/tools/scripts/src/generate-template-variations.ts +++ b/tools/scripts/src/generate-template-variations.ts @@ -306,11 +306,26 @@ async function main() { }) } + /* + * Non-workspace variants must install in isolation from the monorepo. `--ignore-workspace` + * can't do this under pnpm v11 (it reads the flag as "skip pnpm-workspace.yaml entirely", + * dropping build approvals -> ERR_PNPM_IGNORED_BUILDS). Instead drop a transient standalone + * workspace root carrying the templates' build allowances; it's removed before commit so the + * generated template ships unchanged. verifyDepsBeforeRun stops v11 re-installing before the + * later `pnpm generate:*`/`build` runs. + */ + if (!workspace) { + await fs.writeFile( + path.join(destDir, 'pnpm-workspace.yaml'), + `verifyDepsBeforeRun: false\nallowBuilds:\n 'esbuild': true\n 'sharp': true\n 'unrs-resolver': true\n`, + ) + } + // Install packages BEFORE running any commands that load the config // This ensures all imports in payload.config.ts can be resolved log('Installing dependencies...') - execSyncSafe(`pnpm install ${workspace ? '' : '--ignore-workspace'} --no-frozen-lockfile`, { + execSyncSafe(`pnpm install --no-frozen-lockfile`, { cwd: destDir, }) @@ -356,19 +371,24 @@ async function main() { // Generate importmap log('Generating import map') - execSyncSafe(`pnpm ${workspace ? '' : '--ignore-workspace '}generate:importmap`, { + execSyncSafe(`pnpm generate:importmap`, { cwd: destDir, }) // Generate types log('Generating types') - execSyncSafe(`pnpm ${workspace ? '' : '--ignore-workspace '}generate:types`, { + execSyncSafe(`pnpm generate:types`, { cwd: destDir, }) if (shouldBuild) { log('Building...') - execSyncSafe(`pnpm ${workspace ? '' : '--ignore-workspace '}build`, { cwd: destDir }) + execSyncSafe(`pnpm build`, { cwd: destDir }) + } + + // Remove the transient workspace root so it isn't committed into the generated template. + if (!workspace) { + await fs.rm(path.join(destDir, 'pnpm-workspace.yaml'), { force: true }) } // TODO: Email? From 2556c8b8e474fcb8e3343d291083f60952b04d52 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Mon, 29 Jun 2026 14:46:03 -0400 Subject: [PATCH 15/16] chore: run all builds in template generation to avoid allowlist drift --- tools/scripts/src/generate-template-variations.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tools/scripts/src/generate-template-variations.ts b/tools/scripts/src/generate-template-variations.ts index 5980bf2a657..b369a417bf5 100644 --- a/tools/scripts/src/generate-template-variations.ts +++ b/tools/scripts/src/generate-template-variations.ts @@ -310,14 +310,18 @@ async function main() { * Non-workspace variants must install in isolation from the monorepo. `--ignore-workspace` * can't do this under pnpm v11 (it reads the flag as "skip pnpm-workspace.yaml entirely", * dropping build approvals -> ERR_PNPM_IGNORED_BUILDS). Instead drop a transient standalone - * workspace root carrying the templates' build allowances; it's removed before commit so the - * generated template ships unchanged. verifyDepsBeforeRun stops v11 re-installing before the - * later `pnpm generate:*`/`build` runs. + * workspace root; it's removed before commit so the generated template ships unchanged. + * dangerouslyAllowAllBuilds runs every dependency's build script: this is throwaway generation + * tooling, not a shipped config, so there's no allowlist to drift out of sync with the templates' + * deps (e.g. the d1 variant's `workerd`). The install always runs under v11 here — the vercel + * variants' pnpm@10 packageManager pin is added after install — so package.json#pnpm is ignored + * and dangerouslyAllowAllBuilds can't conflict with its onlyBuiltDependencies. verifyDepsBeforeRun + * stops v11 re-installing before the later `pnpm generate:*`/`build` runs. */ if (!workspace) { await fs.writeFile( path.join(destDir, 'pnpm-workspace.yaml'), - `verifyDepsBeforeRun: false\nallowBuilds:\n 'esbuild': true\n 'sharp': true\n 'unrs-resolver': true\n`, + `verifyDepsBeforeRun: false\ndangerouslyAllowAllBuilds: true\n`, ) } From 43babc68d02082abc3b8c2ccc95843f44d6cd2a1 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Mon, 29 Jun 2026 15:02:13 -0400 Subject: [PATCH 16/16] chore: add CI guard against create-payload-app build-approval drift --- .github/workflows/main.yml | 23 +++ .../src/lib/configure-pnpm-builds.ts | 11 +- tools/scripts/package.json | 1 + .../src/build-template-with-local-pkgs.ts | 27 +-- .../src/check-template-build-approvals.ts | 174 ++++++++++++++++++ tools/scripts/src/local-package-tarballs.ts | 35 ++++ 6 files changed, 244 insertions(+), 27 deletions(-) create mode 100644 tools/scripts/src/check-template-build-approvals.ts create mode 100644 tools/scripts/src/local-package-tarballs.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8a829c5f3f6..b1085811fe2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -25,6 +25,7 @@ jobs: needs_build: ${{ steps.filter.outputs.needs_build }} needs_tests: ${{ steps.filter.outputs.needs_tests }} templates: ${{ steps.filter.outputs.templates }} + build_approvals: ${{ steps.filter.outputs.build_approvals }} steps: # https://github.com/actions/virtual-environments/issues/1187 - name: tune linux network @@ -53,11 +54,18 @@ jobs: - 'package.json' templates: - 'templates/**' + build_approvals: + - '.github/workflows/main.yml' + # Any dependency-graph change (packages/* or the blank workspace member) lands in + # the root lockfile; that, plus the allowlist itself, is what can introduce drift. + - 'pnpm-lock.yaml' + - 'packages/create-payload-app/src/lib/configure-pnpm-builds.ts' - name: Log filter results run: | echo "needs_build: ${{ steps.filter.outputs.needs_build }}" echo "needs_tests: ${{ steps.filter.outputs.needs_tests }}" echo "templates: ${{ steps.filter.outputs.templates }}" + echo "build_approvals: ${{ steps.filter.outputs.build_approvals }}" # Detects upstream Next.js canary publishes that are missing `@next/env` or # `@next/eslint-plugin-next` for the same version (would break @@ -517,6 +525,21 @@ jobs: MONGODB_URL: ${{ steps.db.outputs.MONGODB_URL }} NEXT_TELEMETRY_DISABLED: 1 + # Fails if create-payload-app's build-script allowlist drifts from the blank template's deps: + # scaffolds blank, installs it standalone under pnpm v11, and asserts no ERR_PNPM_IGNORED_BUILDS. + check-template-build-approvals: + runs-on: ubuntu-24.04 + needs: [changes, build] + if: needs.changes.outputs.build_approvals == 'true' + steps: + - uses: actions/checkout@v5 + + - name: Node setup + uses: ./.github/actions/setup + + - name: Check create-payload-app build approvals + run: pnpm --filter scripts check-template-build-approvals + tests-type-generation: runs-on: ubuntu-24.04 needs: [changes, build] diff --git a/packages/create-payload-app/src/lib/configure-pnpm-builds.ts b/packages/create-payload-app/src/lib/configure-pnpm-builds.ts index bf400ff6608..a4c7f917969 100644 --- a/packages/create-payload-app/src/lib/configure-pnpm-builds.ts +++ b/packages/create-payload-app/src/lib/configure-pnpm-builds.ts @@ -3,9 +3,14 @@ import path from 'path' import type { PackageManager } from '../types.js' -// Build scripts pnpm v11 must be allowed to run for a Payload project; otherwise the install -// fails with ERR_PNPM_IGNORED_BUILDS. -const ALLOWED_BUILDS = ['esbuild', 'sharp', 'unrs-resolver'] +/* + * Build scripts pnpm v11 must be allowed to run for the projects create-payload-app scaffolds; + * otherwise the install fails with ERR_PNPM_IGNORED_BUILDS. esbuild/sharp/unrs-resolver cover the + * default templates; workerd is required by with-cloudflare-d1. + * TODO(follow-up): the CI drift guard only verifies the blank template — extend it across every + * getValidTemplates() entry so this single list can't drift per-template. + */ +const ALLOWED_BUILDS = ['esbuild', 'sharp', 'unrs-resolver', 'workerd'] /** * pnpm v11 fails installs that have unapproved dependency build scripts, reading approvals only from diff --git a/tools/scripts/package.json b/tools/scripts/package.json index d9ee68ceae2..c17685f755e 100644 --- a/tools/scripts/package.json +++ b/tools/scripts/package.json @@ -15,6 +15,7 @@ "scripts": { "build": "tsc --project tsconfig.build.json", "build-template-with-local-pkgs": "pnpm runts src/build-template-with-local-pkgs.ts", + "check-template-build-approvals": "pnpm runts src/check-template-build-approvals.ts", "gen-templates": "pnpm runts src/generate-template-variations.ts", "generateTranslations:core": "node --no-deprecation --import @swc-node/register/esm-register src/generateTranslations/core.ts", "generateTranslations:plugin-ecommerce": "node --no-deprecation --import @swc-node/register/esm-register src/generateTranslations/plugin-ecommerce.ts", diff --git a/tools/scripts/src/build-template-with-local-pkgs.ts b/tools/scripts/src/build-template-with-local-pkgs.ts index 32879118e0d..322ce6334b9 100644 --- a/tools/scripts/src/build-template-with-local-pkgs.ts +++ b/tools/scripts/src/build-template-with-local-pkgs.ts @@ -4,6 +4,8 @@ import { execSync, spawn } from 'child_process' import fs from 'fs/promises' import path from 'path' +import { mapTarballsToFileSpecs } from './local-package-tarballs.js' + main().catch((error) => { console.error(error) process.exit(1) @@ -33,17 +35,7 @@ async function main() { // Map every packed .tgz (produced by `script:pack --all`) to its package name and a local // `file:` spec, e.g. `@payloadcms/translations` -> `file:./payloadcms-translations-4.0.0.tgz`. - const allFiles = await fs.readdir(templatePath, { withFileTypes: true }) - const fileSpecByPackageName = Object.fromEntries( - allFiles - .filter( - (file) => - file.isFile() && - file.name.endsWith('.tgz') && - (file.name.startsWith('payload-') || file.name.startsWith('payloadcms-')), - ) - .map((file) => [tgzToPackageName(file.name), `file:./${file.name}`]), - ) + const fileSpecByPackageName = await mapTarballsToFileSpecs(templatePath) if (Object.keys(fileSpecByPackageName).length === 0) { throw new Error( @@ -192,16 +184,3 @@ function toWorkspaceYaml(args: { } return `${lines.join('\n')}\n` } - -/** - * Derives a package name from a `pnpm pack` tarball filename, e.g. - * `payload-4.0.0.tgz` -> `payload` and `payloadcms-db-mongodb-4.0.0.tgz` -> `@payloadcms/db-mongodb`. - * pnpm names scoped tarballs `--.tgz`, so the version is the first - * hyphen-delimited segment that starts with a digit. - */ -export function tgzToPackageName(tgzFileName: string): string { - const nameWithoutVersion = tgzFileName.replace(/-\d.*\.tgz$/, '') - return nameWithoutVersion === 'payload' - ? 'payload' - : `@payloadcms/${nameWithoutVersion.replace(/^payloadcms-/, '')}` -} diff --git a/tools/scripts/src/check-template-build-approvals.ts b/tools/scripts/src/check-template-build-approvals.ts new file mode 100644 index 00000000000..417acedf204 --- /dev/null +++ b/tools/scripts/src/check-template-build-approvals.ts @@ -0,0 +1,174 @@ +/** + * CI drift guard for create-payload-app's build-script allowlist (ALLOWED_BUILDS in + * packages/create-payload-app/src/lib/configure-pnpm-builds.ts). + * + * Replicates a create-payload-app scaffold of the blank template against the CURRENT source: + * pack the local Payload packages, repoint the template's workspace:* deps at those tarballs, + * write the scaffold's pnpm-workspace.yaml via the real ensurePnpmBuildApprovals, then install + * it standalone under pnpm v11. Packing source (rather than installing the published version) + * is what makes this catch drift early — a dependency added in this commit that ships a build + * script the allowlist doesn't approve makes v11 fail the install with ERR_PNPM_IGNORED_BUILDS + * (the exact error a user would hit), and this script surfaces the offending packages. + */ +import { PROJECT_ROOT, TEMPLATES_DIR } from '@tools/constants' +import { ensurePnpmBuildApprovals } from 'create-payload-app/lib/configure-pnpm-builds.js' +import { copyRecursiveSync } from 'create-payload-app/utils/copy-recursive-sync.js' +import { spawnSync } from 'node:child_process' +import * as fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { mapTarballsToFileSpecs } from './local-package-tarballs.js' + +main().catch((error) => { + console.error(error) + process.exit(1) +}) + +async function main() { + assertPnpmV11() + + const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cpa-build-approvals-')) + try { + copyRecursiveSync(path.join(TEMPLATES_DIR, 'blank'), projectDir, [ + 'node_modules', + '\\*\\.tgz', + '.next', + '.env$', + 'pnpm-lock.yaml', + ]) + + packLocalPackages(projectDir) + const fileSpecByPackageName = await mapTarballsToFileSpecs(projectDir) + await repointWorkspaceDepsAtTarballs(projectDir, fileSpecByPackageName) + + // Override every Payload package name to its tarball so the packed tarballs' transitive + // @payloadcms/* deps resolve to local source too, not the registry. ensurePnpmBuildApprovals + // then merges the scaffold's allowBuilds into this same file, leaving the install otherwise + // identical to what a real `create-payload-app` produces. + await writeOverrides(projectDir, fileSpecByPackageName) + await ensurePnpmBuildApprovals({ packageManager: 'pnpm', projectDir }) + + const ignored = installAndCollectIgnoredBuilds(projectDir) + if (ignored.length > 0) { + console.error( + `\n❌ Scaffolded blank project has unapproved build scripts: ${ignored.join(', ')}.\n` + + ` Add them to ALLOWED_BUILDS in packages/create-payload-app/src/lib/configure-pnpm-builds.ts,\n` + + ` otherwise users hit ERR_PNPM_IGNORED_BUILDS when scaffolding under pnpm v11.\n`, + ) + process.exit(1) + } + + console.log('\n✅ All scaffold build scripts are approved — ALLOWED_BUILDS is in sync.') + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } +} + +/** + * Packs the current source of every Payload package into `dest` as tarballs. `--no-build` skips + * the upfront build:all (this check only reads dependency manifests, not compiled output), so it + * relies on the `build` job's restored artifacts in CI. + */ +function packLocalPackages(dest: string): void { + const result = spawnSync( + 'pnpm', + ['--filter', 'scripts', 'pack-all-to-dest', '--all', '--no-build', '--dest', dest], + { cwd: PROJECT_ROOT, encoding: 'utf8', stdio: 'inherit' }, + ) + if (result.status !== 0) { + throw new Error('Failed to pack local packages.') + } +} + +/** + * Replaces the template's workspace:* Payload specs with `file:` specs pointing at the packed + * tarballs, so the standalone install resolves the current source. Throws if any workspace: spec + * is left unmatched, since those cannot install outside the monorepo. + */ +async function repointWorkspaceDepsAtTarballs( + projectDir: string, + fileSpecByPackageName: Record, +): Promise { + const packageJsonPath = path.join(projectDir, 'package.json') + const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')) as { + dependencies?: Record + devDependencies?: Record + } + + for (const depKey of ['dependencies', 'devDependencies'] as const) { + const deps = packageJson[depKey] + if (!deps) { + continue + } + for (const [name, spec] of Object.entries(deps)) { + if (fileSpecByPackageName[name]) { + deps[name] = fileSpecByPackageName[name] + } else if (spec.startsWith('workspace:')) { + throw new Error(`No packed tarball for workspace dependency "${name}".`) + } + } + } + + await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2)) +} + +/** Writes a pnpm-workspace.yaml with an `overrides` block forcing each name to its tarball spec. */ +async function writeOverrides( + projectDir: string, + fileSpecByPackageName: Record, +): Promise { + const lines = ['overrides:'] + for (const [name, spec] of Object.entries(fileSpecByPackageName)) { + lines.push(` '${name}': '${spec}'`) + } + await fs.writeFile(path.join(projectDir, 'pnpm-workspace.yaml'), `${lines.join('\n')}\n`) +} + +/** + * Installs the scaffolded project under pnpm v11 and returns the packages whose build scripts + * were blocked. Resolving from PROJECT_ROOT keeps the repo's pinned pnpm v11 in effect while + * `--dir` targets the throwaway project. v11's default strictDepBuilds aborts the install when + * a build script is unapproved, so an empty list means every required build is approved. + */ +function installAndCollectIgnoredBuilds(projectDir: string): string[] { + const result = spawnSync('pnpm', ['install', '--no-frozen-lockfile', '--dir', projectDir], { + cwd: PROJECT_ROOT, + encoding: 'utf8', + }) + + if (result.status === 0) { + return [] + } + + const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}` + const ignored = parseIgnoredBuilds(output) + if (ignored.length === 0) { + console.error(output) + throw new Error('pnpm install failed for a reason other than ignored build scripts.') + } + return ignored +} + +function parseIgnoredBuilds(output: string): string[] { + const captured = /Ignored build scripts:\s*(.+)/i.exec(output)?.[1] + if (!captured) { + return [] + } + const names = captured + .split(',') + .map((entry) => entry.trim().replace(/@[^@]+$/, '')) + .filter(Boolean) + return [...new Set(names)] +} + +function assertPnpmV11(): void { + const result = spawnSync('pnpm', ['--version'], { cwd: PROJECT_ROOT, encoding: 'utf8' }) + const version = (result.stdout ?? '').trim() + const major = Number(version.split('.')[0]) + if (!Number.isFinite(major) || major < 11) { + throw new Error( + `This check guards pnpm v11's strict build approvals and must run under pnpm v11+. Found "${version}".`, + ) + } +} diff --git a/tools/scripts/src/local-package-tarballs.ts b/tools/scripts/src/local-package-tarballs.ts new file mode 100644 index 00000000000..8cbe71fa157 --- /dev/null +++ b/tools/scripts/src/local-package-tarballs.ts @@ -0,0 +1,35 @@ +import * as fs from 'node:fs/promises' + +/** + * Derives a package name from a `pnpm pack` tarball filename, e.g. + * `payload-4.0.0.tgz` -> `payload` and `payloadcms-db-mongodb-4.0.0.tgz` -> `@payloadcms/db-mongodb`. + * pnpm names scoped tarballs `--.tgz`, so the version is the first + * hyphen-delimited segment that starts with a digit. + */ +export function tgzToPackageName(tgzFileName: string): string { + const nameWithoutVersion = tgzFileName.replace(/-\d.*\.tgz$/, '') + return nameWithoutVersion === 'payload' + ? 'payload' + : `@payloadcms/${nameWithoutVersion.replace(/^payloadcms-/, '')}` +} + +/** + * Maps every packed Payload `.tgz` in `dir` to its package name and a local `file:` spec, + * e.g. `@payloadcms/ui` -> `file:./payloadcms-ui-4.0.0.tgz`. Used to repoint a template's + * workspace dependencies at locally packed tarballs (the current source) instead of the + * published versions. The `file:` specs are relative, so `dir` must be the directory that + * holds both the tarballs and the consuming package.json. + */ +export async function mapTarballsToFileSpecs(dir: string): Promise> { + const entries = await fs.readdir(dir, { withFileTypes: true }) + return Object.fromEntries( + entries + .filter( + (file) => + file.isFile() && + file.name.endsWith('.tgz') && + (file.name.startsWith('payload-') || file.name.startsWith('payloadcms-')), + ) + .map((file) => [tgzToPackageName(file.name), `file:./${file.name}`]), + ) +}