From 34059d1c3c33090882e699e609f2e4265999e081 Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:37:24 -0700 Subject: [PATCH] [rush] Fix "rush install" with pnpm 12 and injected workspace dependencies With pnpm 12 or newer, "rush install" for a subspace that has injected workspace dependencies now passes "--no-frozen-lockfile --prefer-frozen-lockfile" instead of "--frozen-lockfile". Native pnpm's frozen-lockfile freshness check reads the injected workspace manifests without applying pnpmfile hooks, and it rejects "workspace:" targets that live outside the pnpm workspace root. Every Rush project is outside common/temp/, so that check always fails with ERR_PNPM_OUTDATED_LOCKFILE. Rush has already verified that the committed shrinkwrap satisfies every package.json, so pnpm can safely reuse the lockfile as-is. Subspaces without injected dependencies, and pnpm versions below 12, keep using "--frozen-lockfile". Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...cted-frozen-lockfile_2026-10-14-18-22.json | 11 ++++ .../src/logic/base/BaseInstallManager.ts | 17 ++++- .../src/logic/test/BaseInstallManager.test.ts | 64 +++++++++++++++++-- 3 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 common/changes/@microsoft/rush/pnpm12-injected-frozen-lockfile_2026-10-14-18-22.json diff --git a/common/changes/@microsoft/rush/pnpm12-injected-frozen-lockfile_2026-10-14-18-22.json b/common/changes/@microsoft/rush/pnpm12-injected-frozen-lockfile_2026-10-14-18-22.json new file mode 100644 index 0000000000..19aa02ea93 --- /dev/null +++ b/common/changes/@microsoft/rush/pnpm12-injected-frozen-lockfile_2026-10-14-18-22.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Use \"--prefer-frozen-lockfile\" instead of \"--frozen-lockfile\" for \"rush install\" with PNPM 12 or newer when a subspace has injected workspace dependencies, because native PNPM's frozen lockfile check rejects injected workspace dependencies outside the PNPM workspace root.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "223556219+Copilot@users.noreply.github.com" +} diff --git a/libraries/rush-lib/src/logic/base/BaseInstallManager.ts b/libraries/rush-lib/src/logic/base/BaseInstallManager.ts index 9ff25e2aa6..74ce886554 100644 --- a/libraries/rush-lib/src/logic/base/BaseInstallManager.ts +++ b/libraries/rush-lib/src/logic/base/BaseInstallManager.ts @@ -924,7 +924,22 @@ ${gitLfsHookHandling} const { configuration: experiments } = this.rushConfiguration.experimentsConfiguration; if (experiments.usePnpmFrozenLockfileForRushInstall && !allowShrinkwrapUpdates) { - args.push('--frozen-lockfile'); + if ( + semver.gte(this.rushConfiguration.packageManagerToolVersion, '12.0.0') && + subspace.getPackageJsonInjectedDependenciesHash(options.variant) !== undefined + ) { + // Native PNPM's frozen-lockfile check reads injected workspace manifests without applying + // pnpmfile hooks, and rejects "workspace:" targets outside the PNPM workspace root. Every Rush + // project is outside common/temp, so that check always fails. Rush has already verified that + // the committed shrinkwrap satisfies all package.json files, so prefer the lockfile instead. + this._terminal.writeLine( + `PNPM ${this.rushConfiguration.packageManagerToolVersion} cannot verify injected workspace ` + + 'dependencies with "--frozen-lockfile"; using "--prefer-frozen-lockfile" instead.' + ); + args.push('--no-frozen-lockfile', '--prefer-frozen-lockfile'); + } else { + args.push('--frozen-lockfile'); + } if ( pnpmFilterArgumentValues.length > 0 && diff --git a/libraries/rush-lib/src/logic/test/BaseInstallManager.test.ts b/libraries/rush-lib/src/logic/test/BaseInstallManager.test.ts index a42f25a86a..53a369988e 100644 --- a/libraries/rush-lib/src/logic/test/BaseInstallManager.test.ts +++ b/libraries/rush-lib/src/logic/test/BaseInstallManager.test.ts @@ -148,11 +148,17 @@ describe('PNPM configuration arguments', () => { jest.restoreAllMocks(); }); + interface IConfigurationArgsScenario { + frozenLockfileForInstall?: boolean; + hasInjectedDependencies?: boolean; + } + function getConfigurationArgs( pnpmVersion: string, npmrcSettings: boolean, - pnpmOptions: Partial> = {} - ): { args: string[]; warnings: string } { + pnpmOptions: Partial> = {}, + { frozenLockfileForInstall = false, hasInjectedDependencies = false }: IConfigurationArgsScenario = {} + ): { args: string[]; output: string; warnings: string } { jest.spyOn(npmrcUtilities, 'isVariableSetInNpmrcFile').mockReturnValue(npmrcSettings); const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(); const manager: FakeBaseInstallManager = Object.create(FakeBaseInstallManager.prototype); @@ -162,19 +168,65 @@ describe('PNPM configuration arguments', () => { isPnpm: true, packageManagerToolVersion: pnpmVersion, pnpmOptions: { pnpmStore: 'global', pnpmStorePath: '/repo/common/temp/pnpm-store', ...pnpmOptions }, - experimentsConfiguration: { configuration: {} } + experimentsConfiguration: { + configuration: { usePnpmFrozenLockfileForRushInstall: frozenLockfileForInstall } + } }, _terminal: new Terminal(terminalProvider) }); const args: string[] = []; manager.pushConfigurationArgs( args, - { pnpmFilterArgumentValues: [] } as unknown as IInstallManagerOptions, - { getSubspaceConfigFolderPath: () => '/repo/common/config/rush' } as Subspace + { pnpmFilterArgumentValues: [], allowShrinkwrapUpdates: false } as unknown as IInstallManagerOptions, + { + getSubspaceConfigFolderPath: () => '/repo/common/config/rush', + getPackageJsonInjectedDependenciesHash: () => (hasInjectedDependencies ? 'hash' : undefined) + } as unknown as Subspace ); - return { args, warnings: terminalProvider.getWarningOutput() }; + return { args, output: terminalProvider.getOutput(), warnings: terminalProvider.getWarningOutput() }; } + it.each([ + ['10.27.0', true], + ['12.10.1', false] + ])( + 'uses a frozen lockfile for install with PNPM %s when injected dependencies=%s', + (pnpmVersion: string, hasInjectedDependencies: boolean) => { + const { args, output } = getConfigurationArgs( + pnpmVersion, + false, + {}, + { + frozenLockfileForInstall: true, + hasInjectedDependencies + } + ); + + expect(args).toContain('--frozen-lockfile'); + expect(args).not.toContain('--no-frozen-lockfile'); + expect(args).not.toContain('--prefer-frozen-lockfile'); + expect(output).not.toContain('injected workspace dependencies'); + } + ); + + it('prefers the lockfile for install with native PNPM and injected dependencies', () => { + const { args, output, warnings } = getConfigurationArgs( + '12.10.1', + false, + {}, + { + frozenLockfileForInstall: true, + hasInjectedDependencies: true + } + ); + + expect(args).toContain('--no-frozen-lockfile'); + expect(args).toContain('--prefer-frozen-lockfile'); + expect(args).not.toContain('--frozen-lockfile'); + expect(output).toContain('cannot verify injected workspace dependencies with "--frozen-lockfile"'); + expect(warnings).toBe(''); + }); + it.each(['8.14.0', '12.11.2'])( 'uses Rush defaults when settings are absent with PNPM %s', (pnpmVersion: string) => {