Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"comment": "Fix pnpm 11 silently ignoring the `globalOverrides`, `globalPackageExtensions`, `globalPeerDependencyRules`, `globalAllowedDeprecatedVersions`, and `globalPatchedDependencies` settings from pnpm-config.json. Because pnpm 11 no longer reads the `pnpm` field of package.json, Rush now writes these settings to the generated `common/temp/pnpm-workspace.yaml` for pnpm 11+ (matching the existing `allowBuilds` relocation), and `rush-pnpm patch-commit`/`patch-remove` now read `patchedDependencies` back from `pnpm-workspace.yaml` for pnpm 11+. Behavior for pnpm 10 and earlier is unchanged.",
"type": "patch",
"packageName": "@microsoft/rush"
}
],
"packageName": "@microsoft/rush",
"email": "brunojppb@users.noreply.github.com"
}
26 changes: 21 additions & 5 deletions libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,12 +537,28 @@ export class RushPnpmCommandLineParser {
break;
}

// Example: "C:\MyRepo\common\temp\package.json"
const commonPackageJsonFilename: string = `${subspaceTempFolder}/${FileConstants.PackageJson}`;
const commonPackageJson: JsonObject = JsonFile.load(commonPackageJsonFilename);
const newGlobalPatchedDependencies: Record<string, string> | undefined =
commonPackageJson?.pnpm?.patchedDependencies;
const pnpmOptions: PnpmOptionsConfiguration | undefined = this._subspace.getPnpmOptions();
const pnpmVersion: string = this._rushConfiguration.packageManagerToolVersion;
const semver: typeof import('semver') = await import('semver');

let newGlobalPatchedDependencies: Record<string, string> | undefined;
if (semver.gte(pnpmVersion, '11.0.0')) {
// PNPM 11+ stores patchedDependencies in pnpm-workspace.yaml instead of the package.json "pnpm" field
const workspaceYamlFilename: string = `${subspaceTempFolder}/pnpm-workspace.yaml`;
const yamlModule: typeof import('js-yaml') = await import('js-yaml');
const workspaceYamlContent: string = await FileSystem.readFileAsync(workspaceYamlFilename);
const workspaceYaml: { patchedDependencies?: Record<string, string> } = (yamlModule.load(
workspaceYamlContent
) ?? {}) as { patchedDependencies?: Record<string, string> };
newGlobalPatchedDependencies = workspaceYaml?.patchedDependencies;
} else {
// PNPM 10.x and earlier store patchedDependencies in the package.json "pnpm" field
// Example: "C:\MyRepo\common\temp\package.json"
const commonPackageJsonFilename: string = `${subspaceTempFolder}/${FileConstants.PackageJson}`;
const commonPackageJson: JsonObject = JsonFile.load(commonPackageJsonFilename);
newGlobalPatchedDependencies = commonPackageJson?.pnpm?.patchedDependencies;
}

const currentGlobalPatchedDependencies: Record<string, string> | undefined =
pnpmOptions?.globalPatchedDependencies;

Expand Down
110 changes: 110 additions & 0 deletions libraries/rush-lib/src/cli/test/RushPnpmCommandLineParser.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import { FileSystem, JsonFile } from '@rushstack/node-core-library';

import { RushPnpmCommandLineParser } from '../RushPnpmCommandLineParser';

interface IRushPnpmCommandLineParserInternals {
Expand All @@ -13,6 +15,48 @@ async function validatePnpmArgsAsync(pnpmArgs: string[]): Promise<string[]> {
return pnpmArgs;
}

interface IPnpmOptionsStub {
globalPatchedDependencies: Record<string, string> | undefined;
updateGlobalPatchedDependencies: jest.Mock;
}

interface ISubspaceStub {
getSubspaceTempFolderPath(): string;
getSubspaceConfigFolderPath(): string;
getSubspacePnpmPatchesFolderPath(): string;
getPnpmOptions(): IPnpmOptionsStub | undefined;
}

interface IPostExecuteInternals {
_commandName?: string;
_subspace: ISubspaceStub;
_rushConfiguration: { packageManagerToolVersion: string };
_terminal: { writeWarningLine: jest.Mock; writeErrorLine: jest.Mock };
_doRushUpdateAsync: jest.Mock;
_postExecuteAsync(): Promise<void>;
}

const SUBSPACE_TEMP_FOLDER: string = '/repo/common/temp';

function createPostExecuteParser(
commandName: string,
pnpmVersion: string,
pnpmOptions: IPnpmOptionsStub
): IPostExecuteInternals {
const parser: IPostExecuteInternals = Object.create(RushPnpmCommandLineParser.prototype);
parser._commandName = commandName;
parser._rushConfiguration = { packageManagerToolVersion: pnpmVersion };
parser._terminal = { writeWarningLine: jest.fn(), writeErrorLine: jest.fn() };
parser._doRushUpdateAsync = jest.fn().mockResolvedValue(undefined);
parser._subspace = {
getSubspaceTempFolderPath: () => SUBSPACE_TEMP_FOLDER,
getSubspaceConfigFolderPath: () => '/repo/common/config/rush',
getSubspacePnpmPatchesFolderPath: () => '/repo/common/config/rush/pnpm-patches',
getPnpmOptions: () => pnpmOptions
};
return parser;
}

describe(RushPnpmCommandLineParser.name, () => {
it('adds recursive mode to workspace query commands by default', async () => {
await expect(validatePnpmArgsAsync(['outdated'])).resolves.toEqual(['outdated', '--recursive']);
Expand All @@ -34,3 +78,69 @@ describe(RushPnpmCommandLineParser.name, () => {
await expect(validatePnpmArgsAsync(['outdated', '--global'])).resolves.toEqual(['outdated', '--global']);
});
});

describe('RushPnpmCommandLineParser patch-commit patchedDependencies sync', () => {
let readFileAsyncSpy: jest.SpyInstance;
let existsSpy: jest.SpyInstance;
let jsonLoadSpy: jest.SpyInstance;

afterEach(() => {
readFileAsyncSpy?.mockRestore();
existsSpy?.mockRestore();
jsonLoadSpy?.mockRestore();
});

it('reads patchedDependencies from pnpm-workspace.yaml for pnpm >= 11', async () => {
const pnpmOptions: IPnpmOptionsStub = {
globalPatchedDependencies: { 'left-pad@1.0.0': 'patches/left-pad@1.0.0.patch' },
updateGlobalPatchedDependencies: jest.fn()
};
const parser: IPostExecuteInternals = createPostExecuteParser('patch-commit', '11.7.0', pnpmOptions);

const workspaceYaml: string =
'packages:\n' +
' - ../../app\n' +
'patchedDependencies:\n' +
' lodash@4.17.21: patches/lodash@4.17.21.patch\n';
readFileAsyncSpy = jest.spyOn(FileSystem, 'readFileAsync').mockResolvedValue(workspaceYaml);
existsSpy = jest.spyOn(FileSystem, 'exists').mockReturnValue(false);
// If the code incorrectly read package.json for pnpm 11, it would pick up this sentinel value.
jsonLoadSpy = jest
.spyOn(JsonFile, 'load')
.mockReturnValue({ pnpm: { patchedDependencies: { 'should-not-be-used@1.0.0': 'x.patch' } } });

await parser._postExecuteAsync();

expect(readFileAsyncSpy).toHaveBeenCalledWith(`${SUBSPACE_TEMP_FOLDER}/pnpm-workspace.yaml`);
expect(jsonLoadSpy).not.toHaveBeenCalled();
expect(pnpmOptions.updateGlobalPatchedDependencies).toHaveBeenCalledWith({
'lodash@4.17.21': 'patches/lodash@4.17.21.patch'
});
expect(parser._doRushUpdateAsync).toHaveBeenCalledTimes(1);
});

it('reads patchedDependencies from package.json for pnpm < 11', async () => {
const pnpmOptions: IPnpmOptionsStub = {
globalPatchedDependencies: { 'left-pad@1.0.0': 'patches/left-pad@1.0.0.patch' },
updateGlobalPatchedDependencies: jest.fn()
};
const parser: IPostExecuteInternals = createPostExecuteParser('patch-commit', '10.27.0', pnpmOptions);

existsSpy = jest.spyOn(FileSystem, 'exists').mockReturnValue(false);
readFileAsyncSpy = jest.spyOn(FileSystem, 'readFileAsync').mockResolvedValue('');
jsonLoadSpy = jest
.spyOn(JsonFile, 'load')
.mockReturnValue({
pnpm: { patchedDependencies: { 'lodash@4.17.21': 'patches/lodash@4.17.21.patch' } }
});

await parser._postExecuteAsync();

expect(jsonLoadSpy).toHaveBeenCalledWith(`${SUBSPACE_TEMP_FOLDER}/package.json`);
expect(readFileAsyncSpy).not.toHaveBeenCalled();
expect(pnpmOptions.updateGlobalPatchedDependencies).toHaveBeenCalledWith({
'lodash@4.17.21': 'patches/lodash@4.17.21.patch'
});
expect(parser._doRushUpdateAsync).toHaveBeenCalledTimes(1);
});
});
19 changes: 12 additions & 7 deletions libraries/rush-lib/src/logic/installManager/InstallHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,19 +65,24 @@ export class InstallHelpers {
commonPackageJson.pnpm = {};
}

if (pnpmOptions.globalOverrides) {
const pnpmVersion: string = rushConfiguration.packageManagerToolVersion;

// pnpm 11 no longer reads the "pnpm" field of package.json. For pnpm 11+, these settings
// are written to common/temp/pnpm-workspace.yaml by WorkspaceInstallManager instead.
// See https://github.com/microsoft/rushstack/issues/5837
const isPnpm11OrNewer: boolean = semver.gte(pnpmVersion, '11.0.0');

if (!isPnpm11OrNewer && pnpmOptions.globalOverrides) {
commonPackageJson.pnpm.overrides = pnpmOptions.globalOverrides;
}

if (pnpmOptions.globalPackageExtensions) {
if (!isPnpm11OrNewer && pnpmOptions.globalPackageExtensions) {
commonPackageJson.pnpm.packageExtensions = pnpmOptions.globalPackageExtensions;
}
if (pnpmOptions.globalPeerDependencyRules) {
if (!isPnpm11OrNewer && pnpmOptions.globalPeerDependencyRules) {
commonPackageJson.pnpm.peerDependencyRules = pnpmOptions.globalPeerDependencyRules;
}

const pnpmVersion: string = rushConfiguration.packageManagerToolVersion;

if (pnpmOptions.globalNeverBuiltDependencies) {
if (semver.gte(pnpmVersion, '11.0.0')) {
terminal.writeWarningLine(
Expand Down Expand Up @@ -134,11 +139,11 @@ export class InstallHelpers {
commonPackageJson.pnpm.ignoredOptionalDependencies = pnpmOptions.globalIgnoredOptionalDependencies;
}

if (pnpmOptions.globalAllowedDeprecatedVersions) {
if (!isPnpm11OrNewer && pnpmOptions.globalAllowedDeprecatedVersions) {
commonPackageJson.pnpm.allowedDeprecatedVersions = pnpmOptions.globalAllowedDeprecatedVersions;
}

if (pnpmOptions.globalPatchedDependencies) {
if (!isPnpm11OrNewer && pnpmOptions.globalPatchedDependencies) {
commonPackageJson.pnpm.patchedDependencies = pnpmOptions.globalPatchedDependencies;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -476,10 +476,7 @@ export class WorkspaceInstallManager extends BaseInstallManager {
) {
if (pnpmOptions.globalAllowBuilds) {
workspaceFile.setAllowBuilds(pnpmOptions.globalAllowBuilds);
} else if (
pnpmOptions.globalOnlyBuiltDependencies ||
pnpmOptions.globalNeverBuiltDependencies
) {
} else if (pnpmOptions.globalOnlyBuiltDependencies || pnpmOptions.globalNeverBuiltDependencies) {
// Backward compatibility: convert globalOnlyBuiltDependencies/globalNeverBuiltDependencies
// to allowBuilds format for pnpm 11+
const allowBuilds: Record<string, boolean> = {};
Expand Down Expand Up @@ -509,6 +506,30 @@ export class WorkspaceInstallManager extends BaseInstallManager {
);
}

// For pnpm 11+, the following settings must be written to pnpm-workspace.yaml because pnpm 11
// no longer reads the "pnpm" field of package.json (where Rush writes them for older pnpm).
// See https://github.com/microsoft/rushstack/issues/5837
if (
this.rushConfiguration.rushConfigurationJson.pnpmVersion !== undefined &&
semver.gte(this.rushConfiguration.rushConfigurationJson.pnpmVersion, '11.0.0')
) {
if (pnpmOptions.globalOverrides) {
workspaceFile.setOverrides(pnpmOptions.globalOverrides);
}
if (pnpmOptions.globalPackageExtensions) {
workspaceFile.setPackageExtensions(pnpmOptions.globalPackageExtensions);
}
if (pnpmOptions.globalPeerDependencyRules) {
workspaceFile.setPeerDependencyRules(pnpmOptions.globalPeerDependencyRules);
}
if (pnpmOptions.globalAllowedDeprecatedVersions) {
workspaceFile.setAllowedDeprecatedVersions(pnpmOptions.globalAllowedDeprecatedVersions);
}
if (pnpmOptions.globalPatchedDependencies) {
workspaceFile.setPatchedDependencies(pnpmOptions.globalPatchedDependencies);
}
}

// Save the generated workspace file. Don't update the file timestamp unless the content has changed,
// since "rush install" will consider this timestamp
workspaceFile.save(workspaceFile.workspaceFilename, { onlyIfChanged: true });
Expand Down
Loading