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,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"
}
66 changes: 65 additions & 1 deletion libraries/rush-lib/src/cli/actions/PublishAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -55,6 +57,7 @@ export class PublishAction extends BaseRushAction {

#prereleaseToken!: PrereleaseToken;
#hotfixTagOverride!: string;
readonly #pnpmPublishWorkspaceFolderPromises: Map<Subspace, Promise<string | undefined>> = new Map();
#targetNpmrcPublishFolder!: string;
#targetNpmrcPublishPath!: string;

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -518,6 +523,7 @@ export class PublishAction extends BaseRushAction {
async #npmPackAsync(packageName: string, project: RushConfigurationProject): Promise<void> {
const args: string[] = ['pack'];
const env: { [key: string]: string | undefined } = PublishUtilities.getEnvArgs();
await this.#addPnpmWorkspaceConfigAsync(env, project);

await PublishUtilities.execCommandAsync({
shouldExecute: this.#publish.value,
Expand Down Expand Up @@ -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 `<workspaceDir>/.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<void> {
const workspaceFolder: string | undefined = await this.#getPnpmPublishWorkspaceFolderAsync(
project.subspace
);
if (workspaceFolder) {
env.NPM_CONFIG_WORKSPACE_DIR = workspaceFolder;
}
}

#getPnpmPublishWorkspaceFolderAsync(subspace: Subspace): Promise<string | undefined> {
let workspaceFolderPromise: Promise<string | undefined> | undefined =
this.#pnpmPublishWorkspaceFolderPromises.get(subspace);
if (!workspaceFolderPromise) {
workspaceFolderPromise = this.#createPnpmPublishWorkspaceFolderAsync(subspace);
this.#pnpmPublishWorkspaceFolderPromises.set(subspace, workspaceFolderPromise);
}

return workspaceFolderPromise;
}

async #createPnpmPublishWorkspaceFolderAsync(subspace: Subspace): Promise<string | undefined> {
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;
}
}
14 changes: 13 additions & 1 deletion libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Omit<IPnpmWorkspaceYaml, 'packages'>>;

export class PnpmWorkspaceFile extends BaseWorkspaceFile {
/**
* The filename of the workspace file.
Expand All @@ -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<string>();

if (settings) {
Object.assign(this, settings);
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading