Skip to content
Merged
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,10 @@
{
"changes": [
{
"packageName": "@microsoft/rush",
"comment": "Fix cross-subspace `workspace:*` dependency failures with pnpm 11.",
"type": "patch"
}
],
"packageName": "@microsoft/rush"
}
3 changes: 3 additions & 0 deletions libraries/rush-lib/src/logic/base/BaseInstallManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,9 @@ export abstract class BaseInstallManager {
extraNpmrcLines.push(...commonNpmrcFileLines);
}

// NOTE: pnpm 11+ only reads auth/registry settings from .npmrc, so it ignores this line;
// for pnpm 11+ the global pnpmfile path is emitted via the generated pnpm-workspace.yaml
// instead (see InstallHelpers.resolvePnpmSettings). The line is kept for pnpm 10 and earlier.
extraNpmrcLines.push(
`global-pnpmfile=${subspace.getSubspaceTempFolderPath()}/${RushConstants.pnpmfileGlobalFilename}`
);
Expand Down
10 changes: 10 additions & 0 deletions libraries/rush-lib/src/logic/installManager/InstallHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,16 @@ export class InstallHelpers {
workspaceFile.trustPolicy = trustPolicy;
workspaceFile.trustPolicyExclude = trustPolicyExclude;
workspaceFile.trustPolicyIgnoreAfter = trustPolicyIgnoreAfter;

if (rushConfiguration.subspacesFeatureEnabled) {
// When subspaces are enabled, Rush generates a "global pnpmfile" that rewrites
// cross-subspace "workspace:*" dependency specifiers to "link:" specifiers. For pnpm 10 and
// earlier it is wired up via a "global-pnpmfile=" line in the generated .npmrc (see
// BaseInstallManager), but pnpm 11+ only reads auth/registry settings from .npmrc, so that
// line is silently ignored and installation fails with ERR_PNPM_WORKSPACE_PKG_NOT_FOUND.
// For pnpm 11+, emit the path via the generated pnpm-workspace.yaml instead.
workspaceFile.globalPnpmfile = `${subspace.getSubspaceTempFolderPath()}/${RushConstants.pnpmfileGlobalFilename}`;
}
} else {
// For older pnpm, these settings live in the "pnpm" field of package.json.
packageJsonPnpmSection = {
Expand Down
20 changes: 17 additions & 3 deletions libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,15 @@ interface IPnpmWorkspaceYaml {
* (SUPPORTED ONLY IN PNPM 10.16.0 AND NEWER)
*/
minimumReleaseAgeExclude: string[] | undefined;
/**
* The path to the "global pnpmfile" that Rush generates when subspaces are enabled, which rewrites
* cross-subspace `workspace:*` dependency specifiers to `link:` specifiers. pnpm 11+ only reads
* auth/registry settings from `.npmrc`, so the `global-pnpmfile=` line Rush writes there is
* silently ignored; for pnpm 11+ the path is emitted here instead. Without it, installation of a
* subspace with cross-subspace dependencies fails with ERR_PNPM_WORKSPACE_PKG_NOT_FOUND.
* (USED ONLY IN PNPM 11.0.0 AND NEWER)
*/
globalPnpmfile: string | undefined;
}

export class PnpmWorkspaceFile extends BaseWorkspaceFile {
Expand All @@ -133,6 +142,7 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile {
public trustPolicyIgnoreAfter: IPnpmWorkspaceYaml['trustPolicyIgnoreAfter'];
public minimumReleaseAge: IPnpmWorkspaceYaml['minimumReleaseAge'];
public minimumReleaseAgeExclude: IPnpmWorkspaceYaml['minimumReleaseAgeExclude'];
public globalPnpmfile: IPnpmWorkspaceYaml['globalPnpmfile'];

/**
* The PNPM workspace file is used to specify the location of workspaces relative to the root
Expand Down Expand Up @@ -190,7 +200,8 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile {
trustPolicyExclude,
trustPolicyIgnoreAfter,
minimumReleaseAge,
minimumReleaseAgeExclude
minimumReleaseAgeExclude,
globalPnpmfile
} = workspaceYaml;
workspaceFile.catalogs = catalogs;
workspaceFile.allowBuilds = allowBuilds;
Expand All @@ -205,6 +216,7 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile {
workspaceFile.trustPolicyIgnoreAfter = trustPolicyIgnoreAfter;
workspaceFile.minimumReleaseAge = minimumReleaseAge;
workspaceFile.minimumReleaseAgeExclude = minimumReleaseAgeExclude;
workspaceFile.globalPnpmfile = globalPnpmfile;
}

return workspaceFile;
Expand Down Expand Up @@ -236,7 +248,8 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile {
trustPolicyExclude,
trustPolicyIgnoreAfter,
minimumReleaseAge,
minimumReleaseAgeExclude
minimumReleaseAgeExclude,
globalPnpmfile
} = this;
// Ensure stable sort order when serializing
Sort.sortSet(workspacePackages);
Expand All @@ -256,7 +269,8 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile {
trustPolicyExclude,
trustPolicyIgnoreAfter,
minimumReleaseAge,
minimumReleaseAgeExclude
minimumReleaseAgeExclude,
globalPnpmfile
};

const yamlModule: typeof import('js-yaml') = await import('js-yaml');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,19 @@ describe(PnpmWorkspaceFile.name, () => {
});
});

it('reads globalPnpmfile from an existing workspace file', async () => {
const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath);
workspaceFile.addPackage(`${projectsDir}/app1`);
workspaceFile.globalPnpmfile = '/repo/common/temp/my-subspace/global-pnpmfile.cjs';
await workspaceFile.saveAsync(workspaceFilePath, { onlyIfChanged: true });

const loadedWorkspaceFile: PnpmWorkspaceFile | undefined =
await PnpmWorkspaceFile.tryLoadAsync(workspaceFilePath);
expect(loadedWorkspaceFile?.globalPnpmfile).toEqual(
'/repo/common/temp/my-subspace/global-pnpmfile.cjs'
);
});

it('returns undefined when the workspace file has no patchedDependencies', async () => {
const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath);
workspaceFile.addPackage(`${projectsDir}/app1`);
Expand Down Expand Up @@ -448,6 +461,19 @@ describe(PnpmWorkspaceFile.name, () => {
});
});

describe('globalPnpmfile functionality', () => {
it('generates workspace file with globalPnpmfile', async () => {
const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath);
workspaceFile.addPackage(`${projectsDir}/app1`);

workspaceFile.globalPnpmfile = '/repo/common/temp/my-subspace/global-pnpmfile.cjs';

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

expect(writtenContent).toMatchSnapshot();
});
});

describe('minimumReleaseAge functionality', () => {
it('generates workspace file with minimumReleaseAge', async () => {
const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,13 @@ trustPolicyIgnoreAfter: 1440
"
`;

exports[`PnpmWorkspaceFile globalPnpmfile functionality generates workspace file with globalPnpmfile 1`] = `
"globalPnpmfile: /repo/common/temp/my-subspace/global-pnpmfile.cjs
packages:
- projects/app1
"
`;

exports[`PnpmWorkspaceFile minimumReleaseAge functionality generates workspace file with minimumReleaseAge 1`] = `
"minimumReleaseAge: 20160
packages:
Expand Down
41 changes: 41 additions & 0 deletions libraries/rush-lib/src/logic/test/InstallHelpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,47 @@ describe(InstallHelpers.name, () => {
expect(workspaceFile?.trustPolicy).toEqual('no-downgrade');
expect(workspaceFile?.trustPolicyExclude).toEqual(['chokidar@4.0.3']);
expect(workspaceFile?.trustPolicyIgnoreAfter).toEqual(1440);

// The subspaces feature is not enabled in this repo, so no global pnpmfile is emitted.
expect(workspaceFile?.globalPnpmfile).toBeUndefined();
});

it('emits the subspace global pnpmfile path via pnpm-workspace.yaml for pnpm 11', async () => {
const RUSH_JSON_FILENAME: string = `${__dirname}/pnpmConfigPnpm11Subspaces/rush.json`;
const rushConfiguration: RushConfiguration =
RushConfiguration.loadFromConfigurationFile(RUSH_JSON_FILENAME);
const pnpmSettings = InstallHelpers.resolvePnpmSettings(
rushConfiguration,
rushConfiguration.defaultSubspace,
terminal
);

// pnpm 11+ only reads auth/registry settings from .npmrc, so the "global-pnpmfile=" line in
// the generated .npmrc is ignored; the path must be emitted via pnpm-workspace.yaml instead,
// otherwise cross-subspace "workspace:*" dependencies fail with
// ERR_PNPM_WORKSPACE_PKG_NOT_FOUND.
const workspaceFile: PnpmWorkspaceFile | undefined =
TestUtilities.stripAnnotations(pnpmSettings)?.workspaceFile;
expect(workspaceFile?.globalPnpmfile).toEqual(
`${rushConfiguration.defaultSubspace.getSubspaceTempFolderPath()}/global-pnpmfile.cjs`
);
});

it('does not emit the global pnpmfile via pnpm-workspace.yaml for pnpm < 11', async () => {
const RUSH_JSON_FILENAME: string = `${__dirname}/repoWithSubspaces/rush.json`;
const rushConfiguration: RushConfiguration =
RushConfiguration.loadFromConfigurationFile(RUSH_JSON_FILENAME);
const pnpmSettings = InstallHelpers.resolvePnpmSettings(
rushConfiguration,
rushConfiguration.defaultSubspace,
terminal
);

// For pnpm 10 and earlier the global pnpmfile stays wired up via the generated .npmrc
// (see BaseInstallManager); the workspace file must not carry it.
const workspaceFile: PnpmWorkspaceFile | undefined =
TestUtilities.stripAnnotations(pnpmSettings)?.workspaceFile;
expect(workspaceFile?.globalPnpmfile).toBeUndefined();
});
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing

exports[`InstallHelpers generateCommonPackageJsonAsync does not emit the global pnpmfile via pnpm-workspace.yaml for pnpm < 11: Terminal Output 1`] = `Array []`;

exports[`InstallHelpers generateCommonPackageJsonAsync does not generate a "pnpm" field for pnpm 11 (all settings belong in pnpm-workspace.yaml): Terminal Output 1`] = `Array []`;

exports[`InstallHelpers generateCommonPackageJsonAsync emits the subspace global pnpmfile path via pnpm-workspace.yaml for pnpm 11: Terminal Output 1`] = `Array []`;

exports[`InstallHelpers generateCommonPackageJsonAsync generates correct package json with pnpm configurations 1`] = `
Object {
"dependencies": Object {},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"useWorkspaces": true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/rush/v5/subspaces.schema.json",
"subspacesEnabled": true,
"subspaceNames": ["extra-subspace"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"pnpmVersion": "11.0.0",
"rushVersion": "5.58.0",
"projects": []
}