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": "Fixed an issue where the pnpm `ignoredOptionalDependencies`, `trustPolicy`, `trustPolicyExclude`, and `trustPolicyIgnoreAfterMinutes` settings were written to the `pnpm` field of the generated `package.json` (which pnpm 11 ignores) instead of `pnpm-workspace.yaml`, causing them to be silently ignored under pnpm 11.",
"type": "patch",
"packageName": "@microsoft/rush"
}
],
"packageName": "@microsoft/rush",
"email": "iclanton@users.noreply.github.com"
}
8 changes: 4 additions & 4 deletions libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -545,10 +545,10 @@ export class RushPnpmCommandLineParser {
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 workspaceFile: PnpmWorkspaceFile = await PnpmWorkspaceFile.loadAsync(
const workspaceFile: PnpmWorkspaceFile | undefined = await PnpmWorkspaceFile.tryLoadAsync(
`${subspaceTempFolder}/${RushConstants.pnpmWorkspaceFileName}`
);
newGlobalPatchedDependencies = workspaceFile.patchedDependencies;
newGlobalPatchedDependencies = workspaceFile?.patchedDependencies;
} else {
// PNPM 10.x and earlier store patchedDependencies in the package.json "pnpm" field
// Example: "C:\MyRepo\common\temp\package.json"
Expand Down Expand Up @@ -613,10 +613,10 @@ export class RushPnpmCommandLineParser {

if (semver.gte(pnpmVersion, '11.0.0')) {
// PNPM 11+ uses allowBuilds in pnpm-workspace.yaml instead of onlyBuiltDependencies in package.json
const workspaceFile: PnpmWorkspaceFile = await PnpmWorkspaceFile.loadAsync(
const workspaceFile: PnpmWorkspaceFile | undefined = await PnpmWorkspaceFile.tryLoadAsync(
`${subspaceTempFolder}/${RushConstants.pnpmWorkspaceFileName}`
);
const newGlobalAllowBuilds: Record<string, boolean> | undefined = workspaceFile.allowBuilds;
const newGlobalAllowBuilds: Record<string, boolean> | undefined = workspaceFile?.allowBuilds;
const currentGlobalAllowBuilds: Record<string, boolean> | undefined =
pnpmOptions?.globalAllowBuilds;

Expand Down
45 changes: 24 additions & 21 deletions libraries/rush-lib/src/logic/installManager/InstallHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,10 @@ interface ICommonPackageJson extends IPackageJson {
*/
export interface IResolvedPnpmSettings {
/**
* The "pnpm" field to write into `common/temp/package.json`. For pnpm 11+, settings that pnpm
* no longer reads from package.json are omitted here and appear in {@link workspaceFile}.
* The "pnpm" field to write into `common/temp/package.json`, or `undefined` for pnpm 11+ (which
* no longer reads that field — all settings are placed on {@link workspaceFile} instead).
*/
packageJsonPnpmSection: ICommonPackageJsonPnpmSection;
packageJsonPnpmSection: ICommonPackageJsonPnpmSection | undefined;

/**
* Additional top-level properties to merge into `common/temp/package.json`
Expand Down Expand Up @@ -324,33 +324,36 @@ export class InstallHelpers {
workspaceFile.minimumReleaseAge = minimumReleaseAgeMinutes;
workspaceFile.minimumReleaseAgeExclude = minimumReleaseAgeExclude;

// pnpm 11 no longer reads the "pnpm" field of package.json. For pnpm 11+, the overrides,
// packageExtensions, peerDependencyRules, allowedDeprecatedVersions, and patchedDependencies
// settings are written to common/temp/pnpm-workspace.yaml (see workspaceSettings below) instead.
// pnpm 11 no longer reads the "pnpm" field of package.json, so for pnpm 11+ we don't generate
// it at all; every setting is written to common/temp/pnpm-workspace.yaml instead.
// See https://github.com/microsoft/rushstack/issues/5837
const packageJsonPnpmSection: ICommonPackageJsonPnpmSection = {
neverBuiltDependencies,
onlyBuiltDependencies,
ignoredOptionalDependencies: globalIgnoredOptionalDependencies,
trustPolicy,
trustPolicyExclude,
trustPolicyIgnoreAfter
};
let packageJsonPnpmSection: ICommonPackageJsonPnpmSection | undefined;

if (isPnpm11) {
// These are written to pnpm-workspace.yaml only for pnpm 11+ (see note above); for older pnpm
// they live in packageJsonPnpmSection instead and are left undefined here.
workspaceFile.overrides = globalOverrides;
workspaceFile.packageExtensions = globalPackageExtensions;
workspaceFile.peerDependencyRules = globalPeerDependencyRules;
workspaceFile.allowedDeprecatedVersions = globalAllowedDeprecatedVersions;
workspaceFile.patchedDependencies = globalPatchedDependencies;
workspaceFile.ignoredOptionalDependencies = globalIgnoredOptionalDependencies;
workspaceFile.trustPolicy = trustPolicy;
workspaceFile.trustPolicyExclude = trustPolicyExclude;
workspaceFile.trustPolicyIgnoreAfter = trustPolicyIgnoreAfter;
} else {
packageJsonPnpmSection.overrides = globalOverrides;
packageJsonPnpmSection.packageExtensions = globalPackageExtensions;
packageJsonPnpmSection.peerDependencyRules = globalPeerDependencyRules;
packageJsonPnpmSection.allowedDeprecatedVersions = globalAllowedDeprecatedVersions;
packageJsonPnpmSection.patchedDependencies = globalPatchedDependencies;
// For older pnpm, these settings live in the "pnpm" field of package.json.
packageJsonPnpmSection = {
neverBuiltDependencies,
onlyBuiltDependencies,
overrides: globalOverrides,
packageExtensions: globalPackageExtensions,
peerDependencyRules: globalPeerDependencyRules,
allowedDeprecatedVersions: globalAllowedDeprecatedVersions,
patchedDependencies: globalPatchedDependencies,
ignoredOptionalDependencies: globalIgnoredOptionalDependencies,
trustPolicy,
trustPolicyExclude,
trustPolicyIgnoreAfter
};
}

return {
Expand Down
100 changes: 85 additions & 15 deletions libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import { FileSystem, Sort, Path } from '@rushstack/node-core-library';

import { BaseWorkspaceFile } from '../base/BaseWorkspaceFile';
import { PNPM_SHRINKWRAP_YAML_FORMAT } from './PnpmYamlCommon';
import type { IPnpmPackageExtension, IPnpmPeerDependencyRules } from './PnpmOptionsConfiguration';
import type {
IPnpmPackageExtension,
IPnpmPeerDependencyRules,
PnpmTrustPolicy
} from './PnpmOptionsConfiguration';

/**
* This interface represents the raw pnpm-workspace.YAML file
Expand Down Expand Up @@ -71,6 +75,32 @@ interface IPnpmWorkspaceYaml {
* (SUPPORTED ONLY IN PNPM 11.0.0 AND NEWER)
*/
patchedDependencies: Record<string, string> | undefined;
/**
* Optional dependencies whose names are listed here are skipped during installation. In pnpm 11+
* this replaces the `pnpm.ignoredOptionalDependencies` field of `package.json`, which pnpm no
* longer reads.
* (SUPPORTED ONLY IN PNPM 9.0.0 AND NEWER)
*/
ignoredOptionalDependencies: string[] | undefined;
/**
* The trust policy applied when installing packages. In pnpm 11+ this replaces the
* `pnpm.trustPolicy` field of `package.json`, which pnpm no longer reads.
* (SUPPORTED ONLY IN PNPM 10.21.0 AND NEWER)
*/
trustPolicy: PnpmTrustPolicy | undefined;
/**
* Package selectors excluded from the trust policy check. In pnpm 11+ this replaces the
* `pnpm.trustPolicyExclude` field of `package.json`, which pnpm no longer reads.
* (SUPPORTED ONLY IN PNPM 10.22.0 AND NEWER)
*/
trustPolicyExclude: string[] | undefined;
/**
* Ignore the trust policy check for packages published more than this many minutes ago. In
* pnpm 11+ this replaces the `pnpm.trustPolicyIgnoreAfter` field of `package.json`, which pnpm
* no longer reads.
* (SUPPORTED ONLY IN PNPM 10.27.0 AND NEWER)
*/
trustPolicyIgnoreAfter: number | undefined;
/**
* The minimum number of minutes that must pass after a version is published before pnpm will install it.
* (SUPPORTED ONLY IN PNPM 10.16.0 AND NEWER)
Expand All @@ -97,6 +127,10 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile {
public peerDependencyRules: IPnpmWorkspaceYaml['peerDependencyRules'];
public allowedDeprecatedVersions: IPnpmWorkspaceYaml['allowedDeprecatedVersions'];
public patchedDependencies: IPnpmWorkspaceYaml['patchedDependencies'];
public ignoredOptionalDependencies: IPnpmWorkspaceYaml['ignoredOptionalDependencies'];
public trustPolicy: IPnpmWorkspaceYaml['trustPolicy'];
public trustPolicyExclude: IPnpmWorkspaceYaml['trustPolicyExclude'];
public trustPolicyIgnoreAfter: IPnpmWorkspaceYaml['trustPolicyIgnoreAfter'];
public minimumReleaseAge: IPnpmWorkspaceYaml['minimumReleaseAge'];
public minimumReleaseAgeExclude: IPnpmWorkspaceYaml['minimumReleaseAgeExclude'];

Expand Down Expand Up @@ -124,24 +158,53 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile {
*
* @param workspaceYamlFilename - The path to the `pnpm-workspace.yaml` file
*/
public static async loadAsync(workspaceYamlFilename: string): Promise<PnpmWorkspaceFile> {
const workspaceYamlContent: string = await FileSystem.readFileAsync(workspaceYamlFilename);
public static async tryLoadAsync(workspaceYamlFilename: string): Promise<PnpmWorkspaceFile | undefined> {
let workspaceYamlContent: string;
try {
workspaceYamlContent = await FileSystem.readFileAsync(workspaceYamlFilename);
} catch (error) {
if (FileSystem.isNotExistError(error)) {
return undefined;
} else {
throw error;
}
}

const yamlModule: typeof import('js-yaml') = await import('js-yaml');
const workspaceYaml: IPnpmWorkspaceYaml | undefined = yamlModule.load(workspaceYamlContent) as
| IPnpmWorkspaceYaml
| undefined;

const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceYamlFilename);
if (workspaceYaml) {
workspaceFile.catalogs = workspaceYaml.catalogs;
workspaceFile.allowBuilds = workspaceYaml.allowBuilds;
workspaceFile.overrides = workspaceYaml.overrides;
workspaceFile.packageExtensions = workspaceYaml.packageExtensions;
workspaceFile.peerDependencyRules = workspaceYaml.peerDependencyRules;
workspaceFile.allowedDeprecatedVersions = workspaceYaml.allowedDeprecatedVersions;
workspaceFile.patchedDependencies = workspaceYaml.patchedDependencies;
workspaceFile.minimumReleaseAge = workspaceYaml.minimumReleaseAge;
workspaceFile.minimumReleaseAgeExclude = workspaceYaml.minimumReleaseAgeExclude;
const {
catalogs,
allowBuilds,
overrides,
packageExtensions,
peerDependencyRules,
allowedDeprecatedVersions,
patchedDependencies,
ignoredOptionalDependencies,
trustPolicy,
trustPolicyExclude,
trustPolicyIgnoreAfter,
minimumReleaseAge,
minimumReleaseAgeExclude
} = workspaceYaml;
workspaceFile.catalogs = catalogs;
workspaceFile.allowBuilds = allowBuilds;
workspaceFile.overrides = overrides;
workspaceFile.packageExtensions = packageExtensions;
workspaceFile.peerDependencyRules = peerDependencyRules;
workspaceFile.allowedDeprecatedVersions = allowedDeprecatedVersions;
workspaceFile.patchedDependencies = patchedDependencies;
workspaceFile.ignoredOptionalDependencies = ignoredOptionalDependencies;
workspaceFile.trustPolicy = trustPolicy;
workspaceFile.trustPolicyExclude = trustPolicyExclude;
workspaceFile.trustPolicyIgnoreAfter = trustPolicyIgnoreAfter;
workspaceFile.minimumReleaseAge = minimumReleaseAge;
workspaceFile.minimumReleaseAgeExclude = minimumReleaseAgeExclude;
}

return workspaceFile;
Expand All @@ -159,9 +222,6 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile {
}

protected override async serializeAsync(): Promise<string> {
// Ensure stable sort order when serializing
Sort.sortSet(this._workspacePackages);

const {
_workspacePackages: workspacePackages,
catalogs,
Expand All @@ -171,9 +231,15 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile {
peerDependencyRules,
allowedDeprecatedVersions,
patchedDependencies,
ignoredOptionalDependencies,
trustPolicy,
trustPolicyExclude,
trustPolicyIgnoreAfter,
minimumReleaseAge,
minimumReleaseAgeExclude
} = this;
// Ensure stable sort order when serializing
Sort.sortSet(workspacePackages);
const workspaceYaml: IPnpmWorkspaceYaml = {
packages: Array.from(workspacePackages),
// js-yaml omits mapping entries whose value is `undefined`, so no guard is needed here.
Expand All @@ -185,6 +251,10 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile {
peerDependencyRules,
allowedDeprecatedVersions,
patchedDependencies,
ignoredOptionalDependencies,
trustPolicy,
trustPolicyExclude,
trustPolicyIgnoreAfter,
minimumReleaseAge,
minimumReleaseAgeExclude
};
Expand Down
74 changes: 45 additions & 29 deletions libraries/rush-lib/src/logic/pnpm/test/PnpmWorkspaceFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,44 +350,56 @@ describe(PnpmWorkspaceFile.name, () => {
});
});

describe(PnpmWorkspaceFile.loadAsync.name, () => {
describe(PnpmWorkspaceFile.tryLoadAsync.name, () => {
let mockReadFileAsync: jest.SpyInstance;

beforeEach(() => {
// Mock FileSystem.readFileAsync to return the content captured by the FileSystem.writeFile mock
mockReadFileAsync = jest.spyOn(FileSystem, 'readFileAsync').mockImplementation(async () => {
if (writtenContent === undefined) {
throw new Error('File not found');
}
return writtenContent;
describe('file exists', () => {
beforeEach(() => {
// Mock FileSystem.readFileAsync to return the content captured by the FileSystem.writeFile mock
mockReadFileAsync = jest.spyOn(FileSystem, 'readFileAsync').mockImplementation(async () => {
if (writtenContent === undefined) {
throw new Error('File not found');
}

return writtenContent;
});
});
});

afterEach(() => {
mockReadFileAsync.mockRestore();
});
afterEach(() => {
mockReadFileAsync.mockRestore();
});

it('reads patchedDependencies from an existing workspace file', async () => {
const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath);
workspaceFile.addPackage(`${projectsDir}/app1`);
workspaceFile.patchedDependencies = {
'lodash@4.17.21': 'patches/lodash@4.17.21.patch'
};
await workspaceFile.saveAsync(workspaceFilePath, { onlyIfChanged: true });
it('reads patchedDependencies from an existing workspace file', async () => {
const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath);
workspaceFile.addPackage(`${projectsDir}/app1`);
workspaceFile.patchedDependencies = {
'lodash@4.17.21': 'patches/lodash@4.17.21.patch'
};
await workspaceFile.saveAsync(workspaceFilePath, { onlyIfChanged: true });

const loadedWorkspaceFile: PnpmWorkspaceFile | undefined =
await PnpmWorkspaceFile.tryLoadAsync(workspaceFilePath);
expect(loadedWorkspaceFile?.patchedDependencies).toEqual({
'lodash@4.17.21': 'patches/lodash@4.17.21.patch'
});
});

const loadedWorkspaceFile: PnpmWorkspaceFile = await PnpmWorkspaceFile.loadAsync(workspaceFilePath);
expect(loadedWorkspaceFile.patchedDependencies).toEqual({
'lodash@4.17.21': 'patches/lodash@4.17.21.patch'
it('returns undefined when the workspace file has no patchedDependencies', async () => {
const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath);
workspaceFile.addPackage(`${projectsDir}/app1`);
await workspaceFile.saveAsync(workspaceFilePath, { onlyIfChanged: true });

const loadedWorkspaceFile: PnpmWorkspaceFile | undefined =
await PnpmWorkspaceFile.tryLoadAsync(workspaceFilePath);
expect(loadedWorkspaceFile!.patchedDependencies).toBeUndefined();
});
});

it('returns undefined when the workspace file has no patchedDependencies', async () => {
const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath);
workspaceFile.addPackage(`${projectsDir}/app1`);
await workspaceFile.saveAsync(workspaceFilePath, { onlyIfChanged: true });

const loadedWorkspaceFile: PnpmWorkspaceFile = await PnpmWorkspaceFile.loadAsync(workspaceFilePath);
expect(loadedWorkspaceFile.patchedDependencies).toBeUndefined();
it("handles the case when the file doesn't exist", async () => {
const loadedWorkspaceFile: PnpmWorkspaceFile | undefined = await PnpmWorkspaceFile.tryLoadAsync(
`${__dirname}/file-that-does-not-exist.yaml`
);
expect(loadedWorkspaceFile).toBeUndefined();
});
});

Expand Down Expand Up @@ -425,6 +437,10 @@ describe(PnpmWorkspaceFile.name, () => {
workspaceFile.patchedDependencies = {
'lodash@4.17.21': 'patches/lodash@4.17.21.patch'
};
workspaceFile.ignoredOptionalDependencies = ['fsevents'];
workspaceFile.trustPolicy = 'no-downgrade';
workspaceFile.trustPolicyExclude = ['chokidar@4.0.3'];
workspaceFile.trustPolicyIgnoreAfter = 1440;

await workspaceFile.saveAsync(workspaceFilePath, { onlyIfChanged: true });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ allowedDeprecatedVersions:
catalogs:
default:
react: ^18.0.0
ignoredOptionalDependencies:
- fsevents
overrides:
foo@1.0.0: 1.0.1
packageExtensions:
Expand All @@ -117,6 +119,10 @@ patchedDependencies:
peerDependencyRules:
allowedVersions:
react: '18'
trustPolicy: no-downgrade
trustPolicyExclude:
- chokidar@4.0.3
trustPolicyIgnoreAfter: 1440
"
`;

Expand Down
Loading