Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 <command> --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"
}
8 changes: 8 additions & 0 deletions libraries/rush-lib/config/heft.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
28 changes: 24 additions & 4 deletions libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OperationStatus> = new Set([
OperationStatus.Success,
OperationStatus.Skipped,
OperationStatus.FromCache,
OperationStatus.NoOp
]);

/**
* Constructor parameters for PhasedScriptAction.
*/
Expand Down Expand Up @@ -713,7 +735,6 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> i
const { graph, ignoreHooks, stopwatch, terminal } = options;

let success: boolean = false;
let result: IExecutionResult | undefined;

try {
const definiteResult: IExecutionResult = await measureAsyncFn(
Expand All @@ -722,13 +743,12 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> 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);
Expand Down
21 changes: 21 additions & 0 deletions libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "plugins",
"version": "1.0.0",
"private": true,
"dependencies": {
"rush-mock-clear-operations-plugin": "file:../../../../rush-mock-clear-operations-plugin"
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"plugins": [
{
"packageName": "rush-mock-clear-operations-plugin",
"pluginName": "rush-mock-clear-operations-plugin",
"autoinstallerName": "plugins"
}
]
}
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
Original file line number Diff line number Diff line change
@@ -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
Comment thread
TheLarkInn marked this conversation as resolved.
},
async () => {
return new Set<Operation>();
}
);
}
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "rush-mock-clear-operations-plugin",
"version": "1.0.0",
"private": true,
"dependencies": {}
}
Original file line number Diff line number Diff line change
@@ -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"
}
]
}