Skip to content

Commit e8c049d

Browse files
committed
feat(rush-lib): support granular Node.js version matching in dependsOnNodeVersion
Extend dependsOnNodeVersion from a simple boolean to also accept 'major', 'minor', or 'patch' string values (with true as an alias for 'patch'). - 'major': hash includes only major version (e.g. 18) - 'minor': hash includes major.minor (e.g. 18.17) - 'patch'/true: hash includes full version (e.g. 18.17.1) Extract NodeVersionGranularity type alias for reuse. Pre-compute version strings at all granularity levels once per InputsSnapshot construction.
1 parent e41eb39 commit e8c049d

8 files changed

Lines changed: 212 additions & 17 deletions

File tree

common/changes/@microsoft/rush-lib/rush-project-dependsOnNodeVersion_2026-02-17-00-00.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"changes": [
33
{
44
"packageName": "@microsoft/rush",
5-
"comment": "Add a new `dependsOnNodeVersion` setting for operation entries in rush-project.json. When set to `true`, the Node.js version is included in the build cache hash, ensuring that cached outputs are invalidated when the Node.js version changes.",
5+
"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.",
66
"type": "none"
77
}
88
],

common/reviews/api/rush-lib.api.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -669,7 +669,7 @@ export interface IOperationSettings {
669669
allowCobuildWithoutCache?: boolean;
670670
dependsOnAdditionalFiles?: string[];
671671
dependsOnEnvVars?: string[];
672-
dependsOnNodeVersion?: boolean;
672+
dependsOnNodeVersion?: boolean | NodeVersionGranularity;
673673
disableBuildCacheForOperation?: boolean;
674674
ignoreChangedProjectsOnlyFlag?: boolean;
675675
operationName: string;
@@ -962,6 +962,9 @@ export class LockStepVersionPolicy extends VersionPolicy {
962962

963963
export { LookupByPath }
964964

965+
// @alpha
966+
export type NodeVersionGranularity = 'major' | 'minor' | 'patch';
967+
965968
// @public
966969
export class NpmOptionsConfiguration extends PackageManagerOptionsConfigurationBase {
967970
// @internal

libraries/rush-lib/src/api/RushProjectConfiguration.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,17 @@ export interface IRushPhaseSharding {
7272
shardOperationSettings?: unknown;
7373
}
7474

75+
/**
76+
* The granularity at which the Node.js version is included in the build cache hash.
77+
*
78+
* - `"major"` — includes only the major version (e.g. `18`)
79+
* - `"minor"` — includes the major and minor version (e.g. `18.17`)
80+
* - `"patch"` — includes the full version (e.g. `18.17.1`)
81+
*
82+
* @alpha
83+
*/
84+
export type NodeVersionGranularity = 'major' | 'minor' | 'patch';
85+
7586
/**
7687
* @alpha
7788
*/
@@ -113,12 +124,18 @@ export interface IOperationSettings {
113124
dependsOnEnvVars?: string[];
114125

115126
/**
116-
* If set to true, the Node.js version (process.version) will be included in the hash used for the
117-
* build cache. This ensures that if the Node.js version changes, cached outputs will be invalidated
118-
* and the operation will be re-executed. This is useful for projects that produce
119-
* Node.js-version-specific outputs, such as native module builds.
127+
* Specifies whether and at what granularity the Node.js version should be included in the hash
128+
* used for the build cache. When enabled, changing the Node.js version at the specified granularity
129+
* will invalidate cached outputs and cause the operation to be re-executed. This is useful for
130+
* projects that produce Node.js-version-specific outputs, such as native module builds.
131+
*
132+
* Allowed values:
133+
* - `true` — alias for `"patch"`, includes the full version (e.g. `18.17.1`)
134+
* - `"major"` — includes only the major version (e.g. `18`)
135+
* - `"minor"` — includes the major and minor version (e.g. `18.17`)
136+
* - `"patch"` — includes the full version (e.g. `18.17.1`)
120137
*/
121-
dependsOnNodeVersion?: boolean;
138+
dependsOnNodeVersion?: boolean | NodeVersionGranularity;
122139

123140
/**
124141
* An optional list of glob (minimatch) patterns pointing to files that can affect this operation.

libraries/rush-lib/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export { RushConfigurationProject } from './api/RushConfigurationProject';
7979
export {
8080
type IRushProjectJson as _IRushProjectJson,
8181
type IOperationSettings,
82+
type NodeVersionGranularity,
8283
RushProjectConfiguration,
8384
type IRushPhaseSharding
8485
} from './api/RushProjectConfiguration';

libraries/rush-lib/src/logic/incremental/InputsSnapshot.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import { type IReadonlyLookupByPath, LookupByPath } from '@rushstack/lookup-by-p
1010
import { InternalError, Path, Sort } from '@rushstack/node-core-library';
1111

1212
import type { RushConfigurationProject } from '../../api/RushConfigurationProject';
13-
import type { IOperationSettings, RushProjectConfiguration } from '../../api/RushProjectConfiguration';
13+
import type {
14+
IOperationSettings,
15+
NodeVersionGranularity,
16+
RushProjectConfiguration
17+
} from '../../api/RushProjectConfiguration';
1418
import { RushConstants } from '../RushConstants';
1519

1620
/**
@@ -211,9 +215,9 @@ export class InputsSnapshot implements IInputsSnapshot {
211215
*/
212216
private readonly _environment: Record<string, string | undefined>;
213217
/**
214-
* The Node.js version string to use for `dependsOnNodeVersion`.
218+
* Pre-computed Node.js version strings at each granularity level for `dependsOnNodeVersion`.
215219
*/
216-
private readonly _nodeVersion: string;
220+
private readonly _nodeVersionByGranularity: Readonly<Record<NodeVersionGranularity, string>>;
217221

218222
/**
219223
*
@@ -284,8 +288,8 @@ export class InputsSnapshot implements IInputsSnapshot {
284288
this._globalAdditionalHashes = globalAdditionalHashes;
285289
// Snapshot the environment so that queries are not impacted by when they happen
286290
this._environment = environment;
287-
// Snapshot the Node.js version so that queries are not impacted by when they happen
288-
this._nodeVersion = nodeVersion;
291+
// Parse Node.js version once so it doesn't need to be re-parsed per operation
292+
this._nodeVersionByGranularity = _parseNodeVersion(nodeVersion);
289293
this.hashes = hashes;
290294
this.hasUncommittedChanges = hasUncommittedChanges;
291295
this.rootDirectory = rootDir;
@@ -402,7 +406,9 @@ export class InputsSnapshot implements IInputsSnapshot {
402406
}
403407

404408
if (dependsOnNodeVersion) {
405-
hasher.update(`${hashDelimiter}nodeVersion=${this._nodeVersion}`);
409+
const granularity: NodeVersionGranularity =
410+
dependsOnNodeVersion === true ? 'patch' : dependsOnNodeVersion;
411+
hasher.update(`${hashDelimiter}nodeVersion=${this._nodeVersionByGranularity[granularity]}`);
406412
}
407413

408414
if (outputFolderNames) {
@@ -437,6 +443,24 @@ export class InputsSnapshot implements IInputsSnapshot {
437443
}
438444
}
439445

446+
/**
447+
* Parses a Node.js version string once and returns pre-computed strings for each granularity level.
448+
*
449+
* @param rawVersion - The full Node.js version string (e.g. `v18.17.1`)
450+
* @returns An object with pre-computed version strings for `major`, `minor`, and `patch` granularities
451+
*/
452+
function _parseNodeVersion(rawVersion: string): Record<NodeVersionGranularity, string> {
453+
// Strip leading 'v' if present
454+
const version: string = rawVersion.startsWith('v') ? rawVersion.slice(1) : rawVersion;
455+
const [major, minor]: string[] = version.split('.');
456+
457+
return {
458+
major,
459+
minor: `${major}.${minor}`,
460+
patch: version
461+
};
462+
}
463+
440464
function getOrCreateProjectFilter(
441465
record: IInternalInputsSnapshotProjectMetadata
442466
): (filePath: string) => boolean {

libraries/rush-lib/src/logic/incremental/test/InputsSnapshot.test.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,153 @@ describe(InputsSnapshot.name, () => {
467467
expect(result2).not.toEqual(result1);
468468
});
469469

470+
it('Respects dependsOnNodeVersion with major granularity', () => {
471+
const { project, options } = getTestConfig();
472+
473+
const projectConfig: Pick<RushProjectConfiguration, 'operationSettingsByOperationName'> = {
474+
operationSettingsByOperationName: new Map([
475+
[
476+
'_phase:build',
477+
{
478+
operationName: '_phase:build',
479+
dependsOnNodeVersion: 'major'
480+
}
481+
]
482+
])
483+
};
484+
485+
// Same major, different minor — should produce the same hash
486+
const input1: InputsSnapshot = new InputsSnapshot({
487+
...options,
488+
projectMap: new Map([[project, { projectConfig: projectConfig as RushProjectConfiguration }]]),
489+
nodeVersion: 'v18.17.0'
490+
});
491+
492+
const input2: InputsSnapshot = new InputsSnapshot({
493+
...options,
494+
projectMap: new Map([[project, { projectConfig: projectConfig as RushProjectConfiguration }]]),
495+
nodeVersion: 'v18.20.3'
496+
});
497+
498+
const result1: string = input1.getOperationOwnStateHash(project, '_phase:build');
499+
const result2: string = input2.getOperationOwnStateHash(project, '_phase:build');
500+
501+
expect(result1).toEqual(result2);
502+
503+
// Different major — should produce a different hash
504+
const input3: InputsSnapshot = new InputsSnapshot({
505+
...options,
506+
projectMap: new Map([[project, { projectConfig: projectConfig as RushProjectConfiguration }]]),
507+
nodeVersion: 'v20.10.0'
508+
});
509+
510+
const result3: string = input3.getOperationOwnStateHash(project, '_phase:build');
511+
512+
expect(result3).not.toEqual(result1);
513+
});
514+
515+
it('Respects dependsOnNodeVersion with minor granularity', () => {
516+
const { project, options } = getTestConfig();
517+
518+
const projectConfig: Pick<RushProjectConfiguration, 'operationSettingsByOperationName'> = {
519+
operationSettingsByOperationName: new Map([
520+
[
521+
'_phase:build',
522+
{
523+
operationName: '_phase:build',
524+
dependsOnNodeVersion: 'minor'
525+
}
526+
]
527+
])
528+
};
529+
530+
// Same major.minor, different patch — should produce the same hash
531+
const input1: InputsSnapshot = new InputsSnapshot({
532+
...options,
533+
projectMap: new Map([[project, { projectConfig: projectConfig as RushProjectConfiguration }]]),
534+
nodeVersion: 'v18.17.0'
535+
});
536+
537+
const input2: InputsSnapshot = new InputsSnapshot({
538+
...options,
539+
projectMap: new Map([[project, { projectConfig: projectConfig as RushProjectConfiguration }]]),
540+
nodeVersion: 'v18.17.5'
541+
});
542+
543+
const result1: string = input1.getOperationOwnStateHash(project, '_phase:build');
544+
const result2: string = input2.getOperationOwnStateHash(project, '_phase:build');
545+
546+
expect(result1).toEqual(result2);
547+
548+
// Different minor — should produce a different hash
549+
const input3: InputsSnapshot = new InputsSnapshot({
550+
...options,
551+
projectMap: new Map([[project, { projectConfig: projectConfig as RushProjectConfiguration }]]),
552+
nodeVersion: 'v18.20.0'
553+
});
554+
555+
const result3: string = input3.getOperationOwnStateHash(project, '_phase:build');
556+
557+
expect(result3).not.toEqual(result1);
558+
});
559+
560+
it('Respects dependsOnNodeVersion with patch granularity', () => {
561+
const { project, options } = getTestConfig();
562+
563+
const projectConfig: Pick<RushProjectConfiguration, 'operationSettingsByOperationName'> = {
564+
operationSettingsByOperationName: new Map([
565+
[
566+
'_phase:build',
567+
{
568+
operationName: '_phase:build',
569+
dependsOnNodeVersion: 'patch'
570+
}
571+
]
572+
])
573+
};
574+
575+
// true and 'patch' should produce identical hashes
576+
const projectConfigTrue: Pick<RushProjectConfiguration, 'operationSettingsByOperationName'> = {
577+
operationSettingsByOperationName: new Map([
578+
[
579+
'_phase:build',
580+
{
581+
operationName: '_phase:build',
582+
dependsOnNodeVersion: true
583+
}
584+
]
585+
])
586+
};
587+
588+
const inputPatch: InputsSnapshot = new InputsSnapshot({
589+
...options,
590+
projectMap: new Map([[project, { projectConfig: projectConfig as RushProjectConfiguration }]]),
591+
nodeVersion: 'v18.17.1'
592+
});
593+
594+
const inputTrue: InputsSnapshot = new InputsSnapshot({
595+
...options,
596+
projectMap: new Map([[project, { projectConfig: projectConfigTrue as RushProjectConfiguration }]]),
597+
nodeVersion: 'v18.17.1'
598+
});
599+
600+
const resultPatch: string = inputPatch.getOperationOwnStateHash(project, '_phase:build');
601+
const resultTrue: string = inputTrue.getOperationOwnStateHash(project, '_phase:build');
602+
603+
expect(resultPatch).toEqual(resultTrue);
604+
605+
// Different patch — should produce a different hash
606+
const input2: InputsSnapshot = new InputsSnapshot({
607+
...options,
608+
projectMap: new Map([[project, { projectConfig: projectConfig as RushProjectConfiguration }]]),
609+
nodeVersion: 'v18.17.2'
610+
});
611+
612+
const result2: string = input2.getOperationOwnStateHash(project, '_phase:build');
613+
614+
expect(result2).not.toEqual(resultPatch);
615+
});
616+
470617
it('Does not include node version when dependsOnNodeVersion is not set', () => {
471618
const { project, options } = getTestConfig();
472619

libraries/rush-lib/src/logic/incremental/test/__snapshots__/InputsSnapshot.test.ts.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ exports[`InputsSnapshot getOperationOwnStateHash Respects dependsOnEnvVars 1`] =
1010

1111
exports[`InputsSnapshot getOperationOwnStateHash Respects dependsOnEnvVars 2`] = `"2c68d56fc9278b6495496070a6a992b929c37a83"`;
1212

13-
exports[`InputsSnapshot getOperationOwnStateHash Respects dependsOnNodeVersion 1`] = `"df8e519b405d7deca932c0085c1c6b48b57ae4fc"`;
13+
exports[`InputsSnapshot getOperationOwnStateHash Respects dependsOnNodeVersion 1`] = `"bdfb861c2d1106b68b604b74e13f1c3f95095df6"`;
1414

15-
exports[`InputsSnapshot getOperationOwnStateHash Respects dependsOnNodeVersion 2`] = `"8323bdc4729024e6f0e4b4378f0f3e6bef96dce2"`;
15+
exports[`InputsSnapshot getOperationOwnStateHash Respects dependsOnNodeVersion 2`] = `"cc182bde6aa81c0410ede7db22f3af2f0a24d3e3"`;
1616

1717
exports[`InputsSnapshot getOperationOwnStateHash Respects globalAdditionalFiles 1`] = `"0e0437ad1941bacd098b22da15dc673f86ca6003"`;
1818

libraries/rush-lib/src/schemas/rush-project.schema.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,11 @@
9999
}
100100
},
101101
"dependsOnNodeVersion": {
102-
"description": "If set to true, the Node.js version (process.version) will be included in the hash used for the build cache. This ensures that if the Node.js version changes, cached outputs will be invalidated and the operation will be re-executed. This is useful for projects that produce Node.js-version-specific outputs, such as native module builds.",
103-
"type": "boolean"
102+
"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').",
103+
"oneOf": [
104+
{ "type": "boolean", "enum": [true] },
105+
{ "type": "string", "enum": ["major", "minor", "patch"] }
106+
]
104107
},
105108
"weight": {
106109
"description": "The number of concurrency units that this operation should take up. The maximum concurrency units is determined by the -p flag.",

0 commit comments

Comments
 (0)