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
9 changes: 9 additions & 0 deletions ng-dev/release/publish/actions-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,17 @@
* found in the LICENSE file at https://angular.io/license
*/

import type {PullRequest} from './actions.js';

/** Error that will be thrown if the user manually aborted a release action. */
export class UserAbortedReleaseActionError extends Error {}

/** Error that will be thrown if the action has been aborted due to a fatal error. */
export class FatalReleaseActionError extends Error {}

/** Error that will be thrown if the stage-only phase is completed successfully. */
export class StageOnlySuccessError extends Error {
constructor(public pullRequest: PullRequest) {
super('Stage-only phase completed successfully.');
}
}
29 changes: 27 additions & 2 deletions ng-dev/release/publish/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ import {ActiveReleaseTrains} from '../versioning/active-release-trains.js';
import {createExperimentalSemver} from '../versioning/experimental-versions.js';
import {NpmCommand} from '../versioning/npm-command.js';
import {getReleaseTagForVersion} from '../versioning/version-tags.js';
import {FatalReleaseActionError, UserAbortedReleaseActionError} from './actions-error.js';
import {
FatalReleaseActionError,
StageOnlySuccessError,
UserAbortedReleaseActionError,
} from './actions-error.js';
import {
analyzeAndExtendBuiltPackagesWithInfo,
assertIntegrityOfBuiltPackages,
Expand Down Expand Up @@ -78,7 +82,15 @@ export interface ReleaseActionConstructor<T extends ReleaseAction = ReleaseActio
/** Whether the release action is currently active. */
isActive(active: ActiveReleaseTrains, config: ReleaseConfig): Promise<boolean>;
/** Constructs a release action. */
new (...args: [ActiveReleaseTrains, AuthenticatedGitClient, ReleaseConfig, string]): T;
new (
...args: [
active: ActiveReleaseTrains,
git: AuthenticatedGitClient,
config: ReleaseConfig,
projectDir: string,
stageOnly?: boolean,
]
): T;
}

/**
Expand Down Expand Up @@ -106,6 +118,7 @@ export abstract class ReleaseAction {
protected git: AuthenticatedGitClient,
protected config: ReleaseConfig,
protected projectDir: string,
protected stageOnly = false,
) {}

/**
Expand Down Expand Up @@ -519,6 +532,18 @@ export abstract class ReleaseAction {

Log.info(green(' ✓ Release staging pull request has been created.'));

if (this.stageOnly) {
await Promise.all(
builtPackagesWithInfo.map(async (pkg) => {
if (existsSync(pkg.outputPath)) {
await fs.rm(pkg.outputPath, {recursive: true, force: true});
Log.info(`Cleaned up built package directory: ${pkg.outputPath}`);
}
}),
);
throw new StageOnlySuccessError(pullRequest);
}

return {releaseNotes, pullRequest, builtPackagesWithInfo};
}

Expand Down
13 changes: 10 additions & 3 deletions ng-dev/release/publish/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,16 @@ export interface ReleasePublishOptions extends ReleaseToolFlags {}

/** Yargs command builder for configuring the `ng-dev release publish` command. */
function builder(argv: Argv): Argv<ReleasePublishOptions> {
return addGithubTokenOption(argv).option('publishRegistry', {
type: 'string',
});
return addGithubTokenOption(argv)
.option('publishRegistry', {
type: 'string',
})
.option('stage-only', {
type: 'boolean',
default: false,
description:
'Only stage the release (bump version, generate changelog, build, precheck, create PR) and exit.',
});
}

/** Yargs command handler for staging a release. */
Expand Down
16 changes: 13 additions & 3 deletions ng-dev/release/publish/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,14 @@ import {getNextBranchName, ReleaseRepoWithApi} from '../versioning/version-branc

import {ReleaseAction} from './actions.js';
import {ExternalCommands} from './external-commands.js';
import {FatalReleaseActionError, UserAbortedReleaseActionError} from './actions-error.js';
import {
FatalReleaseActionError,
StageOnlySuccessError,
UserAbortedReleaseActionError,
} from './actions-error.js';
import {actions} from './actions/index.js';
import {verifyNgDevToolIsUpToDate} from '../../utils/version-check.js';
import {Log, yellow} from '../../utils/logging.js';
import {green, Log, yellow} from '../../utils/logging.js';
import {Prompt} from '../../utils/prompt.js';
import {setMergeModeRelease} from '../../caretaker/merge-mode/release.js';

Expand All @@ -33,6 +37,7 @@ export enum CompletionState {
/** A set of flags available to be used to override settings at runtime. */
export interface ReleaseToolFlags {
publishRegistry?: string;
stageOnly?: boolean;
}

export class ReleaseTool {
Expand All @@ -46,7 +51,7 @@ export class ReleaseTool {
config: ReleaseConfig,
protected _github: GithubConfig,
protected _projectRoot: string,
_flags: ReleaseToolFlags,
protected _flags: ReleaseToolFlags,
) {
this._config = {
...config,
Expand Down Expand Up @@ -99,6 +104,10 @@ export class ReleaseTool {
try {
await action.perform();
} catch (e) {
if (e instanceof StageOnlySuccessError) {
Log.info(green(`✓ Staging completed successfully. PR URL: ${e.pullRequest.url}`));
return CompletionState.SUCCESS;
}
if (e instanceof UserAbortedReleaseActionError) {
return CompletionState.MANUALLY_ABORTED;
}
Expand Down Expand Up @@ -137,6 +146,7 @@ export class ReleaseTool {
this._git,
this._config,
this._projectRoot,
this._flags.stageOnly,
);
choices.push({name: await action.getDescription(), value: action});
}
Expand Down
55 changes: 54 additions & 1 deletion ng-dev/release/publish/test/common.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

import {readFileSync, writeFileSync} from 'fs';
import {existsSync, readFileSync, writeFileSync} from 'fs';
import {join} from 'path';
import semver from 'semver';

Expand All @@ -25,6 +25,7 @@ import {ActiveReleaseTrains} from '../../versioning/active-release-trains.js';
import {NpmCommand} from '../../versioning/npm-command.js';
import {ReleaseTrain} from '../../versioning/release-trains.js';
import {actions} from '../actions/index.js';
import {StageOnlySuccessError} from '../actions-error.js';
import {githubReleaseBodyLimit} from '../constants.js';
import {DelegateTestAction} from './delegate-test-action.js';
import {getTestConfigurationsForAction, testReleasePackages} from './test-utils/action-mocks.js';
Expand Down Expand Up @@ -619,6 +620,58 @@ describe('common release action logic', () => {
);
});
});

describe('stage-only mode', () => {
it('should throw StageOnlySuccessError and clean up output directories', async () => {
const action = setupReleaseActionForTesting(DelegateTestAction, baseReleaseTrains, {
stageOnly: true,
useSandboxGitClient: true,
});
const {version, branchName} = baseReleaseTrains.next;
const expectedStagingForkBranch = `release-stage-${version.format()}`;

const git = SandboxGitRepo.withInitialCommit(action.githubConfig).createTagForHead(
'0.0.0-compare-base',
);
git.commit('feat(test): first commit');

// Mock only the staging API requests (exclude merge/publish)
action.repo
.expectBranchRequest(branchName, {sha: 'PRE_STAGING_SHA'})
.expectCommitStatusCheck('PRE_STAGING_SHA', 'success')
.expectFindForkRequest(action.fork)
.expectPullRequestToBeCreated(branchName, action.fork, expectedStagingForkBranch, 200);

action.fork.expectBranchRequest(expectedStagingForkBranch);

// We need to simulate that we have built packages and they exist on disk.
// After staging succeeds, they should be cleaned up.
// Let's create dummy package output files/directories.
for (const pkg of action.builtPackagesWithInfo) {
await writePackageJson(pkg.name, version.format());
expect(existsSync(pkg.outputPath)).toBe(true);
}

const stagePromise = action.instance.testStagingWithBuild(
version,
branchName,
parse('0.0.0-compare-base'),
);

// Confirm changelog changes
action.promptConfirmSpy.and.returnValue(Promise.resolve(true));

await expectAsync(stagePromise).toBeRejectedWithError(
StageOnlySuccessError,
'Stage-only phase completed successfully.',
);

// Verify they are cleaned up
for (const pkg of action.builtPackagesWithInfo) {
expect(existsSync(pkg.outputPath)).toBe(false);
}
});
});
});

/** Mock class for `ReleaseNotes` which accepts a list of in-memory commit objects. */
Expand Down
7 changes: 7 additions & 0 deletions ng-dev/release/publish/test/test-utils/test-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ export interface TestOptions {
* appear as published to NPM or not.
*/
isExceptionalMinorPublishedToNpm?: boolean;

/**
* Whether the release action is running in stage-only mode.
*/
stageOnly?: boolean;
}

/** Type describing the default options. Used for narrowing in generics. */
Expand All @@ -47,6 +52,7 @@ export type defaultTestOptionsType = TestOptions & {
stubBuiltPackageOutputChecks: true;
isNextPublishedToNpm: true;
isExceptionalMinorPublishedToNpm: true;
stageOnly: false;
};

/** Default options for tests. Need to match with the default options type. */
Expand All @@ -55,6 +61,7 @@ export const defaultTestOptions: defaultTestOptionsType = {
stubBuiltPackageOutputChecks: true,
isNextPublishedToNpm: true,
isExceptionalMinorPublishedToNpm: true,
stageOnly: false,
};

/** Type describing test options with defaults merged. */
Expand Down
8 changes: 7 additions & 1 deletion ng-dev/release/publish/test/test-utils/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,13 @@ export function setupReleaseActionForTesting<
testOptionsWithDefaults.useSandboxGitClient,
);

const action = new actionCtor(active, gitClient, releaseConfig, projectDir);
const action = new actionCtor(
active,
gitClient,
releaseConfig,
projectDir,
testOptionsWithDefaults.stageOnly,
);

return {
instance: action,
Expand Down