fix: prevent frozen-lockfile error when approving builds in global install (#11294)

In --global mode, globalAdd passes workspaceDir to approve-builds so it can
update the global pnpm-workspace.yaml. approve-builds then forwarded that
workspaceDir into install.handler, which (with workspacePackagePatterns
undefined) recursively discovered sibling install dirs as workspace projects
and failed the frozen-lockfile check on stale @pnpm/exe install dirs.
This commit is contained in:
Zoltan Kochan
2026-04-19 01:39:24 +02:00
committed by GitHub
parent 9e0833c3cc
commit 7d9aae9662
6 changed files with 134 additions and 34 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/building.commands": patch
"pnpm": patch
---
Fix `ERR_PNPM_OUTDATED_LOCKFILE` when approving builds during a global install. The `approve-builds` flow called by `pnpm add -g` passed the global packages directory to the subsequent install as `workspaceDir`, which caused sibling install directories (such as those left behind by `pnpm self-update`) to be picked up as workspace projects and fail the frozen-lockfile check.

View File

@@ -14,7 +14,17 @@ import { renderHelp } from 'render-help'
import { rebuild, type RebuildCommandOpts } from '../build/index.js'
import { getAutomaticallyIgnoredBuilds } from './getAutomaticallyIgnoredBuilds.js'
export type ApproveBuildsCommandOpts = Pick<Config, 'modulesDir' | 'dir' | 'allowBuilds' | 'enableGlobalVirtualStore'> & Pick<ConfigContext, 'rootProjectManifest' | 'rootProjectManifestDir'> & { all?: boolean, global?: boolean }
export type ApproveBuildsCommandOpts = Pick<Config, 'modulesDir' | 'dir' | 'allowBuilds' | 'enableGlobalVirtualStore'> & Pick<ConfigContext, 'rootProjectManifest' | 'rootProjectManifestDir'> & {
all?: boolean
global?: boolean
/**
* When set, overrides the target directory for writeSettings.
* Used by the global-install flow to point allowBuilds updates at the
* global pnpm-workspace.yaml while keeping workspaceDir unset so the
* install itself targets only the single install directory.
*/
settingsDir?: string
}
export const commandNames = ['approve-builds']
@@ -184,7 +194,7 @@ Do you approve?`,
}
await writeSettings({
...opts,
workspaceDir: opts.workspaceDir ?? opts.rootProjectManifestDir,
workspaceDir: opts.settingsDir ?? opts.workspaceDir ?? opts.rootProjectManifestDir,
updatedSettings: { allowBuilds },
})
if (modulesManifest?.ignoredBuilds) {

View File

@@ -462,3 +462,54 @@ test('should retain existing allowBuilds entries when approving builds', async (
},
})
})
// Regression test for the global-install path: globalAdd invokes
// approve-builds with globalPkgDir set (so writeSettings updates the global
// pnpm-workspace.yaml) but without workspaceDir. If approve-builds were to
// treat globalPkgDir as a workspace, install.handler would recursively
// discover sibling install dirs as workspace projects and fail the
// frozen-lockfile check on those that don't have a matching pnpm-lock.yaml.
test('GVS approve-builds writes settings to globalPkgDir without scanning siblings', async () => {
const temp = tempDir()
prepare({
dependencies: {
'@pnpm.e2e/pre-and-postinstall-scripts-example': '1.0.0',
},
}, {
tempDir: path.join(temp, 'project'),
})
// Sibling install dir with a package.json that has no matching
// pnpm-lock.yaml — mimics a stale `@pnpm/exe` install dir left behind in
// the global packages directory.
fs.mkdirSync(path.join(temp, 'stale-install'))
fs.writeFileSync(
path.join(temp, 'stale-install/package.json'),
JSON.stringify({ dependencies: { '@pnpm/exe': '11.0.0-rc.2' } })
)
await execPnpmInstall({ enableGlobalVirtualStore: true })
const config = await getApproveBuildsConfig()
prompt.mockResolvedValueOnce({
result: [{ value: '@pnpm.e2e/pre-and-postinstall-scripts-example' }],
})
prompt.mockResolvedValueOnce({ build: true })
// Match the global-install call site: settingsDir points at the global
// packages dir (for writeSettings) but workspaceDir is not set, so install
// doesn't scan globalPkgDir as a workspace.
await approveBuilds.handler({
...omit(['workspaceDir', 'workspacePackagePatterns'], config),
enableGlobalVirtualStore: true,
settingsDir: temp,
rootProjectManifestDir: process.cwd(),
} as ApproveBuildsCommandOpts & RebuildCommandOpts, [], {})
// writeSettings should have written allowBuilds to globalPkgDir's
// pnpm-workspace.yaml, not to the project dir.
const globalManifest = readYamlFileSync<any>(path.join(temp, 'pnpm-workspace.yaml')) // eslint-disable-line
expect(globalManifest.allowBuilds?.['@pnpm.e2e/pre-and-postinstall-scripts-example']).toBe(true)
expect(fs.existsSync('node_modules/@pnpm.e2e/pre-and-postinstall-scripts-example/generated-by-postinstall.js')).toBeTruthy()
})

View File

@@ -19,6 +19,7 @@ import { symlinkDir } from 'symlink-dir'
import { checkGlobalBinConflicts } from './checkGlobalBinConflicts.js'
import { installGlobalPackages } from './installGlobalPackages.js'
import { promptApproveGlobalBuilds } from './promptApproveGlobalBuilds.js'
import { readInstalledPackages } from './readInstalledPackages.js'
export type GlobalAddOptions = CreateStoreControllerOptions & {
@@ -90,22 +91,13 @@ export async function handleGlobalAdd (
const ignoredBuilds = await installGlobalPackages(makeInstallOpts(installDir, allowBuilds), params)
// If any packages had their builds skipped, prompt the user to approve them
// (reuses the same interactive flow as `pnpm approve-builds`)
if (ignoredBuilds?.size && process.stdin.isTTY) {
await commands['approve-builds']({
...opts,
modulesDir: path.join(installDir, 'node_modules'),
dir: installDir,
lockfileDir: installDir,
rootProjectManifest: undefined,
rootProjectManifestDir: installDir,
workspaceDir: opts.globalPkgDir!,
global: false,
pending: false,
allowBuilds,
}, [], commands)
}
await promptApproveGlobalBuilds({
globalPkgDir: globalDir,
installDir,
ignoredBuilds,
allowBuilds,
inheritedOpts: opts,
}, commands)
// Read resolved aliases from the installed package.json
const pkgJson = readPackageJsonFromDirRawSync(installDir)

View File

@@ -18,6 +18,7 @@ import { symlinkDir } from 'symlink-dir'
import { checkGlobalBinConflicts } from './checkGlobalBinConflicts.js'
import { installGlobalPackages } from './installGlobalPackages.js'
import { promptApproveGlobalBuilds } from './promptApproveGlobalBuilds.js'
import { readInstalledPackages } from './readInstalledPackages.js'
export type GlobalUpdateOptions = CreateStoreControllerOptions & {
@@ -110,22 +111,13 @@ async function updateGlobalPackageGroup (
allowBuilds,
}, depSpecs)
// If any packages had their builds skipped, prompt the user to approve them
// (reuses the same interactive flow as `pnpm approve-builds`)
if (ignoredBuilds?.size && process.stdin.isTTY) {
await commands['approve-builds']({
...opts,
modulesDir: path.join(installDir, 'node_modules'),
dir: installDir,
lockfileDir: installDir,
rootProjectManifest: undefined,
rootProjectManifestDir: installDir,
workspaceDir: opts.globalPkgDir!,
global: false,
pending: false,
allowBuilds,
}, [], commands)
}
await promptApproveGlobalBuilds({
globalPkgDir: globalDir,
installDir,
ignoredBuilds,
allowBuilds,
inheritedOpts: opts,
}, commands)
// Check for bin name conflicts with other global packages
const pkgs = await readInstalledPackages(installDir)

View File

@@ -0,0 +1,49 @@
import path from 'node:path'
import type { CommandHandlerMap } from '@pnpm/cli.command'
import type { IgnoredBuilds } from '@pnpm/types'
export interface PromptApproveGlobalBuildsOptions {
globalPkgDir: string
installDir: string
ignoredBuilds: IgnoredBuilds | undefined
allowBuilds: Record<string, string | boolean>
/** Inherited config opts from the global add/update handler. */
inheritedOpts: object
}
/**
* If the previous global install left builds awaiting approval, run the
* interactive `approve-builds` flow against the install directory.
*
* `settingsDir` points at the global packages directory so the resulting
* allowBuilds update lands in its pnpm-workspace.yaml. The
* workspace-context fields (`workspaceDir`, `allProjects`,
* `selectedProjectsGraph`, `workspacePackagePatterns`) are explicitly
* cleared so that the install run by approve-builds in GVS mode operates
* only on the install directory — otherwise it would treat the global
* packages dir as a workspace and discover sibling install directories as
* workspace projects.
*/
export async function promptApproveGlobalBuilds (
opts: PromptApproveGlobalBuildsOptions,
commands: CommandHandlerMap
): Promise<void> {
if (!opts.ignoredBuilds?.size || !process.stdin.isTTY) return
await commands['approve-builds']({
...opts.inheritedOpts,
workspaceDir: undefined,
allProjects: undefined,
selectedProjectsGraph: undefined,
workspacePackagePatterns: undefined,
modulesDir: path.join(opts.installDir, 'node_modules'),
dir: opts.installDir,
lockfileDir: opts.installDir,
rootProjectManifest: undefined,
rootProjectManifestDir: opts.installDir,
settingsDir: opts.globalPkgDir,
global: false,
pending: false,
allowBuilds: opts.allowBuilds,
}, [], commands)
}