From 29d91d058a851e1d9211bc4d70d993f873d402c3 Mon Sep 17 00:00:00 2001 From: Ben Keen Date: Wed, 16 Jul 2025 09:44:13 -0700 Subject: [PATCH 1/4] Add safety check to Bridge Cache plugin write action --- ...e-cache-safety-check_2025-07-16-16-53.json | 10 ++++++++ .../rush-bridge-cache-plugin/README.md | 2 +- .../src/BridgeCachePlugin.ts | 25 ++++++++++++++++++- 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 common/changes/@microsoft/rush/benkeen-bridge-cache-safety-check_2025-07-16-16-53.json diff --git a/common/changes/@microsoft/rush/benkeen-bridge-cache-safety-check_2025-07-16-16-53.json b/common/changes/@microsoft/rush/benkeen-bridge-cache-safety-check_2025-07-16-16-53.json new file mode 100644 index 00000000000..bd7ff97cb34 --- /dev/null +++ b/common/changes/@microsoft/rush/benkeen-bridge-cache-safety-check_2025-07-16-16-53.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/rush-plugins/rush-bridge-cache-plugin/README.md b/rush-plugins/rush-bridge-cache-plugin/README.md index bc2ba2f975f..0b0c4a2b771 100644 --- a/rush-plugins/rush-bridge-cache-plugin/README.md +++ b/rush-plugins/rush-bridge-cache-plugin/README.md @@ -8,7 +8,7 @@ Alternatively, the `--bridge-cache-action=read` parameter is useful for tasks su ## Here be dragons! -The `write` action for plugin assumes that the work for a particular task has already been completed and the build artifacts have been generated on disk. **If you run this command on a package where the command hasn't already been run and the build artifacts are missing or incorrect, you will cache invalid content**. Be careful and beware! +The `write` action for plugin assumes that the work for a particular task has already been completed and the build artifacts have been generated on disk. **If you run this command on a package where the command hasn't already been run and the build artifacts are missing or incorrect, you will cache invalid content**. Be careful and beware! If any defined output folders for a task are not on disk, the write action will skip that package. The `read` action for this plugin makes no guarantee that the requested operations will have their outputs restored and is purely a best-effort. diff --git a/rush-plugins/rush-bridge-cache-plugin/src/BridgeCachePlugin.ts b/rush-plugins/rush-bridge-cache-plugin/src/BridgeCachePlugin.ts index 3b2ce0bf559..50d568865fb 100644 --- a/rush-plugins/rush-bridge-cache-plugin/src/BridgeCachePlugin.ts +++ b/rush-plugins/rush-bridge-cache-plugin/src/BridgeCachePlugin.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { Async } from '@rushstack/node-core-library'; +import { Async, FileSystem } from '@rushstack/node-core-library'; import { _OperationBuildCache as OperationBuildCache } from '@rushstack/rush-sdk'; import type { ICreateOperationsContext, @@ -123,6 +123,29 @@ export class BridgeCachePlugin implements IRushPlugin { ); } } else if (cacheAction === CACHE_ACTION_WRITE) { + // skip this action if any of the defined output folders do not exist on disk + if ( + operation.settings?.outputFolderNames && + operation.settings?.outputFolderNames?.length > 0 + ) { + const projectFolder: string = operation.associatedProject?.projectFolder; + const results: { outputFolderName: string; exists: boolean }[] = + operation.settings.outputFolderNames.map((outputFolderName: string) => ({ + outputFolderName, + exists: FileSystem.exists(`${projectFolder}/${outputFolderName}`) + })); + + if (results.some((folder) => !folder.exists)) { + const missingFolders: string[] = results + .filter((folder) => !folder.exists) + .map((folder) => folder.outputFolderName); + terminal.writeErrorLine( + `Operation "${operation.name}": The following output folders do not exist: "${missingFolders.join('", "')}". Skipping cache population.` + ); + return; + } + } + const success: boolean = await projectBuildCache.trySetCacheEntryAsync(terminal); if (success) { ++successCount; From 64bc197d88f2b8bc0dfa7bb67dcd9f572546d33b Mon Sep 17 00:00:00 2001 From: Ben Keen Date: Wed, 23 Jul 2025 08:45:22 -0700 Subject: [PATCH 2/4] Make safety check an optional flag; code review feedback --- ...e-cache-safety-check_2025-07-16-16-53.json | 2 +- .../rush-bridge-cache-plugin/README.md | 28 ++++++++++----- .../src/BridgeCachePlugin.ts | 34 ++++++++++++++----- .../schemas/bridge-cache-config.schema.json | 4 +++ 4 files changed, 50 insertions(+), 18 deletions(-) diff --git a/common/changes/@microsoft/rush/benkeen-bridge-cache-safety-check_2025-07-16-16-53.json b/common/changes/@microsoft/rush/benkeen-bridge-cache-safety-check_2025-07-16-16-53.json index bd7ff97cb34..08baea98f88 100644 --- a/common/changes/@microsoft/rush/benkeen-bridge-cache-safety-check_2025-07-16-16-53.json +++ b/common/changes/@microsoft/rush/benkeen-bridge-cache-safety-check_2025-07-16-16-53.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@microsoft/rush", - "comment": "", + "comment": "Adds an optional safety check flag to the Bridge Cache plugin write action.", "type": "none" } ], diff --git a/rush-plugins/rush-bridge-cache-plugin/README.md b/rush-plugins/rush-bridge-cache-plugin/README.md index 0b0c4a2b771..8643f2db143 100644 --- a/rush-plugins/rush-bridge-cache-plugin/README.md +++ b/rush-plugins/rush-bridge-cache-plugin/README.md @@ -8,7 +8,7 @@ Alternatively, the `--bridge-cache-action=read` parameter is useful for tasks su ## Here be dragons! -The `write` action for plugin assumes that the work for a particular task has already been completed and the build artifacts have been generated on disk. **If you run this command on a package where the command hasn't already been run and the build artifacts are missing or incorrect, you will cache invalid content**. Be careful and beware! If any defined output folders for a task are not on disk, the write action will skip that package. +The `write` action for this plugin assumes that the work for a particular task has already been completed and the build artifacts have been generated on disk. **If you run this command on a package where the command hasn't already been run and the build artifacts are missing or incorrect, you will cache invalid content**. Be careful and beware! See the optional `requireOutputFoldersParameterName` setting below to include a safety check to require all expected output folders for a command to actually be on disk. The `read` action for this plugin makes no guarantee that the requested operations will have their outputs restored and is purely a best-effort. @@ -34,6 +34,14 @@ The `read` action for this plugin makes no guarantee that the requested operatio "description": "When specified for any associated command, bypass running the command itself, and cache whatever outputs exist in the output folders as-is. Beware! Only run when you know the build artifacts are in a valid state for the command." } ] +}, + +// optional +{ + "associatedCommands": ["build", "test", "lint", "a11y", "typecheck"], + "description": "Optional flag that can be used in combination with --bridge-cache-action=write. When used, this will only populate a cache entry when all defined output folders for a command are present on disk.", + "parameterKind": "flag", + "longName": "--require-output-folders", } ``` @@ -47,15 +55,20 @@ The `read` action for this plugin makes no guarantee that the requested operatio ``` 4. Create a configuration file for this plugin at this location: `common/config/rush-plugins/rush-bridge-cache-plugin.json` that defines the flag name you'll use to trigger the plugin: + ```json { - "actionParameterName": "--bridge-cache-action" + "actionParameterName": "--bridge-cache-action", + + // optional + "requireOutputFoldersParameterName": "--require-output-folders" } ``` + ## Usage -You can now use the parameter to have any Rush phased command either *only* restore from the cache (without any local building), or *only* write the cache, assuming all current output files are correct. +You can now use this plugin to have any Rush phased command either *only* restore from the cache (without any local building), or *only* write the cache, assuming all current output files are correct. **Replay the cache entries for this command as best-effort, but don't execute any build processes** `rush build --to your-packageX --bridge-cache-action=read` @@ -65,9 +78,6 @@ That will populate the cache for `your-packageX` and all of its dependencies. `rush build --to your-packageX --bridge-cache-action=write` That will populate the cache for `your-packageX` and all of its dependencies. - -## Performance - -When running within a pipeline, you may want to populate the cache as quickly as possible so local Rush users will benefit from the cached entry sooner. So instead of waiting until the full build graph has been processed, running it after each individual task when it's been completed, e.g. - -`rush lint --only your-packageY --set-cache-only` +**Write whatever outputs are on disk for this command to the cache, but only if all output folders are present** +`rush build --to your-packageX --bridge-cache-action=write --require-output-folders` +That will populate the cache for `your-packageX` and all of its dependencies, skipping any that don't have all output folders present. diff --git a/rush-plugins/rush-bridge-cache-plugin/src/BridgeCachePlugin.ts b/rush-plugins/rush-bridge-cache-plugin/src/BridgeCachePlugin.ts index 50d568865fb..981172f4dfe 100644 --- a/rush-plugins/rush-bridge-cache-plugin/src/BridgeCachePlugin.ts +++ b/rush-plugins/rush-bridge-cache-plugin/src/BridgeCachePlugin.ts @@ -14,7 +14,7 @@ import type { RushSession } from '@rushstack/rush-sdk'; import { CommandLineParameterKind } from '@rushstack/ts-command-line'; -import type { CommandLineParameter } from '@rushstack/ts-command-line'; +import type { CommandLineFlagParameter, CommandLineParameter } from '@rushstack/ts-command-line'; const PLUGIN_NAME: 'RushBridgeCachePlugin' = 'RushBridgeCachePlugin'; @@ -25,14 +25,17 @@ type CacheAction = typeof CACHE_ACTION_READ | typeof CACHE_ACTION_WRITE; export interface IBridgeCachePluginOptions { readonly actionParameterName: string; + readonly requireOutputFoldersParameterName: string | undefined; } export class BridgeCachePlugin implements IRushPlugin { public readonly pluginName: string = PLUGIN_NAME; private readonly _actionParameterName: string; + private readonly _requireOutputFoldersParameterName: string | undefined; public constructor(options: IBridgeCachePluginOptions) { this._actionParameterName = options.actionParameterName; + this._requireOutputFoldersParameterName = options.requireOutputFoldersParameterName; if (!this._actionParameterName) { throw new Error( @@ -46,6 +49,7 @@ export class BridgeCachePlugin implements IRushPlugin { const logger: ILogger = session.getLogger(PLUGIN_NAME); let cacheAction: CacheAction | undefined; + let requireOutputFolders: boolean = false; // cancel the actual operations. We don't want to run the command, just cache the output folders on disk command.hooks.createOperations.tap( @@ -63,12 +67,13 @@ export class BridgeCachePlugin implements IRushPlugin { for (const operation of operations) { operation.enabled = false; } + + requireOutputFolders = this._isRequireOutputFoldersFlagSet(context); } return operations; } ); - // populate the cache for each operation command.hooks.beforeExecuteOperations.tap( PLUGIN_NAME, @@ -123,8 +128,9 @@ export class BridgeCachePlugin implements IRushPlugin { ); } } else if (cacheAction === CACHE_ACTION_WRITE) { - // skip this action if any of the defined output folders do not exist on disk + // if the require output folders flag has been passed, skip populating the cache if any of the expected output folders does not exist if ( + requireOutputFolders && operation.settings?.outputFolderNames && operation.settings?.outputFolderNames?.length > 0 ) { @@ -135,11 +141,11 @@ export class BridgeCachePlugin implements IRushPlugin { exists: FileSystem.exists(`${projectFolder}/${outputFolderName}`) })); - if (results.some((folder) => !folder.exists)) { - const missingFolders: string[] = results - .filter((folder) => !folder.exists) - .map((folder) => folder.outputFolderName); - terminal.writeErrorLine( + const missingFolders: string[] = results + .filter((folder) => !folder.exists) + .map((folder) => folder.outputFolderName); + if (missingFolders.length > 0) { + terminal.writeWarningLine( `Operation "${operation.name}": The following output folders do not exist: "${missingFolders.join('", "')}". Skipping cache population.` ); return; @@ -209,4 +215,16 @@ export class BridgeCachePlugin implements IRushPlugin { return undefined; } + + private _isRequireOutputFoldersFlagSet(context: IExecuteOperationsContext): boolean { + if (!this._requireOutputFoldersParameterName) { + return false; + } + + const requireOutputFoldersParam: CommandLineParameter | undefined = context.customParameters.get( + this._requireOutputFoldersParameterName + ); + + return !!(requireOutputFoldersParam && (requireOutputFoldersParam as CommandLineFlagParameter).value); + } } diff --git a/rush-plugins/rush-bridge-cache-plugin/src/schemas/bridge-cache-config.schema.json b/rush-plugins/rush-bridge-cache-plugin/src/schemas/bridge-cache-config.schema.json index 695adc8de13..c0361a879d2 100644 --- a/rush-plugins/rush-bridge-cache-plugin/src/schemas/bridge-cache-config.schema.json +++ b/rush-plugins/rush-bridge-cache-plugin/src/schemas/bridge-cache-config.schema.json @@ -10,6 +10,10 @@ "actionParameterName": { "type": "string", "description": "(Required) The name of the choice parameter used to trigger this plugin on your phased commands. It should accept two values, 'read' and 'write'." + }, + "requireOutputFoldersParameterName": { + "type": "string", + "description": "(Optional) The name of the parameter used to specify whether the output folders must exist for the action in order to populate the cache." } } } From f5c571333e15d3ffd0050e4fc0bf4c5b98657632 Mon Sep 17 00:00:00 2001 From: Ben Keen Date: Wed, 23 Jul 2025 11:40:23 -0700 Subject: [PATCH 3/4] Code review feedback: check param type --- .../src/BridgeCachePlugin.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/rush-plugins/rush-bridge-cache-plugin/src/BridgeCachePlugin.ts b/rush-plugins/rush-bridge-cache-plugin/src/BridgeCachePlugin.ts index 981172f4dfe..1b7bba4f3ab 100644 --- a/rush-plugins/rush-bridge-cache-plugin/src/BridgeCachePlugin.ts +++ b/rush-plugins/rush-bridge-cache-plugin/src/BridgeCachePlugin.ts @@ -14,7 +14,7 @@ import type { RushSession } from '@rushstack/rush-sdk'; import { CommandLineParameterKind } from '@rushstack/ts-command-line'; -import type { CommandLineFlagParameter, CommandLineParameter } from '@rushstack/ts-command-line'; +import type { CommandLineParameter } from '@rushstack/ts-command-line'; const PLUGIN_NAME: 'RushBridgeCachePlugin' = 'RushBridgeCachePlugin'; @@ -225,6 +225,14 @@ export class BridgeCachePlugin implements IRushPlugin { this._requireOutputFoldersParameterName ); - return !!(requireOutputFoldersParam && (requireOutputFoldersParam as CommandLineFlagParameter).value); + if (!requireOutputFoldersParam) { + return false; + } + + if (requireOutputFoldersParam.kind !== CommandLineParameterKind.Flag) { + throw new Error(`The parameter "${this._requireOutputFoldersParameterName}" must be a flag.`); + } + + return requireOutputFoldersParam.value; } } From 8e2cc3355a6e60fe29441bf58084e92539a72148 Mon Sep 17 00:00:00 2001 From: Ben Keen Date: Tue, 29 Jul 2025 16:20:11 -0700 Subject: [PATCH 4/4] Update rush-plugins/rush-bridge-cache-plugin/src/BridgeCachePlugin.ts Co-authored-by: David Michon --- .../src/BridgeCachePlugin.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/rush-plugins/rush-bridge-cache-plugin/src/BridgeCachePlugin.ts b/rush-plugins/rush-bridge-cache-plugin/src/BridgeCachePlugin.ts index 1b7bba4f3ab..0f22d3d85b0 100644 --- a/rush-plugins/rush-bridge-cache-plugin/src/BridgeCachePlugin.ts +++ b/rush-plugins/rush-bridge-cache-plugin/src/BridgeCachePlugin.ts @@ -135,15 +135,12 @@ export class BridgeCachePlugin implements IRushPlugin { operation.settings?.outputFolderNames?.length > 0 ) { const projectFolder: string = operation.associatedProject?.projectFolder; - const results: { outputFolderName: string; exists: boolean }[] = - operation.settings.outputFolderNames.map((outputFolderName: string) => ({ - outputFolderName, - exists: FileSystem.exists(`${projectFolder}/${outputFolderName}`) - })); - - const missingFolders: string[] = results - .filter((folder) => !folder.exists) - .map((folder) => folder.outputFolderName); + const missingFolders: string[] = []; + operation.settings.outputFolderNames.forEach((outputFolderName: string) => { + if (!FileSystem.exists(`${projectFolder}/${outputFolderName}`)) { + missingFolders.push(outputFolderName); + } + }); if (missingFolders.length > 0) { terminal.writeWarningLine( `Operation "${operation.name}": The following output folders do not exist: "${missingFolders.join('", "')}". Skipping cache population.`