From 1193824aafe88a9f44a2614e8a6c97ee10b12c28 Mon Sep 17 00:00:00 2001 From: ATKasem <158775699+ATKasem@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:36:41 -0500 Subject: [PATCH] [rush] Fix "rush-pnpm patch-commit" writing absolute paths into pnpm-config.json When running "pnpm patch-commit"/"patch-remove" with pnpm 9+, pnpm rewrites pre-existing patchedDependencies entries using absolute paths into common/temp. Rush then copied those into pnpm-config.json, leaking the local checkout location. Normalize absolute paths under the common/temp folder back to relative paths at the point they are recorded, inside PnpmOptionsConfiguration.updateGlobalPatchedDependencies. This is the single sink for both the pnpm 11+ (pnpm-workspace.yaml) and older (package.json) sources, so both are covered. --- ...patch-absolute-paths_2026-07-14-18-30.json | 10 ++++ .../logic/pnpm/PnpmOptionsConfiguration.ts | 23 +++++++- .../test/PnpmOptionsConfiguration.test.ts | 53 +++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 common/changes/@microsoft/rush/fix-rush-pnpm-patch-absolute-paths_2026-07-14-18-30.json diff --git a/common/changes/@microsoft/rush/fix-rush-pnpm-patch-absolute-paths_2026-07-14-18-30.json b/common/changes/@microsoft/rush/fix-rush-pnpm-patch-absolute-paths_2026-07-14-18-30.json new file mode 100644 index 0000000000..8d64fdc822 --- /dev/null +++ b/common/changes/@microsoft/rush/fix-rush-pnpm-patch-absolute-paths_2026-07-14-18-30.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Fix an issue where \"rush-pnpm patch-commit\" rewrote the pre-existing \"globalPatchedDependencies\" entries in pnpm-config.json using absolute paths when running with pnpm >= 9.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush" +} diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts b/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts index dbaf9027c0..d452cd065f 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts @@ -1,7 +1,9 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { JsonFile, type JsonObject } from '@rushstack/node-core-library'; +import * as path from 'node:path'; + +import { JsonFile, type JsonObject, Path } from '@rushstack/node-core-library'; import { NonProjectConfigurationFile } from '@rushstack/heft-config-file'; import { ConsoleTerminalProvider, Terminal } from '@rushstack/terminal'; @@ -211,6 +213,7 @@ export interface IPnpmOptionsJson extends IPackageManagerOptionsJsonBase { */ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfigurationBase { private readonly _json: JsonObject; + private readonly _commonTempFolder: string; private _globalPatchedDependencies: Record | undefined; /** @@ -556,6 +559,7 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration private constructor(json: IPnpmOptionsJson, commonTempFolder: string, jsonFilename?: string) { super(json); this._json = json; + this._commonTempFolder = commonTempFolder; this.jsonFilename = jsonFilename; this.pnpmStore = json.pnpmStore || 'local'; if (EnvironmentConfiguration.pnpmStorePathOverride) { @@ -636,8 +640,25 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration /** * Updates patchedDependencies field of the PNPM options in the common/config/rush/pnpm-config.json file. + * + * @remarks + * When running "pnpm patch-commit"/"pnpm patch-remove" (pnpm 9 and newer), pnpm rewrites the + * pre-existing entries using absolute paths pointing into the common/temp folder. Normalize any such + * value back to a path relative to the common/temp folder (e.g. "patches/example\@1.0.0.patch") so the location + * of the local checkout does not leak into pnpm-config.json. */ public updateGlobalPatchedDependencies(patchedDependencies: Record | undefined): void { + if (patchedDependencies) { + const normalized: Record = {}; + for (const [dependency, patchPath] of Object.entries(patchedDependencies)) { + normalized[dependency] = + path.isAbsolute(patchPath) && Path.isUnder(patchPath, this._commonTempFolder) + ? Path.convertToSlashes(path.relative(this._commonTempFolder, patchPath)) + : patchPath; + } + patchedDependencies = normalized; + } + this._globalPatchedDependencies = patchedDependencies; this._json.globalPatchedDependencies = patchedDependencies; if (this.jsonFilename) { diff --git a/libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts b/libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts index eb546fdc35..b06c5af51f 100644 --- a/libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts +++ b/libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts @@ -169,4 +169,57 @@ describe(PnpmOptionsConfiguration.name, () => { } }); }); + + describe('updateGlobalPatchedDependencies', () => { + function update( + patchedDependencies: Record | undefined + ): Record | undefined { + // No jsonFilename, so updateGlobalPatchedDependencies won't try to write to disk + const pnpmConfiguration: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonObject( + {}, + fakeCommonTempFolder + ); + pnpmConfiguration.updateGlobalPatchedDependencies(patchedDependencies); + return pnpmConfiguration.globalPatchedDependencies; + } + + it('converts absolute patch paths under the common temp folder back to relative paths', () => { + // pnpm >= 9 "patch-commit"/"patch-remove" rewrite pre-existing "patchedDependencies" entries + // using absolute paths pointing into the common/temp folder + expect( + update({ + 'example@1.0.0': path.join(fakeCommonTempFolder, 'patches', 'example@1.0.0.patch'), + '@scope/example2@2.0.0': path.join(fakeCommonTempFolder, 'patches', '@scope__example2@2.0.0.patch') + }) + ).toEqual({ + 'example@1.0.0': 'patches/example@1.0.0.patch', + '@scope/example2@2.0.0': 'patches/@scope__example2@2.0.0.patch' + }); + }); + + it('leaves relative patch paths unchanged', () => { + expect( + update({ + 'example@1.0.0': 'patches/example@1.0.0.patch' + }) + ).toEqual({ + 'example@1.0.0': 'patches/example@1.0.0.patch' + }); + }); + + it('leaves absolute patch paths outside the common temp folder unchanged', () => { + const outsidePath: string = path.join(path.sep, 'somewhere', 'else', 'example@1.0.0.patch'); + expect( + update({ + 'example@1.0.0': outsidePath + }) + ).toEqual({ + 'example@1.0.0': outsidePath + }); + }); + + it('passes through undefined', () => { + expect(update(undefined)).toBeUndefined(); + }); + }); });