diff --git a/common/changes/@microsoft/rush-lib/rush-project-dependsOnNodeVersion_2026-02-17-00-00.json b/common/changes/@microsoft/rush-lib/rush-project-dependsOnNodeVersion_2026-02-17-00-00.json new file mode 100644 index 00000000000..b18941f9ea4 --- /dev/null +++ b/common/changes/@microsoft/rush-lib/rush-project-dependsOnNodeVersion_2026-02-17-00-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Add a new `dependsOnNodeVersion` setting for operation entries in rush-project.json. When enabled, the Node.js version is included in the build cache hash, ensuring that cached outputs are invalidated when the Node.js version changes. Accepts `true` (alias for `\"patch\"`), `\"major\"`, `\"minor\"`, or `\"patch\"` to control the granularity of version matching.", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 6254bc8cabf..1f8def84f93 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -669,6 +669,7 @@ export interface IOperationSettings { allowCobuildWithoutCache?: boolean; dependsOnAdditionalFiles?: string[]; dependsOnEnvVars?: string[]; + dependsOnNodeVersion?: boolean | NodeVersionGranularity; disableBuildCacheForOperation?: boolean; ignoreChangedProjectsOnlyFlag?: boolean; operationName: string; @@ -961,6 +962,9 @@ export class LockStepVersionPolicy extends VersionPolicy { export { LookupByPath } +// @alpha +export type NodeVersionGranularity = 'major' | 'minor' | 'patch'; + // @public export class NpmOptionsConfiguration extends PackageManagerOptionsConfigurationBase { // @internal diff --git a/libraries/rush-lib/src/api/RushProjectConfiguration.ts b/libraries/rush-lib/src/api/RushProjectConfiguration.ts index 7dd28c3c789..528680feb50 100644 --- a/libraries/rush-lib/src/api/RushProjectConfiguration.ts +++ b/libraries/rush-lib/src/api/RushProjectConfiguration.ts @@ -72,6 +72,17 @@ export interface IRushPhaseSharding { shardOperationSettings?: unknown; } +/** + * The granularity at which the Node.js version is included in the build cache hash. + * + * - `"major"` — includes only the major version (e.g. `18`) + * - `"minor"` — includes the major and minor version (e.g. `18.17`) + * - `"patch"` — includes the full version (e.g. `18.17.1`) + * + * @alpha + */ +export type NodeVersionGranularity = 'major' | 'minor' | 'patch'; + /** * @alpha */ @@ -112,6 +123,20 @@ export interface IOperationSettings { */ dependsOnEnvVars?: string[]; + /** + * Specifies whether and at what granularity the Node.js version should be included in the hash + * used for the build cache. When enabled, changing the Node.js version at the specified granularity + * will invalidate cached outputs and cause the operation to be re-executed. This is useful for + * projects that produce Node.js-version-specific outputs, such as native module builds. + * + * Allowed values: + * - `true` — alias for `"patch"`, includes the full version (e.g. `18.17.1`) + * - `"major"` — includes only the major version (e.g. `18`) + * - `"minor"` — includes the major and minor version (e.g. `18.17`) + * - `"patch"` — includes the full version (e.g. `18.17.1`) + */ + dependsOnNodeVersion?: boolean | NodeVersionGranularity; + /** * An optional list of glob (minimatch) patterns pointing to files that can affect this operation. * The hash values of the contents of these files will become part of the final hash when reading diff --git a/libraries/rush-lib/src/index.ts b/libraries/rush-lib/src/index.ts index 88dfb89789e..a91cea23a98 100644 --- a/libraries/rush-lib/src/index.ts +++ b/libraries/rush-lib/src/index.ts @@ -79,6 +79,7 @@ export { RushConfigurationProject } from './api/RushConfigurationProject'; export { type IRushProjectJson as _IRushProjectJson, type IOperationSettings, + type NodeVersionGranularity, RushProjectConfiguration, type IRushPhaseSharding } from './api/RushProjectConfiguration'; diff --git a/libraries/rush-lib/src/logic/incremental/InputsSnapshot.ts b/libraries/rush-lib/src/logic/incremental/InputsSnapshot.ts index 8eeaa5ed224..181a602ab13 100644 --- a/libraries/rush-lib/src/logic/incremental/InputsSnapshot.ts +++ b/libraries/rush-lib/src/logic/incremental/InputsSnapshot.ts @@ -10,7 +10,11 @@ import { type IReadonlyLookupByPath, LookupByPath } from '@rushstack/lookup-by-p import { InternalError, Path, Sort } from '@rushstack/node-core-library'; import type { RushConfigurationProject } from '../../api/RushConfigurationProject'; -import type { IOperationSettings, RushProjectConfiguration } from '../../api/RushProjectConfiguration'; +import type { + IOperationSettings, + NodeVersionGranularity, + RushProjectConfiguration +} from '../../api/RushProjectConfiguration'; import { RushConstants } from '../RushConstants'; /** @@ -90,6 +94,11 @@ export interface IInputsSnapshotParameters { * @defaultValue \{ ...process.env \} */ environment?: Record; + /** + * The Node.js version string to use for `dependsOnNodeVersion`. Defaults to `process.version`. + * @defaultValue process.version + */ + nodeVersion?: string; /** * File paths (keys into additionalHashes or hashes) to be included as part of every operation's dependencies. */ @@ -205,6 +214,10 @@ export class InputsSnapshot implements IInputsSnapshot { * The environment to use for `dependsOnEnvVars`. */ private readonly _environment: Record; + /** + * Pre-computed Node.js version strings at each granularity level for `dependsOnNodeVersion`. + */ + private readonly _nodeVersionByGranularity: Readonly>; /** * @@ -219,6 +232,7 @@ export class InputsSnapshot implements IInputsSnapshot { hashes, hasUncommittedChanges, lookupByPath, + nodeVersion = process.version, rootDir } = params; const projectMetadataMap: Map< @@ -274,6 +288,8 @@ export class InputsSnapshot implements IInputsSnapshot { this._globalAdditionalHashes = globalAdditionalHashes; // Snapshot the environment so that queries are not impacted by when they happen this._environment = environment; + // Parse Node.js version once so it doesn't need to be re-parsed per operation + this._nodeVersionByGranularity = _parseNodeVersion(nodeVersion); this.hashes = hashes; this.hasUncommittedChanges = hasUncommittedChanges; this.rootDirectory = rootDir; @@ -380,7 +396,7 @@ export class InputsSnapshot implements IInputsSnapshot { const operationSettings: Readonly | undefined = record.projectConfig?.operationSettingsByOperationName.get(operationName); if (operationSettings) { - const { dependsOnEnvVars, outputFolderNames } = operationSettings; + const { dependsOnEnvVars, dependsOnNodeVersion, outputFolderNames } = operationSettings; if (dependsOnEnvVars) { // As long as we enumerate environment variables in a consistent order, we will get a stable hash. // Changing the order in rush-project.json will change the hash anyway since the file contents are part of the hash. @@ -389,6 +405,12 @@ export class InputsSnapshot implements IInputsSnapshot { } } + if (dependsOnNodeVersion) { + const granularity: NodeVersionGranularity = + dependsOnNodeVersion === true ? 'patch' : dependsOnNodeVersion; + hasher.update(`${hashDelimiter}nodeVersion=${this._nodeVersionByGranularity[granularity]}`); + } + if (outputFolderNames) { hasher.update(`${hashDelimiter}${JSON.stringify(outputFolderNames)}`); } @@ -421,6 +443,24 @@ export class InputsSnapshot implements IInputsSnapshot { } } +/** + * Parses a Node.js version string once and returns pre-computed strings for each granularity level. + * + * @param rawVersion - The full Node.js version string (e.g. `v18.17.1`) + * @returns An object with pre-computed version strings for `major`, `minor`, and `patch` granularities + */ +function _parseNodeVersion(rawVersion: string): Record { + // Strip leading 'v' if present + const version: string = rawVersion.startsWith('v') ? rawVersion.slice(1) : rawVersion; + const [major, minor]: string[] = version.split('.'); + + return { + major, + minor: `${major}.${minor}`, + patch: version + }; +} + function getOrCreateProjectFilter( record: IInternalInputsSnapshotProjectMetadata ): (filePath: string) => boolean { diff --git a/libraries/rush-lib/src/logic/incremental/test/InputsSnapshot.test.ts b/libraries/rush-lib/src/logic/incremental/test/InputsSnapshot.test.ts index 867a1f446f9..9b18c99efaf 100644 --- a/libraries/rush-lib/src/logic/incremental/test/InputsSnapshot.test.ts +++ b/libraries/rush-lib/src/logic/incremental/test/InputsSnapshot.test.ts @@ -413,6 +413,253 @@ describe(InputsSnapshot.name, () => { expect(result2).not.toEqual(result1); }); + it('Respects dependsOnNodeVersion', () => { + const { project, options } = getTestConfig(); + const baseline: string = new InputsSnapshot(options).getOperationOwnStateHash(project, '_phase:build'); + + const projectConfig1: Pick = { + operationSettingsByOperationName: new Map([ + [ + '_phase:build', + { + operationName: '_phase:build', + dependsOnNodeVersion: true + } + ] + ]) + }; + + const input1: InputsSnapshot = new InputsSnapshot({ + ...options, + projectMap: new Map([ + [ + project, + { + projectConfig: projectConfig1 as RushProjectConfiguration + } + ] + ]), + nodeVersion: 'v18.17.0' + }); + + const result1: string = input1.getOperationOwnStateHash(project, '_phase:build'); + + expect(result1).toMatchSnapshot(); + expect(result1).not.toEqual(baseline); + + const input2: InputsSnapshot = new InputsSnapshot({ + ...options, + projectMap: new Map([ + [ + project, + { + projectConfig: projectConfig1 as RushProjectConfiguration + } + ] + ]), + nodeVersion: 'v20.10.0' + }); + + const result2: string = input2.getOperationOwnStateHash(project, '_phase:build'); + + expect(result2).toMatchSnapshot(); + expect(result2).not.toEqual(baseline); + expect(result2).not.toEqual(result1); + }); + + it('Respects dependsOnNodeVersion with major granularity', () => { + const { project, options } = getTestConfig(); + + const projectConfig: Pick = { + operationSettingsByOperationName: new Map([ + [ + '_phase:build', + { + operationName: '_phase:build', + dependsOnNodeVersion: 'major' + } + ] + ]) + }; + + // Same major, different minor — should produce the same hash + const input1: InputsSnapshot = new InputsSnapshot({ + ...options, + projectMap: new Map([[project, { projectConfig: projectConfig as RushProjectConfiguration }]]), + nodeVersion: 'v18.17.0' + }); + + const input2: InputsSnapshot = new InputsSnapshot({ + ...options, + projectMap: new Map([[project, { projectConfig: projectConfig as RushProjectConfiguration }]]), + nodeVersion: 'v18.20.3' + }); + + const result1: string = input1.getOperationOwnStateHash(project, '_phase:build'); + const result2: string = input2.getOperationOwnStateHash(project, '_phase:build'); + + expect(result1).toEqual(result2); + + // Different major — should produce a different hash + const input3: InputsSnapshot = new InputsSnapshot({ + ...options, + projectMap: new Map([[project, { projectConfig: projectConfig as RushProjectConfiguration }]]), + nodeVersion: 'v20.10.0' + }); + + const result3: string = input3.getOperationOwnStateHash(project, '_phase:build'); + + expect(result3).not.toEqual(result1); + }); + + it('Respects dependsOnNodeVersion with minor granularity', () => { + const { project, options } = getTestConfig(); + + const projectConfig: Pick = { + operationSettingsByOperationName: new Map([ + [ + '_phase:build', + { + operationName: '_phase:build', + dependsOnNodeVersion: 'minor' + } + ] + ]) + }; + + // Same major.minor, different patch — should produce the same hash + const input1: InputsSnapshot = new InputsSnapshot({ + ...options, + projectMap: new Map([[project, { projectConfig: projectConfig as RushProjectConfiguration }]]), + nodeVersion: 'v18.17.0' + }); + + const input2: InputsSnapshot = new InputsSnapshot({ + ...options, + projectMap: new Map([[project, { projectConfig: projectConfig as RushProjectConfiguration }]]), + nodeVersion: 'v18.17.5' + }); + + const result1: string = input1.getOperationOwnStateHash(project, '_phase:build'); + const result2: string = input2.getOperationOwnStateHash(project, '_phase:build'); + + expect(result1).toEqual(result2); + + // Different minor — should produce a different hash + const input3: InputsSnapshot = new InputsSnapshot({ + ...options, + projectMap: new Map([[project, { projectConfig: projectConfig as RushProjectConfiguration }]]), + nodeVersion: 'v18.20.0' + }); + + const result3: string = input3.getOperationOwnStateHash(project, '_phase:build'); + + expect(result3).not.toEqual(result1); + }); + + it('Respects dependsOnNodeVersion with patch granularity', () => { + const { project, options } = getTestConfig(); + + const projectConfig: Pick = { + operationSettingsByOperationName: new Map([ + [ + '_phase:build', + { + operationName: '_phase:build', + dependsOnNodeVersion: 'patch' + } + ] + ]) + }; + + // true and 'patch' should produce identical hashes + const projectConfigTrue: Pick = { + operationSettingsByOperationName: new Map([ + [ + '_phase:build', + { + operationName: '_phase:build', + dependsOnNodeVersion: true + } + ] + ]) + }; + + const inputPatch: InputsSnapshot = new InputsSnapshot({ + ...options, + projectMap: new Map([[project, { projectConfig: projectConfig as RushProjectConfiguration }]]), + nodeVersion: 'v18.17.1' + }); + + const inputTrue: InputsSnapshot = new InputsSnapshot({ + ...options, + projectMap: new Map([[project, { projectConfig: projectConfigTrue as RushProjectConfiguration }]]), + nodeVersion: 'v18.17.1' + }); + + const resultPatch: string = inputPatch.getOperationOwnStateHash(project, '_phase:build'); + const resultTrue: string = inputTrue.getOperationOwnStateHash(project, '_phase:build'); + + expect(resultPatch).toEqual(resultTrue); + + // Different patch — should produce a different hash + const input2: InputsSnapshot = new InputsSnapshot({ + ...options, + projectMap: new Map([[project, { projectConfig: projectConfig as RushProjectConfiguration }]]), + nodeVersion: 'v18.17.2' + }); + + const result2: string = input2.getOperationOwnStateHash(project, '_phase:build'); + + expect(result2).not.toEqual(resultPatch); + }); + + it('Does not include node version when dependsOnNodeVersion is not set', () => { + const { project, options } = getTestConfig(); + + const projectConfig: Pick = { + operationSettingsByOperationName: new Map([ + [ + '_phase:build', + { + operationName: '_phase:build' + } + ] + ]) + }; + + const input1: InputsSnapshot = new InputsSnapshot({ + ...options, + projectMap: new Map([ + [ + project, + { + projectConfig: projectConfig as RushProjectConfiguration + } + ] + ]), + nodeVersion: 'v18.17.0' + }); + + const input2: InputsSnapshot = new InputsSnapshot({ + ...options, + projectMap: new Map([ + [ + project, + { + projectConfig: projectConfig as RushProjectConfiguration + } + ] + ]), + nodeVersion: 'v20.10.0' + }); + + const result1: string = input1.getOperationOwnStateHash(project, '_phase:build'); + const result2: string = input2.getOperationOwnStateHash(project, '_phase:build'); + + expect(result1).toEqual(result2); + }); + it('Respects dependsOnEnvVars', () => { const { project, options } = getTestConfig(); const baseline: string = new InputsSnapshot(options).getOperationOwnStateHash(project, '_phase:build'); diff --git a/libraries/rush-lib/src/logic/incremental/test/__snapshots__/InputsSnapshot.test.ts.snap b/libraries/rush-lib/src/logic/incremental/test/__snapshots__/InputsSnapshot.test.ts.snap index a1db7fa9818..eab9d7c76b8 100644 --- a/libraries/rush-lib/src/logic/incremental/test/__snapshots__/InputsSnapshot.test.ts.snap +++ b/libraries/rush-lib/src/logic/incremental/test/__snapshots__/InputsSnapshot.test.ts.snap @@ -10,6 +10,10 @@ exports[`InputsSnapshot getOperationOwnStateHash Respects dependsOnEnvVars 1`] = exports[`InputsSnapshot getOperationOwnStateHash Respects dependsOnEnvVars 2`] = `"2c68d56fc9278b6495496070a6a992b929c37a83"`; +exports[`InputsSnapshot getOperationOwnStateHash Respects dependsOnNodeVersion 1`] = `"bdfb861c2d1106b68b604b74e13f1c3f95095df6"`; + +exports[`InputsSnapshot getOperationOwnStateHash Respects dependsOnNodeVersion 2`] = `"cc182bde6aa81c0410ede7db22f3af2f0a24d3e3"`; + exports[`InputsSnapshot getOperationOwnStateHash Respects globalAdditionalFiles 1`] = `"0e0437ad1941bacd098b22da15dc673f86ca6003"`; exports[`InputsSnapshot getOperationOwnStateHash Respects incrementalBuildIgnoredGlobs 1`] = `"f7b5af9ffdaa39831ed3374f28d0f7dccbee9c8d"`; diff --git a/libraries/rush-lib/src/schemas/rush-project.schema.json b/libraries/rush-lib/src/schemas/rush-project.schema.json index acf1b20e5ae..33fb7933403 100644 --- a/libraries/rush-lib/src/schemas/rush-project.schema.json +++ b/libraries/rush-lib/src/schemas/rush-project.schema.json @@ -98,6 +98,13 @@ } } }, + "dependsOnNodeVersion": { + "description": "Specifies whether and at what granularity the Node.js version should be included in the hash used for the build cache. When enabled, changing the Node.js version at the specified granularity will invalidate cached outputs and cause the operation to be re-executed. This is useful for projects that produce Node.js-version-specific outputs, such as native module builds. Allowed values: true (alias for 'patch'), 'major' (e.g. '18'), 'minor' (e.g. '18.17'), or 'patch' (e.g. '18.17.1').", + "oneOf": [ + { "type": "boolean", "enum": [true] }, + { "type": "string", "enum": ["major", "minor", "patch"] } + ] + }, "weight": { "description": "The number of concurrency units that this operation should take up. The maximum concurrency units is determined by the -p flag.", "type": "integer",