Skip to content

Commit

Permalink
feat(ng-dev): support exceptional minors in release publish tool
Browse files Browse the repository at this point in the history
Introduces three new actions:

* An action for initiating an exceptional minor. This is the point where
  we branch-off from the existing patch.
* An action for cutting pre-releases of an exceptional minor. e.g.
  bumping next.0 to next.1, or rc.0 to rc.1
* An action for cutting the first RC in an exceptional minor.

Also we update the existing actions:

* Cut Stable: It will now take the exceptional minor, if there is one.
  Allowing an exceptional minor to becomethe new "patch".

The base logic for how pre-releases and the first RC is cut has been
a little more updated so that more code duplication can be avoided.

Really a first RC action is an extension of the normal pre-releases
action. This is now achieved by extending from it and overidding where
needed.
  • Loading branch information
devversion committed Dec 22, 2022
1 parent c72c9e4 commit b7beaeb
Show file tree
Hide file tree
Showing 19 changed files with 1,094 additions and 138 deletions.
37 changes: 33 additions & 4 deletions ng-dev/release/publish/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {Prompt} from '../../utils/prompt.js';
import {Spinner} from '../../utils/spinner.js';
import {BuiltPackage, BuiltPackageWithInfo, ReleaseConfig} from '../config/index.js';
import {ReleaseNotes, workspaceRelativeChangelogPath} from '../notes/release-notes.js';
import {NpmDistTag} from '../versioning/index.js';
import {NpmDistTag, PackageJson} from '../versioning/index.js';
import {ActiveReleaseTrains} from '../versioning/active-release-trains.js';
import {createExperimentalSemver} from '../versioning/experimental-versions.js';
import {NpmCommand} from '../versioning/npm-command.js';
Expand Down Expand Up @@ -59,6 +59,16 @@ export interface PullRequest {
forkBranch: string;
}

/** Options that can be used to control the staging of a new version. */
export interface StagingOptions {
/**
* As part of staging, the `package.json` can be updated before the
* new version is set.
* @see {ReleaseAction.updateProjectVersion}
*/
updatePkgJsonFn?: (pkgJson: PackageJson) => void;
}

/** Constructor type for instantiating a release action */
export interface ReleaseActionConstructor<T extends ReleaseAction = ReleaseAction> {
/** Whether the release action is currently active. */
Expand Down Expand Up @@ -94,13 +104,25 @@ export abstract class ReleaseAction {
protected projectDir: string,
) {}

/** Updates the version in the project top-level `package.json` file. */
protected async updateProjectVersion(newVersion: semver.SemVer) {
/**
* Updates the version in the project top-level `package.json` file.
*
* @param newVersion New SemVer version to be set in the file.
* @param additionalUpdateFn Optional update function that runs before
* the version update. Can be used to update other fields.
*/
protected async updateProjectVersion(
newVersion: semver.SemVer,
additionalUpdateFn?: (pkgJson: PackageJson) => void,
) {
const pkgJsonPath = join(this.projectDir, workspaceRelativePackageJsonPath);
const pkgJson = JSON.parse(await fs.readFile(pkgJsonPath, 'utf8')) as {
version: string;
[key: string]: any;
};
if (additionalUpdateFn !== undefined) {
additionalUpdateFn(pkgJson);
}
pkgJson.version = newVersion.format();
// Write the `package.json` file. Note that we add a trailing new line
// to avoid unnecessary diff. IDEs usually add a trailing new line.
Expand Down Expand Up @@ -417,12 +439,15 @@ export abstract class ReleaseAction {
* @param compareVersionForReleaseNotes Version used for comparing with the current
* `HEAD` in order build the release notes.
* @param pullRequestTargetBranch Branch the pull request should target.
* @param opts Non-mandatory options for controlling the staging, e.g.
* allowing for additional `package.json` modifications.
* @returns an object capturing actions performed as part of staging.
*/
protected async stageVersionForBranchAndCreatePullRequest(
newVersion: semver.SemVer,
compareVersionForReleaseNotes: semver.SemVer,
pullRequestTargetBranch: string,
opts?: StagingOptions,
): Promise<{
releaseNotes: ReleaseNotes;
pullRequest: PullRequest;
Expand All @@ -448,7 +473,7 @@ export abstract class ReleaseAction {
'HEAD',
);

await this.updateProjectVersion(newVersion);
await this.updateProjectVersion(newVersion, opts?.updatePkgJsonFn);
await this.prependReleaseNotesToChangelog(releaseNotes);
await this.waitForEditsAndCreateReleaseCommit(newVersion);

Expand Down Expand Up @@ -486,12 +511,15 @@ export abstract class ReleaseAction {
* @param compareVersionForReleaseNotes Version used for comparing with `HEAD` of
* the staging branch in order build the release notes.
* @param stagingBranch Branch within the new version should be staged.
* @param stagingOptions Non-mandatory options for controlling the staging of
* the new version. e.g. allowing for additional `package.json` modifications.
* @returns an object capturing actions performed as part of staging.
*/
protected async checkoutBranchAndStageVersion(
newVersion: semver.SemVer,
compareVersionForReleaseNotes: semver.SemVer,
stagingBranch: string,
stagingOpts?: StagingOptions,
): Promise<{
releaseNotes: ReleaseNotes;
pullRequest: PullRequest;
Expand All @@ -510,6 +538,7 @@ export abstract class ReleaseAction {
newVersion,
compareVersionForReleaseNotes,
stagingBranch,
stagingOpts,
);

return {
Expand Down
7 changes: 4 additions & 3 deletions ng-dev/release/publish/actions/cut-npm-next-prerelease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,10 @@ export class CutNpmNextPrereleaseAction extends CutPrereleaseBaseAction {
})();

releaseNotesCompareVersion = (async () => {
// If we happen to detect the case from above, we use the most recent patch version as base for
// building release notes. This is better than finding the "next" version when we branched-off
// as it also prevents us from duplicating many commits that have already landed in the FF/RC.
// If we happen to detect the case from above, we use the most recent patch version as base
// for building release notes. This is better than finding the "next" version when we
// branched off as it also prevents us from duplicating many commits that have already
// landed in the new patch that was worked on when we branched off.
// For more details see the release notes generation and commit range determination.
if (this.releaseTrain === this.active.next && (await this.shouldUseExistingVersion)) {
return this.active.latest.version;
Expand Down
15 changes: 11 additions & 4 deletions ng-dev/release/publish/actions/cut-npm-next-release-candidate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
* found in the LICENSE file at https://angular.io/license
*/

import semver from 'semver';
import {semverInc} from '../../../utils/semver.js';
import {ActiveReleaseTrains} from '../../versioning/active-release-trains.js';
import {CutReleaseCandidateBaseAction} from './shared/cut-release-candidate.js';
import {CutNpmNextPrereleaseAction} from './cut-npm-next-prerelease.js';

/**
* Release action that allows for the NPM `@next` first release-candidate. The
Expand All @@ -20,9 +22,14 @@ import {CutReleaseCandidateBaseAction} from './shared/cut-release-candidate.js';
* Additional note: There is a separate action allowing in-progress minor's to
* go directly into the RC phase from the `next` train. See `MoveNextIntoReleaseCandidate`.
*/
export class CutNpmNextReleaseCandidateAction extends CutReleaseCandidateBaseAction {
releaseTrain = this.active.releaseCandidate!;
npmDistTag = 'next' as const;
export class CutNpmNextReleaseCandidateAction extends CutNpmNextPrereleaseAction {
override async getDescription(): Promise<string> {
return await super.getReleaseCandidateDescription();
}

override async getNewVersion(): Promise<semver.SemVer> {
return semverInc(this.releaseTrain.version, 'prerelease', 'rc');
}

static override async isActive(active: ActiveReleaseTrains) {
// A NPM `@next` release-candidate can only be cut if we are in feature-freeze.
Expand Down
95 changes: 65 additions & 30 deletions ng-dev/release/publish/actions/cut-stable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,58 +10,72 @@ import semver from 'semver';

import {ActiveReleaseTrains} from '../../versioning/active-release-trains.js';
import {getLtsNpmDistTagOfMajor} from '../../versioning/long-term-support.js';
import {ReleaseAction} from '../actions.js';
import {NpmDistTag} from '../../versioning/npm-registry.js';
import {ReleaseTrain} from '../../versioning/release-trains.js';
import {exceptionalMinorPackageIndicator} from '../../versioning/version-branches.js';
import {FatalReleaseActionError} from '../actions-error.js';
import {ReleaseAction, StagingOptions} from '../actions.js';
import {ExternalCommands} from '../external-commands.js';

/**
* Release action that cuts a stable version for the current release-train in the release
* candidate phase. The pre-release release-candidate version label is removed.
* Release action that cuts a stable version for the current release-train
* in the "release-candidate" phase.
*
* There are only two possible release-trains that can ever be in the RC phase.
* This is either an exceptional-minor or the dedicated FF/RC release-train.
*/
export class CutStableAction extends ReleaseAction {
private _newVersion = this._computeNewVersion();
private _isNewMajor = this.active.releaseCandidate!.isMajor;
private _train = (this.active.exceptionalMinor ?? this.active.releaseCandidate)!;
private _branch = this._train.branchName;
private _newVersion = this._computeNewVersion(this._train);
private _isNewMajor = this._train.isMajor;

override async getDescription() {
if (this._isNewMajor) {
return `Cut a stable release for the release-candidate branch — published as \`@next\` (v${this._newVersion}).`;
return `Cut a stable release for the "${this._branch}" branch — published as \`@next\` (v${this._newVersion}).`;
} else {
return `Cut a stable release for the release-candidate branch — published as \`@latest\` (v${this._newVersion}).`;
return `Cut a stable release for the "${this._branch}" branch — published as \`@latest\` (v${this._newVersion}).`;
}
}

override async perform() {
const {branchName} = this.active.releaseCandidate!;
// This should never happen, but we add a sanity check just to be sure.
if (this._isNewMajor && this._train === this.active.exceptionalMinor) {
throw new FatalReleaseActionError('Unexpected major release of an `exceptional-minor`.');
}

const branchName = this._branch;
const newVersion = this._newVersion;

// When cutting a new stable minor/major, we want to build the release notes capturing
// all changes that have landed in the individual next and RC pre-releases.
// When cutting a new stable minor/major or an exceptional minor, we want to build the
// notes capturing all changes that have landed in the individual `-next`/RC pre-releases.
const compareVersionForReleaseNotes = this.active.latest.version;

// We always remove a potential exceptional-minor indicator. If we would
// publish a stable version of an exceptional minor here- it would leave
// the exceptional minor train and the indicator should be removed.
const stagingOpts: StagingOptions = {
updatePkgJsonFn: (pkgJson) => {
pkgJson[exceptionalMinorPackageIndicator] = undefined;
},
};

const {pullRequest, releaseNotes, builtPackagesWithInfo, beforeStagingSha} =
await this.checkoutBranchAndStageVersion(
newVersion,
compareVersionForReleaseNotes,
branchName,
stagingOpts,
);

await this.promptAndWaitForPullRequestMerged(pullRequest);

// If a new major version is published, we publish to the `next` NPM dist tag temporarily.
// We do this because for major versions, we want all main Angular projects to have their
// new major become available at the same time. Publishing immediately to the `latest` NPM
// dist tag could cause inconsistent versions when users install packages with `@latest`.
// For example: Consider Angular Framework releases v12. CLI and Components would need to
// wait for that release to complete. Once done, they can update their dependencies to point
// to v12. Afterwards they could start the release process. In the meanwhile though, the FW
// dependencies were already available as `@latest`, so users could end up installing v12 while
// still having the older (but currently still latest) CLI version that is incompatible.
// The major release can be re-tagged to `latest` through a separate release action.
await this.publish(
builtPackagesWithInfo,
releaseNotes,
beforeStagingSha,
branchName,
this._isNewMajor ? 'next' : 'latest',
this._getNpmDistTag(),
);

// If a new major version is published and becomes the "latest" release-train, we need
Expand Down Expand Up @@ -93,18 +107,39 @@ export class CutStableAction extends ReleaseAction {
await this.cherryPickChangelogIntoNextBranch(releaseNotes, branchName);
}

/** Gets the new stable version of the release candidate release-train. */
private _computeNewVersion(): semver.SemVer {
const {version} = this.active.releaseCandidate!;
private _getNpmDistTag(): NpmDistTag {
// If a new major version is published, we publish to the `next` NPM dist tag temporarily.
// We do this because for major versions, we want all main Angular projects to have their
// new major become available at the same time. Publishing immediately to the `latest` NPM
// dist tag could cause inconsistent versions when users install packages with `@latest`.
// For example: Consider Angular Framework releases v12. CLI and Components would need to
// wait for that release to complete. Once done, they can update their dependencies to point
// to v12. Afterwards they could start the release process. In the meanwhile though, the FW
// dependencies were already available as `@latest`, so users could end up installing v12 while
// still having the older (but currently still latest) CLI version that is incompatible.
// The major release can be re-tagged to `latest` through a separate release action.
return this._isNewMajor ? 'next' : 'latest';
}

/** Gets the new stable version of the given release-train. */
private _computeNewVersion({version}: ReleaseTrain): semver.SemVer {
return semver.parse(`${version.major}.${version.minor}.${version.patch}`)!;
}

static override async isActive(active: ActiveReleaseTrains) {
// A stable version can be cut for an active release-train currently in the
// release-candidate phase. Note: It is not possible to directly release from
// feature-freeze phase into a stable version.
return (
active.releaseCandidate !== null && active.releaseCandidate.version.prerelease[0] === 'rc'
);
// -- Notes -- :
// * A stable version can be cut for an active release-train currently in the
// release-candidate phase.
// * If there is an exceptional minor, **only** the exceptional minor considered
// because it would be problematic if an in-progress RC would suddenly take over
// while there is still an in-progress exceptional minor.
// * It is impossible to directly release from feature-freeze phase into stable.
if (active.exceptionalMinor !== null) {
return active.exceptionalMinor.version.prerelease[0] === 'rc';
}
if (active.releaseCandidate !== null) {
return active.releaseCandidate.version.prerelease[0] === 'rc';
}
return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {ActiveReleaseTrains} from '../../../versioning/active-release-trains.js';
import {isVersionPublishedToNpm} from '../../../versioning/npm-registry.js';
import {isFirstNextPrerelease} from '../../../versioning/prerelease-version.js';
import {CutPrereleaseBaseAction} from '../shared/cut-prerelease.js';

/**
* Release action that allows for `-next` pre-releases of an in-progress
* exceptional minor. The action is active when there is an exceptional minor.
*
* The action will bump the pre-release version to the next increment
* and publish it to NPM. Note that it would not be tagged on NPM as `@next`.
*/
export class CutExceptionalMinorPrereleaseAction extends CutPrereleaseBaseAction {
releaseTrain = this.active.exceptionalMinor!;

// An exceptional minor will never be released as `@next`. The NPM next dist tag
// will be reserved for the normal FF/RC or `next` release trains. Specifically
// we cannot override the `@next` NPM dist tag when it already points to a more
// recent major. This would most commonly be the case, and in the other edge-case
// of where no NPM next release has occurred yet- arguably an exceptional minor
// should not prevent actual pre-releases for an on-going FF/RC or the next branch.
// Note that NPM always requires a dist-tag, so we explicitly have one dedicated
// for exceptional minors. This tag could be deleted in the future.
// TODO(devversion): consider automatically deleting this tag- or keep it around.
npmDistTag = 'exceptional-minor' as const;

shouldUseExistingVersion = (async () => {
// If an exceptional minor branch has just been created, the actual version
// will not be published directly. To account for this case, based on if the
// version is already published or not, the version is NOT incremented.
return (
isFirstNextPrerelease(this.releaseTrain.version) &&
!(await isVersionPublishedToNpm(this.releaseTrain.version, this.config))
);
})();

releaseNotesCompareVersion = (async () => {
if (await this.shouldUseExistingVersion) {
return this.active.latest.version;
}
return this.releaseTrain.version;
})();

override async getDescription(): Promise<string> {
// Make it more obvious that this action is for an exceptional minor.
return `Exceptional Minor: ${await super.getDescription()}`;
}

static override async isActive(active: ActiveReleaseTrains) {
return active.exceptionalMinor !== null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import semver from 'semver';
import {semverInc} from '../../../../utils/semver.js';
import {ActiveReleaseTrains} from '../../../versioning/active-release-trains.js';
import {CutExceptionalMinorPrereleaseAction} from './cut-exceptional-minor-prerelease.js';

/**
* Release action that allows for the first exceptional minor release-candidate. The
* action is only active when there is an in-progress exceptional minor that
* is still in the `-next` pre-release phase.
*
* The action will bump the pre-release version from the `-next` prerelease to
* the first release-candidate. The action will then become inactive again as
* additional RC pre-releases would be handled by `CutExceptionalMinorPrereleaseAction`
* then.
*/
export class CutExceptionalMinorReleaseCandidateAction extends CutExceptionalMinorPrereleaseAction {
override async getDescription(): Promise<string> {
// Use the RC description and make it clear that this action is for an exceptional minor.
return `Exceptional Minor: ${await super.getReleaseCandidateDescription()}`;
}

override async getNewVersion(): Promise<semver.SemVer> {
return semverInc(this.releaseTrain.version, 'prerelease', 'rc');
}

static override async isActive(active: ActiveReleaseTrains) {
return (
// If there is an exceptional minor and we are still in `-next` pre-releases,
// the first RC pre-release can be cut.
active.exceptionalMinor !== null && active.exceptionalMinor.version.prerelease[0] === 'next'
);
}
}
Loading

0 comments on commit b7beaeb

Please sign in to comment.