From b56a6f05d7c0ff68dfede5e63c9c7de61b83a88b Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:11:08 -0700 Subject: [PATCH 1/4] [rush] Fix rush publish for pnpm catalog: dependencies Set NPM_CONFIG_WORKSPACE_DIR to the project's subspace temp folder for pnpm publish/pack so pnpm can find the generated pnpm-workspace.yaml catalogs, matching rush-pnpm. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...ublish-pnpm-catalogs_2026-10-14-04-30.json | 11 ++++++++++ .../rush-lib/src/cli/actions/PublishAction.ts | 21 ++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 common/changes/@microsoft/rush/fix-rush-publish-pnpm-catalogs_2026-10-14-04-30.json diff --git a/common/changes/@microsoft/rush/fix-rush-publish-pnpm-catalogs_2026-10-14-04-30.json b/common/changes/@microsoft/rush/fix-rush-publish-pnpm-catalogs_2026-10-14-04-30.json new file mode 100644 index 0000000000..600d88a44a --- /dev/null +++ b/common/changes/@microsoft/rush/fix-rush-publish-pnpm-catalogs_2026-10-14-04-30.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Fix an issue where `rush publish` failed with `ERR_PNPM_CATALOG_ENTRY_NOT_FOUND_FOR_SPEC` for projects using pnpm `catalog:` dependencies, by pointing `pnpm publish` and `pnpm pack` at the project's subspace workspace folder (matching `rush-pnpm`).", + "type": "none" + } + ], + "packageName": "@microsoft/rush", + "email": "5100938+bmiddha@users.noreply.github.com" +} diff --git a/libraries/rush-lib/src/cli/actions/PublishAction.ts b/libraries/rush-lib/src/cli/actions/PublishAction.ts index 877eeb4eca..504958881e 100644 --- a/libraries/rush-lib/src/cli/actions/PublishAction.ts +++ b/libraries/rush-lib/src/cli/actions/PublishAction.ts @@ -436,8 +436,10 @@ export class PublishAction extends BaseRushAction { const env: { [key: string]: string | undefined } = PublishUtilities.getEnvArgs(); const args: string[] = ['publish']; - if (this.rushConfiguration.projectsByName.get(packageName)!.shouldPublish) { + const project: RushConfigurationProject = this.rushConfiguration.projectsByName.get(packageName)!; + if (project.shouldPublish) { this.#addSharedNpmConfig(env, args); + this.#addPnpmWorkspaceConfig(env, project); if (this.#npmTag.value) { args.push(`--tag`, this.#npmTag.value); @@ -518,6 +520,7 @@ export class PublishAction extends BaseRushAction { async #npmPackAsync(packageName: string, project: RushConfigurationProject): Promise { const args: string[] = ['pack']; const env: { [key: string]: string | undefined } = PublishUtilities.getEnvArgs(); + this.#addPnpmWorkspaceConfig(env, project); await PublishUtilities.execCommandAsync({ shouldExecute: this.#publish.value, @@ -616,4 +619,20 @@ export class PublishAction extends BaseRushAction { args.push(`--${registry}:_authToken=${this.#npmAuthToken.value}`); } } + + /** + * PNPM resolves "catalog:" and "workspace:" specifiers using the pnpm-workspace.yaml file, which it + * normally finds by searching upward from the working directory. Rush generates that file in the + * subspace's temp folder, which is not a parent of the project folder, so point PNPM at it explicitly. + * This matches what "rush-pnpm" does. + */ + #addPnpmWorkspaceConfig( + env: { [key: string]: string | undefined }, + project: RushConfigurationProject + ): void { + const { isPnpm, pnpmOptions } = this.rushConfiguration; + if (isPnpm && pnpmOptions.useWorkspaces) { + env.NPM_CONFIG_WORKSPACE_DIR = project.subspace.getSubspaceTempFolderPath(); + } + } } From 2f2ada106c27530e54bcfb96269f7e1a0d1f6386 Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:21:28 -0700 Subject: [PATCH 2/4] Use an isolated catalog-only pnpm workspace for rush publish Pointing NPM_CONFIG_WORKSPACE_DIR at the subspace temp folder caused pnpm publish to copy the install-time .npmrc into its temp publish folder, overriding common/config/rush/.npmrc-publish. Generate common/temp/publish-workspaces//pnpm-workspace.yaml containing only the catalogs instead, and only when catalogs are configured. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...ublish-pnpm-catalogs_2026-10-14-04-30.json | 2 +- .../rush-lib/src/cli/actions/PublishAction.ts | 66 +++++++++++++++---- 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/common/changes/@microsoft/rush/fix-rush-publish-pnpm-catalogs_2026-10-14-04-30.json b/common/changes/@microsoft/rush/fix-rush-publish-pnpm-catalogs_2026-10-14-04-30.json index 600d88a44a..54d9374cd3 100644 --- a/common/changes/@microsoft/rush/fix-rush-publish-pnpm-catalogs_2026-10-14-04-30.json +++ b/common/changes/@microsoft/rush/fix-rush-publish-pnpm-catalogs_2026-10-14-04-30.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@microsoft/rush", - "comment": "Fix an issue where `rush publish` failed with `ERR_PNPM_CATALOG_ENTRY_NOT_FOUND_FOR_SPEC` for projects using pnpm `catalog:` dependencies, by pointing `pnpm publish` and `pnpm pack` at the project's subspace workspace folder (matching `rush-pnpm`).", + "comment": "Fix an issue where `rush publish` failed with `ERR_PNPM_CATALOG_ENTRY_NOT_FOUND_FOR_SPEC` for projects using pnpm `catalog:` dependencies. `pnpm publish` and `pnpm pack` are now pointed at an isolated, catalog-only pnpm workspace generated for the project's subspace.", "type": "none" } ], diff --git a/libraries/rush-lib/src/cli/actions/PublishAction.ts b/libraries/rush-lib/src/cli/actions/PublishAction.ts index 504958881e..cda7493f0c 100644 --- a/libraries/rush-lib/src/cli/actions/PublishAction.ts +++ b/libraries/rush-lib/src/cli/actions/PublishAction.ts @@ -30,6 +30,8 @@ import { Git } from '../../logic/Git'; import { RushConstants } from '../../logic/RushConstants'; import { IS_WINDOWS } from '../../utilities/executionUtilities'; import type { RushConfiguration } from '../../api/RushConfiguration'; +import type { Subspace } from '../../api/Subspace'; +import { PnpmWorkspaceFile } from '../../logic/pnpm/PnpmWorkspaceFile'; export class PublishAction extends BaseRushAction { readonly #addCommitDetails: CommandLineFlagParameter; @@ -55,6 +57,7 @@ export class PublishAction extends BaseRushAction { #prereleaseToken!: PrereleaseToken; #hotfixTagOverride!: string; + readonly #pnpmPublishWorkspaceFolderPromises: Map> = new Map(); #targetNpmrcPublishFolder!: string; #targetNpmrcPublishPath!: string; @@ -439,7 +442,7 @@ export class PublishAction extends BaseRushAction { const project: RushConfigurationProject = this.rushConfiguration.projectsByName.get(packageName)!; if (project.shouldPublish) { this.#addSharedNpmConfig(env, args); - this.#addPnpmWorkspaceConfig(env, project); + await this.#addPnpmWorkspaceConfigAsync(env, project); if (this.#npmTag.value) { args.push(`--tag`, this.#npmTag.value); @@ -520,7 +523,7 @@ export class PublishAction extends BaseRushAction { async #npmPackAsync(packageName: string, project: RushConfigurationProject): Promise { const args: string[] = ['pack']; const env: { [key: string]: string | undefined } = PublishUtilities.getEnvArgs(); - this.#addPnpmWorkspaceConfig(env, project); + await this.#addPnpmWorkspaceConfigAsync(env, project); await PublishUtilities.execCommandAsync({ shouldExecute: this.#publish.value, @@ -621,18 +624,59 @@ export class PublishAction extends BaseRushAction { } /** - * PNPM resolves "catalog:" and "workspace:" specifiers using the pnpm-workspace.yaml file, which it - * normally finds by searching upward from the working directory. Rush generates that file in the - * subspace's temp folder, which is not a parent of the project folder, so point PNPM at it explicitly. - * This matches what "rush-pnpm" does. + * PNPM replaces "catalog:" specifiers using the catalogs in pnpm-workspace.yaml, which it normally finds + * by searching upward from the working directory. Rush generates that file in the subspace's temp folder, + * which is not a parent of the project folder, so point PNPM at a workspace folder explicitly. + * + * @remarks + * The subspace temp folder is not used directly because "pnpm publish" copies `/.npmrc` + * into the folder where it runs "npm publish". That would apply Rush's install-time .npmrc with higher + * precedence than the publish .npmrc that `#addSharedNpmConfig()` provides via the home folder. + * Instead, generate an isolated folder that contains only the catalogs and no .npmrc. */ - #addPnpmWorkspaceConfig( + async #addPnpmWorkspaceConfigAsync( env: { [key: string]: string | undefined }, project: RushConfigurationProject - ): void { - const { isPnpm, pnpmOptions } = this.rushConfiguration; - if (isPnpm && pnpmOptions.useWorkspaces) { - env.NPM_CONFIG_WORKSPACE_DIR = project.subspace.getSubspaceTempFolderPath(); + ): Promise { + const workspaceFolder: string | undefined = await this.#getPnpmPublishWorkspaceFolderAsync( + project.subspace + ); + if (workspaceFolder) { + env.NPM_CONFIG_WORKSPACE_DIR = workspaceFolder; + } + } + + #getPnpmPublishWorkspaceFolderAsync(subspace: Subspace): Promise { + let workspaceFolderPromise: Promise | undefined = + this.#pnpmPublishWorkspaceFolderPromises.get(subspace); + if (!workspaceFolderPromise) { + workspaceFolderPromise = this.#createPnpmPublishWorkspaceFolderAsync(subspace); + this.#pnpmPublishWorkspaceFolderPromises.set(subspace, workspaceFolderPromise); + } + + return workspaceFolderPromise; + } + + async #createPnpmPublishWorkspaceFolderAsync(subspace: Subspace): Promise { + const { isPnpm, pnpmOptions: rushPnpmOptions, commonTempFolder } = this.rushConfiguration; + if (!isPnpm || !rushPnpmOptions.useWorkspaces) { + return undefined; + } + + const { globalCatalogs } = subspace.getPnpmOptions() ?? rushPnpmOptions; + if (!globalCatalogs || Object.keys(globalCatalogs).length === 0) { + return undefined; } + + // Example: "common/temp/publish-workspaces/default" + const workspaceFolder: string = path.join(commonTempFolder, 'publish-workspaces', subspace.subspaceName); + await FileSystem.ensureEmptyFolderAsync(workspaceFolder); + + const workspaceFilePath: string = path.join(workspaceFolder, 'pnpm-workspace.yaml'); + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath); + workspaceFile.catalogs = globalCatalogs; + await workspaceFile.saveAsync(workspaceFilePath, { onlyIfChanged: false }); + + return workspaceFolder; } } From a28f917f925b0f13018555919af0c3a7fd841046 Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:47:32 -0700 Subject: [PATCH 3/4] Use RushConstants.pnpmWorkspaceFileName in PublishAction Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- libraries/rush-lib/src/cli/actions/PublishAction.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/rush-lib/src/cli/actions/PublishAction.ts b/libraries/rush-lib/src/cli/actions/PublishAction.ts index cda7493f0c..8556b74f68 100644 --- a/libraries/rush-lib/src/cli/actions/PublishAction.ts +++ b/libraries/rush-lib/src/cli/actions/PublishAction.ts @@ -672,7 +672,7 @@ export class PublishAction extends BaseRushAction { const workspaceFolder: string = path.join(commonTempFolder, 'publish-workspaces', subspace.subspaceName); await FileSystem.ensureEmptyFolderAsync(workspaceFolder); - const workspaceFilePath: string = path.join(workspaceFolder, 'pnpm-workspace.yaml'); + const workspaceFilePath: string = path.join(workspaceFolder, RushConstants.pnpmWorkspaceFileName); const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath); workspaceFile.catalogs = globalCatalogs; await workspaceFile.saveAsync(workspaceFilePath, { onlyIfChanged: false }); From 8f36e35aea8b8d92cf4aab61bda491f439cb29fa Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:24:45 -0700 Subject: [PATCH 4/4] Address review feedback in PublishAction - Build publish workspace paths with template strings. - Allow PnpmWorkspaceFile settings to be passed to the constructor and use it for the catalog-only publish workspace. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../rush-lib/src/cli/actions/PublishAction.ts | 9 ++++---- .../src/logic/pnpm/PnpmWorkspaceFile.ts | 14 +++++++++++- .../logic/pnpm/test/PnpmWorkspaceFile.test.ts | 22 +++++++++++++++++++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/libraries/rush-lib/src/cli/actions/PublishAction.ts b/libraries/rush-lib/src/cli/actions/PublishAction.ts index 8556b74f68..7da3aea45b 100644 --- a/libraries/rush-lib/src/cli/actions/PublishAction.ts +++ b/libraries/rush-lib/src/cli/actions/PublishAction.ts @@ -669,12 +669,13 @@ export class PublishAction extends BaseRushAction { } // Example: "common/temp/publish-workspaces/default" - const workspaceFolder: string = path.join(commonTempFolder, 'publish-workspaces', subspace.subspaceName); + const workspaceFolder: string = `${commonTempFolder}/publish-workspaces/${subspace.subspaceName}`; await FileSystem.ensureEmptyFolderAsync(workspaceFolder); - const workspaceFilePath: string = path.join(workspaceFolder, RushConstants.pnpmWorkspaceFileName); - const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath); - workspaceFile.catalogs = globalCatalogs; + const workspaceFilePath: string = `${workspaceFolder}/${RushConstants.pnpmWorkspaceFileName}`; + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath, { + catalogs: globalCatalogs + }); await workspaceFile.saveAsync(workspaceFilePath, { onlyIfChanged: false }); return workspaceFolder; diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts b/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts index 433ac3d699..545b74901c 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts @@ -122,6 +122,11 @@ interface IPnpmWorkspaceYaml { globalPnpmfile: string | undefined; } +/** + * The pnpm settings that can be provided when constructing a {@link PnpmWorkspaceFile}. + */ +export type PnpmWorkspaceFileSettings = Partial>; + export class PnpmWorkspaceFile extends BaseWorkspaceFile { /** * The filename of the workspace file. @@ -147,14 +152,21 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile { /** * The PNPM workspace file is used to specify the location of workspaces relative to the root * of your PNPM install. + * + * @param workspaceYamlFilename - The path to the `pnpm-workspace.yaml` file + * @param settings - Optional pnpm settings to initialize the file with */ - public constructor(workspaceYamlFilename: string) { + public constructor(workspaceYamlFilename: string, settings?: PnpmWorkspaceFileSettings) { super(); this.workspaceFilename = workspaceYamlFilename; // Ignore any existing file since this file is generated and we need to handle deleting packages // If we need to support manual customization, that should be an additional parameter for "base file" this.#workspacePackages = new Set(); + + if (settings) { + Object.assign(this, settings); + } } /** diff --git a/libraries/rush-lib/src/logic/pnpm/test/PnpmWorkspaceFile.test.ts b/libraries/rush-lib/src/logic/pnpm/test/PnpmWorkspaceFile.test.ts index d34b55191e..bc1c70fc07 100644 --- a/libraries/rush-lib/src/logic/pnpm/test/PnpmWorkspaceFile.test.ts +++ b/libraries/rush-lib/src/logic/pnpm/test/PnpmWorkspaceFile.test.ts @@ -41,6 +41,28 @@ describe(PnpmWorkspaceFile.name, () => { expect(writtenContent).toContain('\\[app-with-brackets\\]'); }); + + it('initializes settings passed to the constructor', async () => { + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath, { + catalogs: { + default: { + react: '^18.0.0' + } + }, + minimumReleaseAge: 1440 + }); + + expect(workspaceFile.catalogs).toEqual({ default: { react: '^18.0.0' } }); + expect(workspaceFile.minimumReleaseAge).toEqual(1440); + + await workspaceFile.saveAsync(workspaceFilePath, { onlyIfChanged: true }); + + expect(writtenContent).toEqual( + ['catalogs:', ' default:', ' react: ^18.0.0', 'minimumReleaseAge: 1440', 'packages: []', ''].join( + '\n' + ) + ); + }); }); describe('catalog functionality', () => {