Skip to content
Draft
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": "Add support for pnpm 11's `allowBuilds` field in `pnpm-workspace.yaml`. Rush now correctly handles the pnpm 11 security model where build scripts must be explicitly approved. The new `globalAllowBuilds` field in `pnpm-config.json` replaces the deprecated `globalOnlyBuiltDependencies` and `globalNeverBuiltDependencies` fields for pnpm 11+. The `rush-pnpm approve-builds` command is also updated to work correctly with pnpm 11.",
"type": "minor",
"packageName": "@microsoft/rush"
}
],
"packageName": "@microsoft/rush",
"email": "198982749+Copilot@users.noreply.github.com"
}
71 changes: 51 additions & 20 deletions libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,8 @@ export class RushPnpmCommandLineParser {
const semver: typeof import('semver') = await import('semver');
/**
* The "approve-builds" command was introduced in pnpm version 10.1.0
* to approve packages for running build scripts when onlyBuiltDependencies is used
* to approve packages for running build scripts when onlyBuiltDependencies is used.
* In pnpm 11.0.0, it was updated to use allowBuilds in pnpm-workspace.yaml.
*/
if (semver.lt(this._rushConfiguration.packageManagerToolVersion, '10.1.0')) {
this._terminal.writeErrorLine(
Expand Down Expand Up @@ -572,26 +573,56 @@ export class RushPnpmCommandLineParser {
break;
}

// Example: "C:\MyRepo\common\temp\package.json"
const commonPackageJsonFilename: string = `${subspaceTempFolder}/${FileConstants.PackageJson}`;
const commonPackageJson: JsonObject = await JsonFile.loadAsync(commonPackageJsonFilename);
const newGlobalOnlyBuiltDependencies: string[] | undefined =
commonPackageJson?.pnpm?.onlyBuiltDependencies;
const pnpmOptions: PnpmOptionsConfiguration | undefined = this._subspace.getPnpmOptions();
const currentGlobalOnlyBuiltDependencies: string[] | undefined =
pnpmOptions?.globalOnlyBuiltDependencies;

if (!Objects.areDeepEqual(currentGlobalOnlyBuiltDependencies, newGlobalOnlyBuiltDependencies)) {
// Update onlyBuiltDependencies to pnpm configuration file
pnpmOptions?.updateGlobalOnlyBuiltDependencies(newGlobalOnlyBuiltDependencies);

// Rerun installation to update
await this._doRushUpdateAsync();

this._terminal.writeWarningLine(
`Rush refreshed the ${RushConstants.pnpmConfigFilename} and shrinkwrap file.\n` +
' Please commit this change to Git.'
);
const pnpmVersion: string = this._rushConfiguration.packageManagerToolVersion;
const semver: typeof import('semver') = await import('semver');

if (semver.gte(pnpmVersion, '11.0.0')) {
// pnpm 11+ uses allowBuilds in pnpm-workspace.yaml instead of onlyBuiltDependencies in package.json
const workspaceYamlFilename: string = `${subspaceTempFolder}/pnpm-workspace.yaml`;
const yamlModule: typeof import('js-yaml') = await import('js-yaml');
const workspaceYamlContent: string = await FileSystem.readFileAsync(workspaceYamlFilename);
const workspaceYaml: { allowBuilds?: Record<string, boolean> } = (yamlModule.load(
workspaceYamlContent
) ?? {}) as { allowBuilds?: Record<string, boolean> };
const newGlobalAllowBuilds: Record<string, boolean> | undefined = workspaceYaml?.allowBuilds;
const currentGlobalAllowBuilds: Record<string, boolean> | undefined =
pnpmOptions?.globalAllowBuilds;

if (!Objects.areDeepEqual(currentGlobalAllowBuilds, newGlobalAllowBuilds)) {
// Update allowBuilds to pnpm configuration file
pnpmOptions?.updateGlobalAllowBuilds(newGlobalAllowBuilds);

// Rerun installation to update
await this._doRushUpdateAsync();

this._terminal.writeWarningLine(
`Rush refreshed the ${RushConstants.pnpmConfigFilename} and shrinkwrap file.\n` +
' Please commit this change to Git.'
);
}
} else {
// pnpm 10.x uses onlyBuiltDependencies in package.json
// Example: "C:\MyRepo\common\temp\package.json"
const commonPackageJsonFilename: string = `${subspaceTempFolder}/${FileConstants.PackageJson}`;
const commonPackageJson: JsonObject = await JsonFile.loadAsync(commonPackageJsonFilename);
const newGlobalOnlyBuiltDependencies: string[] | undefined =
commonPackageJson?.pnpm?.onlyBuiltDependencies;
const currentGlobalOnlyBuiltDependencies: string[] | undefined =
pnpmOptions?.globalOnlyBuiltDependencies;

if (!Objects.areDeepEqual(currentGlobalOnlyBuiltDependencies, newGlobalOnlyBuiltDependencies)) {
// Update onlyBuiltDependencies to pnpm configuration file
pnpmOptions?.updateGlobalOnlyBuiltDependencies(newGlobalOnlyBuiltDependencies);

// Rerun installation to update
await this._doRushUpdateAsync();

this._terminal.writeWarningLine(
`Rush refreshed the ${RushConstants.pnpmConfigFilename} and shrinkwrap file.\n` +
' Please commit this change to Git.'
);
}
}
break;
}
Expand Down
40 changes: 34 additions & 6 deletions libraries/rush-lib/src/logic/installManager/InstallHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,25 +77,53 @@ export class InstallHelpers {
}

if (pnpmOptions.globalNeverBuiltDependencies) {
commonPackageJson.pnpm.neverBuiltDependencies = pnpmOptions.globalNeverBuiltDependencies;
if (
rushConfiguration.rushConfigurationJson.pnpmVersion !== undefined &&
semver.gte(rushConfiguration.rushConfigurationJson.pnpmVersion, '11.0.0')
) {
terminal.writeWarningLine(
Colorize.yellow(
`Your version of pnpm (${rushConfiguration.rushConfigurationJson.pnpmVersion}) ` +
`no longer supports the "globalNeverBuiltDependencies" field in ` +
`${rushConfiguration.commonRushConfigFolder}/${RushConstants.pnpmConfigFilename}. ` +
'Use "globalAllowBuilds" instead (with a value of false to deny build scripts).'
)
);
} else {
commonPackageJson.pnpm.neverBuiltDependencies = pnpmOptions.globalNeverBuiltDependencies;
}
}

if (pnpmOptions.globalOnlyBuiltDependencies) {
if (
rushConfiguration.rushConfigurationJson.pnpmVersion !== undefined &&
semver.lt(rushConfiguration.rushConfigurationJson.pnpmVersion, '10.1.0')
semver.gte(rushConfiguration.rushConfigurationJson.pnpmVersion, '11.0.0')
) {
terminal.writeWarningLine(
Colorize.yellow(
`Your version of pnpm (${rushConfiguration.rushConfigurationJson.pnpmVersion}) ` +
`doesn't support the "globalOnlyBuiltDependencies" field in ` +
`no longer supports the "globalOnlyBuiltDependencies" field in ` +
`${rushConfiguration.commonRushConfigFolder}/${RushConstants.pnpmConfigFilename}. ` +
'Remove this field or upgrade to pnpm 10.1.0 or newer.'
'Use "globalAllowBuilds" instead (with a value of true to allow build scripts).'
)
);
}
} else {
if (
rushConfiguration.rushConfigurationJson.pnpmVersion !== undefined &&
semver.lt(rushConfiguration.rushConfigurationJson.pnpmVersion, '10.1.0')
) {
terminal.writeWarningLine(
Colorize.yellow(
`Your version of pnpm (${rushConfiguration.rushConfigurationJson.pnpmVersion}) ` +
`doesn't support the "globalOnlyBuiltDependencies" field in ` +
`${rushConfiguration.commonRushConfigFolder}/${RushConstants.pnpmConfigFilename}. ` +
'Remove this field or upgrade to pnpm 10.1.0 or newer.'
)
);
}

commonPackageJson.pnpm.onlyBuiltDependencies = pnpmOptions.globalOnlyBuiltDependencies;
commonPackageJson.pnpm.onlyBuiltDependencies = pnpmOptions.globalOnlyBuiltDependencies;
}
}

if (pnpmOptions.globalIgnoredOptionalDependencies) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,46 @@ export class WorkspaceInstallManager extends BaseInstallManager {
workspaceFile.setCatalogs(catalogs);
}

// Set allowBuilds in the workspace file for pnpm 11+ (replaces onlyBuiltDependencies/neverBuiltDependencies)
if (
this.rushConfiguration.rushConfigurationJson.pnpmVersion !== undefined &&
semver.gte(this.rushConfiguration.rushConfigurationJson.pnpmVersion, '11.0.0')
) {
if (pnpmOptions.globalAllowBuilds) {
workspaceFile.setAllowBuilds(pnpmOptions.globalAllowBuilds);
} else if (
pnpmOptions.globalOnlyBuiltDependencies ||
pnpmOptions.globalNeverBuiltDependencies
) {
// Backward compatibility: convert globalOnlyBuiltDependencies/globalNeverBuiltDependencies
// to allowBuilds format for pnpm 11+
const allowBuilds: Record<string, boolean> = {};
if (pnpmOptions.globalOnlyBuiltDependencies) {
for (const pkg of pnpmOptions.globalOnlyBuiltDependencies) {
allowBuilds[pkg] = true;
}
}
if (pnpmOptions.globalNeverBuiltDependencies) {
for (const pkg of pnpmOptions.globalNeverBuiltDependencies) {
allowBuilds[pkg] = false;
}
}
workspaceFile.setAllowBuilds(allowBuilds);
}
} else if (
pnpmOptions.globalAllowBuilds &&
this.rushConfiguration.rushConfigurationJson.pnpmVersion !== undefined
) {
this._terminal.writeWarningLine(
Colorize.yellow(
`Your version of pnpm (${this.rushConfiguration.rushConfigurationJson.pnpmVersion}) ` +
`doesn't support the "globalAllowBuilds" field in ` +
`${this.rushConfiguration.commonRushConfigFolder}/${RushConstants.pnpmConfigFilename}. ` +
'Remove this field or upgrade to pnpm 11.0.0 or newer.'
)
);
}

// Save the generated workspace file. Don't update the file timestamp unless the content has changed,
// since "rush install" will consider this timestamp
workspaceFile.save(workspaceFile.workspaceFilename, { onlyIfChanged: true });
Expand Down
31 changes: 30 additions & 1 deletion libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ export interface IPnpmOptionsJson extends IPackageManagerOptionsJsonBase {
* {@inheritDoc PnpmOptionsConfiguration.globalOnlyBuiltDependencies}
*/
globalOnlyBuiltDependencies?: string[];
/**
* {@inheritDoc PnpmOptionsConfiguration.globalAllowBuilds}
*/
globalAllowBuilds?: Record<string, boolean>;
/**
* {@inheritDoc PnpmOptionsConfiguration.globalIgnoredOptionalDependencies}
*/
Expand Down Expand Up @@ -446,12 +450,26 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration
* The settings are copied into the `pnpm.onlyBuiltDependencies` field of the `common/temp/package.json`
* file that is generated by Rush during installation.
*
* (SUPPORTED ONLY IN PNPM 10.1.0 AND NEWER)
* (SUPPORTED ONLY IN PNPM 10.1.0 AND NEWER; replaced by `globalAllowBuilds` in PNPM 11.0.0)
*
* PNPM documentation: https://pnpm.io/package_json#pnpmonlybuiltdependencies
*/
public readonly globalOnlyBuiltDependencies: string[] | undefined;

/**
* The `globalAllowBuilds` setting controls which packages are allowed to run build scripts
* (`preinstall`, `install`, and `postinstall` lifecycle events). A value of `true` means the
* package is allowed to run build scripts; `false` means it is explicitly denied.
* Packages with build scripts not listed here will cause pnpm to fail with ERR_PNPM_IGNORED_BUILDS.
* The settings are written to the `allowBuilds` field of the `pnpm-workspace.yaml` file
* that is generated by Rush during installation.
*
* (SUPPORTED ONLY IN PNPM 11.0.0 AND NEWER)
*
* PNPM documentation: https://pnpm.io/settings#allowbuilds
*/
public readonly globalAllowBuilds: Record<string, boolean> | undefined;

/**
* The ignoredOptionalDependencies setting allows you to exclude certain optional dependencies from being installed
* during the Rush installation process. This can be useful when optional dependencies are not required or are
Expand Down Expand Up @@ -556,6 +574,7 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration
this.globalPackageExtensions = json.globalPackageExtensions;
this.globalNeverBuiltDependencies = json.globalNeverBuiltDependencies;
this.globalOnlyBuiltDependencies = json.globalOnlyBuiltDependencies;
this.globalAllowBuilds = json.globalAllowBuilds;
this.globalIgnoredOptionalDependencies = json.globalIgnoredOptionalDependencies;
this.globalAllowedDeprecatedVersions = json.globalAllowedDeprecatedVersions;
this.unsupportedPackageJsonSettings = json.unsupportedPackageJsonSettings;
Expand Down Expand Up @@ -629,4 +648,14 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration
JsonFile.save(this._json, this.jsonFilename, { updateExistingFile: true });
}
}

/**
* Updates globalAllowBuilds field of the PNPM options in the common/config/rush/pnpm-config.json file.
*/
public updateGlobalAllowBuilds(allowBuilds: Record<string, boolean> | undefined): void {
this._json.globalAllowBuilds = allowBuilds;
if (this.jsonFilename) {
JsonFile.save(this._json, this.jsonFilename, { updateExistingFile: true });
}
}
}
26 changes: 26 additions & 0 deletions libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ const yamlModule: typeof import('js-yaml') = Import.lazy('js-yaml', require);
* "default": {
* "react": "^18.0.0"
* }
* },
* "allowBuilds": {
* "esbuild": true,
* "fsevents": false
* }
* }
*/
Expand All @@ -31,6 +35,13 @@ interface IPnpmWorkspaceYaml {
packages: string[];
/** Catalog definitions for centralized version management */
catalogs?: Record<string, Record<string, string>>;
/**
* Controls which packages are allowed to run build scripts. A value of `true` means the
* package is allowed to run build scripts; `false` means it is explicitly denied.
* Packages with build scripts not listed here will cause pnpm to fail with ERR_PNPM_IGNORED_BUILDS.
* (SUPPORTED ONLY IN PNPM 11.0.0 AND NEWER)
*/
allowBuilds?: Record<string, boolean>;
}

export class PnpmWorkspaceFile extends BaseWorkspaceFile {
Expand All @@ -41,6 +52,7 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile {

private _workspacePackages: Set<string>;
private _catalogs: Record<string, Record<string, string>> | undefined;
private _allowBuilds: Record<string, boolean> | undefined;

/**
* The PNPM workspace file is used to specify the location of workspaces relative to the root
Expand All @@ -54,6 +66,7 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile {
// If we need to support manual customization, that should be an additional parameter for "base file"
this._workspacePackages = new Set<string>();
this._catalogs = undefined;
this._allowBuilds = undefined;
}

/**
Expand All @@ -64,6 +77,15 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile {
this._catalogs = catalogs;
}

/**
* Sets the allowBuilds definitions for the workspace.
* This controls which packages are allowed to run build scripts in pnpm 11+.
* @param allowBuilds - A map of package name to boolean (true = allowed, false = denied)
*/
public setAllowBuilds(allowBuilds: Record<string, boolean> | undefined): void {
this._allowBuilds = allowBuilds;
}

/** @override */
public addPackage(packagePath: string): void {
// Ensure the path is relative to the pnpm-workspace.yaml file
Expand All @@ -89,6 +111,10 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile {
workspaceYaml.catalogs = this._catalogs;
}

if (this._allowBuilds && Object.keys(this._allowBuilds).length > 0) {
workspaceYaml.allowBuilds = this._allowBuilds;
}

return yamlModule.dump(workspaceYaml, PNPM_SHRINKWRAP_YAML_FORMAT);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,19 @@ describe(PnpmOptionsConfiguration.name, () => {
]);
});

it('loads allowBuilds', () => {
const pnpmConfiguration: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow(
`${__dirname}/jsonFiles/pnpm-config-allowBuilds.json`,
fakeCommonTempFolder
);

expect(TestUtilities.stripAnnotations(pnpmConfiguration.globalAllowBuilds)).toEqual({
esbuild: true,
'@parcel/watcher': true,
fsevents: false
});
});

it('loads minimumReleaseAgeMinutes', () => {
const pnpmConfiguration: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow(
`${__dirname}/jsonFiles/pnpm-config-minimumReleaseAge.json`,
Expand Down
Loading