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..54d9374cd3 --- /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. `pnpm publish` and `pnpm pack` are now pointed at an isolated, catalog-only pnpm workspace generated for the project's subspace.", + "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..7da3aea45b 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; @@ -436,8 +439,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); + await this.#addPnpmWorkspaceConfigAsync(env, project); if (this.#npmTag.value) { args.push(`--tag`, this.#npmTag.value); @@ -518,6 +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(); + await this.#addPnpmWorkspaceConfigAsync(env, project); await PublishUtilities.execCommandAsync({ shouldExecute: this.#publish.value, @@ -616,4 +622,62 @@ export class PublishAction extends BaseRushAction { args.push(`--${registry}:_authToken=${this.#npmAuthToken.value}`); } } + + /** + * 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. + */ + async #addPnpmWorkspaceConfigAsync( + env: { [key: string]: string | undefined }, + project: RushConfigurationProject + ): 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 = `${commonTempFolder}/publish-workspaces/${subspace.subspaceName}`; + await FileSystem.ensureEmptyFolderAsync(workspaceFolder); + + 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', () => {