diff --git a/common/changes/@microsoft/rush/fix-noop-exit-code_2026-07-28-18-56-00.json b/common/changes/@microsoft/rush/fix-noop-exit-code_2026-07-28-18-56-00.json new file mode 100644 index 00000000000..a44aa1b9be0 --- /dev/null +++ b/common/changes/@microsoft/rush/fix-noop-exit-code_2026-07-28-18-56-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Fix an issue where a phased command exited with a nonzero exit code when its overall execution status was successful but not `SUCCESS`. This covers an iteration that scheduled no operations because a plugin consumed the work itself (which broke `rush --drop-graph` in `@rushstack/rush-buildxl-graph-plugin`), as well as an iteration that a plugin short-circuited with a `SKIPPED` or `FROM CACHE` status.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush" +} diff --git a/libraries/rush-lib/config/heft.json b/libraries/rush-lib/config/heft.json index 4f76fe45dcf..02a4934f2bf 100644 --- a/libraries/rush-lib/config/heft.json +++ b/libraries/rush-lib/config/heft.json @@ -27,6 +27,14 @@ "fileExtensions": [".json", ".js", ".map"], "hardlink": true }, + { + "sourcePath": "lib-intermediate-commonjs/cli/test/rush-mock-clear-operations-plugin", + "destinationFolders": [ + "lib-intermediate-commonjs/cli/test/clearOperationsAndRunBuildActionRepo/common/autoinstallers/plugins/node_modules/rush-mock-clear-operations-plugin" + ], + "fileExtensions": [".json", ".js", ".map"], + "hardlink": true + }, { "sourcePath": "src/cli/test", "destinationFolders": ["lib-intermediate-commonjs/cli/test"], diff --git a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts index 849aec4410a..7b79e36e081 100644 --- a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts +++ b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts @@ -64,6 +64,28 @@ import { measureAsyncFn, measureFn } from '../../utilities/performance'; const PERF_PREFIX: 'rush:phasedScriptAction' = 'rush:phasedScriptAction'; +/** + * The set of overall execution statuses that mean the command did what was asked of it and should + * exit with code 0. + * + * - `NoOp` -- the iteration scheduled no non-silent operations. This happens when a plugin + * legitimately consumes the work itself, either by returning an empty operation set from + * `createOperationsAsync` or by disabling every operation during `configureIteration` (a disabled + * record is silent, so both routes converge on this status). + * - `Skipped` / `FromCache` -- a tap short-circuited the iteration with a successful bail status, + * for example the bridge-cache plugin performing a cache read/write out of band. + * + * `PhasedScriptAction` already treats an empty *project* selection as success, so treating an empty + * *operation* set as a failure would be inconsistent. `SuccessWithWarning` is deliberately excluded + * because non-allowed warnings are expected to fail the command. + */ +const SUCCESSFUL_EXECUTION_STATUSES: ReadonlySet = new Set([ + OperationStatus.Success, + OperationStatus.Skipped, + OperationStatus.FromCache, + OperationStatus.NoOp +]); + /** * Constructor parameters for PhasedScriptAction. */ @@ -713,7 +735,6 @@ export class PhasedScriptAction extends BaseScriptAction i const { graph, ignoreHooks, stopwatch, terminal } = options; let success: boolean = false; - let result: IExecutionResult | undefined; try { const definiteResult: IExecutionResult = await measureAsyncFn( @@ -722,13 +743,12 @@ export class PhasedScriptAction extends BaseScriptAction i return await graph.executeAsync(iterationOptions); } ); - success = definiteResult.status === OperationStatus.Success; - result = definiteResult; + success = SUCCESSFUL_EXECUTION_STATUSES.has(definiteResult.status); stopwatch.stop(); const message: string = `rush ${this.actionName} (${stopwatch.toString()})`; - if (result.status === OperationStatus.Success) { + if (success) { terminal.writeLine(Colorize.green(message)); } else { terminal.writeLine(message); diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts index 015c9871873..dcdbca339ff 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts @@ -317,6 +317,27 @@ describe('RushCommandLineParser', () => { }); }); + describe('in repo plugin that produces no operations', () => { + it('succeeds when a plugin returns an empty operation set', async () => { + // Regression test: `@rushstack/rush-buildxl-graph-plugin` writes the build graph to disk in + // response to `--drop-graph` and then returns an empty operation set, because there is + // nothing left for Rush to execute. An iteration with zero operations resolves to + // `OperationStatus.NoOp`, which must not be reported as a failure. + const repoName: string = 'clearOperationsAndRunBuildActionRepo'; + const { parser, spawnMock } = await getCommandLineParserInstanceAsync(repoName, 'build'); + + /** + * The plugin is copied into the autoinstaller folder using an option in /config/heft.json + */ + jest.spyOn(Autoinstaller.prototype, 'prepareAsync').mockImplementation(async function () {}); + + await expect(parser.executeAsync()).resolves.toEqual(true); + + // Nothing should have been executed, since the plugin removed every operation. + expect(spawnMock.mock.calls.length).toEqual(0); + }); + }); + describe('in repo plugin with build command', () => { describe("'build' action", () => { it(`executes the package's 'build' script`, async () => { diff --git a/libraries/rush-lib/src/cli/test/clearOperationsAndRunBuildActionRepo/a/package.json b/libraries/rush-lib/src/cli/test/clearOperationsAndRunBuildActionRepo/a/package.json new file mode 100644 index 00000000000..f00575e3099 --- /dev/null +++ b/libraries/rush-lib/src/cli/test/clearOperationsAndRunBuildActionRepo/a/package.json @@ -0,0 +1,9 @@ +{ + "name": "a", + "version": "1.0.0", + "description": "Test package a", + "scripts": { + "build": "fake_build_task_but_works_with_mock", + "rebuild": "fake_REbuild_task_but_works_with_mock" + } +} diff --git a/libraries/rush-lib/src/cli/test/clearOperationsAndRunBuildActionRepo/b/package.json b/libraries/rush-lib/src/cli/test/clearOperationsAndRunBuildActionRepo/b/package.json new file mode 100644 index 00000000000..8f203bb691d --- /dev/null +++ b/libraries/rush-lib/src/cli/test/clearOperationsAndRunBuildActionRepo/b/package.json @@ -0,0 +1,9 @@ +{ + "name": "b", + "version": "1.0.0", + "description": "Test package b", + "scripts": { + "build": "fake_build_task_but_works_with_mock", + "rebuild": "fake_REbuild_task_but_works_with_mock" + } +} diff --git a/libraries/rush-lib/src/cli/test/clearOperationsAndRunBuildActionRepo/common/autoinstallers/plugins/package.json b/libraries/rush-lib/src/cli/test/clearOperationsAndRunBuildActionRepo/common/autoinstallers/plugins/package.json new file mode 100644 index 00000000000..9d477cd6aad --- /dev/null +++ b/libraries/rush-lib/src/cli/test/clearOperationsAndRunBuildActionRepo/common/autoinstallers/plugins/package.json @@ -0,0 +1,8 @@ +{ + "name": "plugins", + "version": "1.0.0", + "private": true, + "dependencies": { + "rush-mock-clear-operations-plugin": "file:../../../../rush-mock-clear-operations-plugin" + } +} diff --git a/libraries/rush-lib/src/cli/test/clearOperationsAndRunBuildActionRepo/common/autoinstallers/plugins/rush-plugins/rush-mock-clear-operations-plugin/rush-plugin-manifest.json b/libraries/rush-lib/src/cli/test/clearOperationsAndRunBuildActionRepo/common/autoinstallers/plugins/rush-plugins/rush-mock-clear-operations-plugin/rush-plugin-manifest.json new file mode 100644 index 00000000000..94c8982167f --- /dev/null +++ b/libraries/rush-lib/src/cli/test/clearOperationsAndRunBuildActionRepo/common/autoinstallers/plugins/rush-plugins/rush-mock-clear-operations-plugin/rush-plugin-manifest.json @@ -0,0 +1,9 @@ +{ + "plugins": [ + { + "pluginName": "rush-mock-clear-operations-plugin", + "description": "Rush plugin for testing a phased command that produces no operations", + "entryPoint": "index.js" + } + ] +} diff --git a/libraries/rush-lib/src/cli/test/clearOperationsAndRunBuildActionRepo/common/config/rush/rush-plugins.json b/libraries/rush-lib/src/cli/test/clearOperationsAndRunBuildActionRepo/common/config/rush/rush-plugins.json new file mode 100644 index 00000000000..94f6be8e009 --- /dev/null +++ b/libraries/rush-lib/src/cli/test/clearOperationsAndRunBuildActionRepo/common/config/rush/rush-plugins.json @@ -0,0 +1,9 @@ +{ + "plugins": [ + { + "packageName": "rush-mock-clear-operations-plugin", + "pluginName": "rush-mock-clear-operations-plugin", + "autoinstallerName": "plugins" + } + ] +} diff --git a/libraries/rush-lib/src/cli/test/clearOperationsAndRunBuildActionRepo/rush.json b/libraries/rush-lib/src/cli/test/clearOperationsAndRunBuildActionRepo/rush.json new file mode 100644 index 00000000000..8bdab648130 --- /dev/null +++ b/libraries/rush-lib/src/cli/test/clearOperationsAndRunBuildActionRepo/rush.json @@ -0,0 +1,17 @@ +{ + "npmVersion": "6.4.1", + "rushVersion": "5.62.2", + "projectFolderMinDepth": 1, + "projectFolderMaxDepth": 99, + + "projects": [ + { + "packageName": "a", + "projectFolder": "a" + }, + { + "packageName": "b", + "projectFolder": "b" + } + ] +} diff --git a/libraries/rush-lib/src/cli/test/rush-mock-clear-operations-plugin/index.ts b/libraries/rush-lib/src/cli/test/rush-mock-clear-operations-plugin/index.ts new file mode 100644 index 00000000000..f4f7fd9d557 --- /dev/null +++ b/libraries/rush-lib/src/cli/test/rush-mock-clear-operations-plugin/index.ts @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { RushSession, IPhasedCommand, Operation } from '../../../index'; + +/** + * Mimics the shape of `@rushstack/rush-buildxl-graph-plugin`, which performs its work during + * `createOperationsAsync` and then returns an empty operation set because there is nothing left + * for Rush to execute. + * + * Such an invocation must be reported as a success, not a failure. + */ +export default class RushMockClearOperationsPlugin { + public apply(rushSession: RushSession): void { + rushSession.hooks.runAnyPhasedCommand.tapPromise( + RushMockClearOperationsPlugin.name, + async (command: IPhasedCommand) => { + command.hooks.createOperationsAsync.tapPromise( + { + name: RushMockClearOperationsPlugin.name, + // Run after every other plugin has finished creating operations. + stage: Number.MAX_SAFE_INTEGER + }, + async () => { + return new Set(); + } + ); + } + ); + } +} diff --git a/libraries/rush-lib/src/cli/test/rush-mock-clear-operations-plugin/package.json b/libraries/rush-lib/src/cli/test/rush-mock-clear-operations-plugin/package.json new file mode 100644 index 00000000000..a28fd24a685 --- /dev/null +++ b/libraries/rush-lib/src/cli/test/rush-mock-clear-operations-plugin/package.json @@ -0,0 +1,6 @@ +{ + "name": "rush-mock-clear-operations-plugin", + "version": "1.0.0", + "private": true, + "dependencies": {} +} diff --git a/libraries/rush-lib/src/cli/test/rush-mock-clear-operations-plugin/rush-plugin-manifest.json b/libraries/rush-lib/src/cli/test/rush-mock-clear-operations-plugin/rush-plugin-manifest.json new file mode 100644 index 00000000000..94c8982167f --- /dev/null +++ b/libraries/rush-lib/src/cli/test/rush-mock-clear-operations-plugin/rush-plugin-manifest.json @@ -0,0 +1,9 @@ +{ + "plugins": [ + { + "pluginName": "rush-mock-clear-operations-plugin", + "description": "Rush plugin for testing a phased command that produces no operations", + "entryPoint": "index.js" + } + ] +}