feat(dev-infra): publish major versions to "next" NPM dist tag (#42133)

Previously, the dev-infra release tool would publish major versions
directly to the NPM `@latest` dist tag. This is correct in theory, but
rather unpractical given that we want to publish packages first as
`@next` so that other dependent Angular packages can update too,
allowing us to publish all main Angular packages (from FW, COMP
and TOOL) at the same time to `@latest` on NPM.

This involves creating a new release action for re-tagging the
previously released major as `@latest` on NPM.

PR Close #42133
This commit is contained in:
Paul Gschwendtner 2021-05-17 19:02:39 +02:00 committed by atscott
parent 48f49bacb4
commit 9d75687f62
8 changed files with 251 additions and 9 deletions

View File

@ -5954,7 +5954,7 @@ class ReleaseAction {
this._cachedForkRepo = null;
}
/** Whether the release action is currently active. */
static isActive(_trains) {
static isActive(_trains, _config) {
throw Error('Not implemented.');
}
/** Updates the version in the project top-level `package.json` file. */
@ -6635,7 +6635,17 @@ class CutStableAction extends ReleaseAction {
const isNewMajor = (_a = this.active.releaseCandidate) === null || _a === void 0 ? void 0 : _a.isMajor;
const { pullRequest: { id }, releaseNotes } = yield this.checkoutBranchAndStageVersion(newVersion, branchName);
yield this.waitForPullRequestToBeMerged(id);
yield this.buildAndPublish(releaseNotes, branchName, 'latest');
// 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.
yield this.buildAndPublish(releaseNotes, branchName, isNewMajor ? 'next' : 'latest');
// If a new major version is published and becomes the "latest" release-train, we need
// to set the LTS npm dist tag for the previous latest release-train (the current patch).
if (isNewMajor) {
@ -6760,6 +6770,54 @@ class MoveNextIntoFeatureFreezeAction extends ReleaseAction {
}
}
/**
* @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
*/
/**
* Release action that tags the recently published major as latest within the NPM
* registry. Major versions are published to the `next` NPM dist tag initially and
* can be re-tagged to the `latest` NPM dist tag. This allows caretakers to make major
* releases available at the same time. e.g. Framework, Tooling and Components
* are able to publish v12 to `@latest` at the same time. This wouldn't be possible if
* we directly publish to `@latest` because Tooling and Components needs to wait
* for the major framework release to be available on NPM.
* @see {CutStableAction#perform} for more details.
*/
class TagRecentMajorAsLatest extends ReleaseAction {
getDescription() {
return tslib.__awaiter(this, void 0, void 0, function* () {
return `Tag recently published major v${this.active.latest.version} as "next" in NPM.`;
});
}
perform() {
return tslib.__awaiter(this, void 0, void 0, function* () {
yield this.checkoutUpstreamBranch(this.active.latest.branchName);
yield invokeYarnInstallCommand(this.projectDir);
yield invokeSetNpmDistCommand('latest', this.active.latest.version);
});
}
static isActive({ latest }, config) {
return tslib.__awaiter(this, void 0, void 0, function* () {
// If the latest release-train does currently not have a major version as version. e.g.
// the latest branch is `10.0.x` with the version being `10.0.2`. In such cases, a major
// has not been released recently, and this action should never become active.
if (latest.version.minor !== 0 || latest.version.patch !== 0) {
return false;
}
const packageInfo = yield fetchProjectNpmPackageInfo(config);
const npmLatestVersion = semver.parse(packageInfo['dist-tags']['latest']);
// This action only becomes active if a major just has been released recently, but is
// not set to the `latest` NPM dist tag in the NPM registry. Note that we only allow
// re-tagging if the current `@latest` in NPM is the previous major version.
return npmLatestVersion !== null && npmLatestVersion.major === latest.version.major - 1;
});
}
}
/**
* @license
* Copyright Google LLC All Rights Reserved.
@ -6772,6 +6830,7 @@ class MoveNextIntoFeatureFreezeAction extends ReleaseAction {
* by priority. Actions which are selectable are sorted based on this declaration order.
*/
const actions = [
TagRecentMajorAsLatest,
CutStableAction,
CutReleaseCandidateAction,
CutNewPatchAction,
@ -6860,7 +6919,7 @@ class ReleaseTool {
const choices = [];
// Find and instantiate all release actions which are currently valid.
for (let actionType of actions) {
if (yield actionType.isActive(activeTrains)) {
if (yield actionType.isActive(activeTrains, this._config)) {
const action = new actionType(activeTrains, this._git, this._config, this._projectRoot);
choices.push({ name: yield action.getDescription(), value: action });
}

View File

@ -48,7 +48,7 @@ export interface PullRequest {
/** Constructor type for instantiating a release action */
export interface ReleaseActionConstructor<T extends ReleaseAction = ReleaseAction> {
/** Whether the release action is currently active. */
isActive(active: ActiveReleaseTrains): Promise<boolean>;
isActive(active: ActiveReleaseTrains, config: ReleaseConfig): Promise<boolean>;
/** Constructs a release action. */
new(...args: [ActiveReleaseTrains, GitClient<true>, ReleaseConfig, string]): T;
}
@ -60,7 +60,7 @@ export interface ReleaseActionConstructor<T extends ReleaseAction = ReleaseActio
*/
export abstract class ReleaseAction {
/** Whether the release action is currently active. */
static isActive(_trains: ActiveReleaseTrains): Promise<boolean> {
static isActive(_trains: ActiveReleaseTrains, _config: ReleaseConfig): Promise<boolean> {
throw Error('Not implemented.');
}

View File

@ -30,12 +30,22 @@ export class CutStableAction extends ReleaseAction {
const newVersion = this._newVersion;
const isNewMajor = this.active.releaseCandidate?.isMajor;
const {pullRequest: {id}, releaseNotes} =
await this.checkoutBranchAndStageVersion(newVersion, branchName);
await this.waitForPullRequestToBeMerged(id);
await this.buildAndPublish(releaseNotes, branchName, 'latest');
// 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.buildAndPublish(releaseNotes, branchName, isNewMajor ? 'next' : 'latest');
// If a new major version is published and becomes the "latest" release-train, we need
// to set the LTS npm dist tag for the previous latest release-train (the current patch).

View File

@ -14,12 +14,14 @@ import {CutNextPrereleaseAction} from './cut-next-prerelease';
import {CutReleaseCandidateAction} from './cut-release-candidate';
import {CutStableAction} from './cut-stable';
import {MoveNextIntoFeatureFreezeAction} from './move-next-into-feature-freeze';
import {TagRecentMajorAsLatest} from './tag-recent-major-as-latest';
/**
* List of release actions supported by the release staging tool. These are sorted
* by priority. Actions which are selectable are sorted based on this declaration order.
*/
export const actions: ReleaseActionConstructor[] = [
TagRecentMajorAsLatest,
CutStableAction,
CutReleaseCandidateAction,
CutNewPatchAction,

View File

@ -0,0 +1,53 @@
/**
* @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 * as semver from 'semver';
import {ReleaseConfig} from '../../config';
import {ActiveReleaseTrains} from '../../versioning/active-release-trains';
import {fetchProjectNpmPackageInfo} from '../../versioning/npm-registry';
import {ReleaseAction} from '../actions';
import {invokeSetNpmDistCommand, invokeYarnInstallCommand} from '../external-commands';
/**
* Release action that tags the recently published major as latest within the NPM
* registry. Major versions are published to the `next` NPM dist tag initially and
* can be re-tagged to the `latest` NPM dist tag. This allows caretakers to make major
* releases available at the same time. e.g. Framework, Tooling and Components
* are able to publish v12 to `@latest` at the same time. This wouldn't be possible if
* we directly publish to `@latest` because Tooling and Components needs to wait
* for the major framework release to be available on NPM.
* @see {CutStableAction#perform} for more details.
*/
export class TagRecentMajorAsLatest extends ReleaseAction {
async getDescription() {
return `Tag recently published major v${this.active.latest.version} as "next" in NPM.`;
}
async perform() {
await this.checkoutUpstreamBranch(this.active.latest.branchName);
await invokeYarnInstallCommand(this.projectDir);
await invokeSetNpmDistCommand('latest', this.active.latest.version);
}
static async isActive({latest}: ActiveReleaseTrains, config: ReleaseConfig) {
// If the latest release-train does currently not have a major version as version. e.g.
// the latest branch is `10.0.x` with the version being `10.0.2`. In such cases, a major
// has not been released recently, and this action should never become active.
if (latest.version.minor !== 0 || latest.version.patch !== 0) {
return false;
}
const packageInfo = await fetchProjectNpmPackageInfo(config);
const npmLatestVersion = semver.parse(packageInfo['dist-tags']['latest']);
// This action only becomes active if a major just has been released recently, but is
// not set to the `latest` NPM dist tag in the NPM registry. Note that we only allow
// re-tagging if the current `@latest` in NPM is the previous major version.
return npmLatestVersion !== null && npmLatestVersion.major === latest.version.major - 1;
}
}

View File

@ -98,7 +98,7 @@ export class ReleaseTool {
// Find and instantiate all release actions which are currently valid.
for (let actionType of actions) {
if (await actionType.isActive(activeTrains)) {
if (await actionType.isActive(activeTrains, this._config)) {
const action: ReleaseAction =
new actionType(activeTrains, this._git, this._config, this._projectRoot);
choices.push({name: await action.getDescription(), value: action});

View File

@ -77,7 +77,9 @@ describe('cut stable action', () => {
return Promise.resolve();
});
await expectStagingAndPublishWithCherryPick(action, '11.0.x', '11.0.0', 'latest');
// Major is released to the `next` NPM dist tag initially. Can be re-tagged with
// a separate release action. See `CutStableAction` for more details.
await expectStagingAndPublishWithCherryPick(action, '11.0.x', '11.0.0', 'next');
expect(externalCommands.invokeSetNpmDistCommand).toHaveBeenCalledTimes(1);
expect(externalCommands.invokeSetNpmDistCommand)
.toHaveBeenCalledWith('v10-lts', matchesVersion('10.0.3'));

View File

@ -0,0 +1,116 @@
/**
* @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 {matchesVersion} from '../../../utils/testing';
import {ReleaseTrain} from '../../versioning/release-trains';
import {TagRecentMajorAsLatest} from '../actions/tag-recent-major-as-latest';
import * as externalCommands from '../external-commands';
import {fakeNpmPackageQueryRequest, getTestingMocksForReleaseAction, parse, setupReleaseActionForTesting} from './test-utils';
describe('tag recent major as latest action', () => {
it('should not be active if a patch has been published after major release', async () => {
const {releaseConfig} = getTestingMocksForReleaseAction();
expect(await TagRecentMajorAsLatest.isActive(
{
releaseCandidate: null,
next: new ReleaseTrain('master', parse('10.1.0-next.0')),
latest: new ReleaseTrain('10.0.x', parse('10.0.1')),
},
releaseConfig))
.toBe(false);
});
it('should not be active if a major has been released recently but "@latest" on NPM points to ' +
'a more recent major',
async () => {
const {releaseConfig} = getTestingMocksForReleaseAction();
// NPM `@latest` will point to a patch release of a more recent major. This is unlikely
// to happen (only with manual changes outside of the release tool), but should
// prevent accidental overrides from the release action.
fakeNpmPackageQueryRequest(
releaseConfig.npmPackages[0], {'dist-tags': {'latest': '11.0.3'}});
expect(await TagRecentMajorAsLatest.isActive(
{
releaseCandidate: null,
next: new ReleaseTrain('master', parse('10.1.0-next.0')),
latest: new ReleaseTrain('10.0.x', parse('10.0.0')),
},
releaseConfig))
.toBe(false);
});
it('should not be active if a major has been released recently but "@latest" on NPM points to ' +
'an older major',
async () => {
const {releaseConfig} = getTestingMocksForReleaseAction();
// NPM `@latest` will point to a patch release of an older major. This is unlikely to happen
// (only with manual changes outside of the release tool), but should prevent accidental
// changes from the release action that indicate mismatched version branches, or an
// out-of-sync NPM registry.
fakeNpmPackageQueryRequest(releaseConfig.npmPackages[0], {'dist-tags': {'latest': '8.4.7'}});
expect(await TagRecentMajorAsLatest.isActive(
{
releaseCandidate: null,
next: new ReleaseTrain('master', parse('10.1.0-next.0')),
latest: new ReleaseTrain('10.0.x', parse('10.0.0')),
},
releaseConfig))
.toBe(false);
});
it('should be active if a major has been released recently but is not published as ' +
'"@latest" to NPM',
async () => {
const {releaseConfig} = getTestingMocksForReleaseAction();
// NPM `@latest` will point to a patch release of the previous major.
fakeNpmPackageQueryRequest(releaseConfig.npmPackages[0], {'dist-tags': {'latest': '9.2.3'}});
expect(await TagRecentMajorAsLatest.isActive(
{
releaseCandidate: null,
next: new ReleaseTrain('master', parse('10.1.0-next.0')),
latest: new ReleaseTrain('10.0.x', parse('10.0.0')),
},
releaseConfig))
.toBe(true);
});
it('should be active if a major has been released recently but is not published as ' +
'"@latest" to NPM',
async () => {
const {instance, gitClient, releaseConfig} =
setupReleaseActionForTesting(TagRecentMajorAsLatest, {
releaseCandidate: null,
next: new ReleaseTrain('master', parse('10.1.0-next.0')),
latest: new ReleaseTrain('10.0.x', parse('10.0.0')),
});
// NPM `@latest` will point to a patch release of the previous major.
fakeNpmPackageQueryRequest(releaseConfig.npmPackages[0], {'dist-tags': {'latest': '9.2.3'}});
await instance.perform();
// Ensure that the NPM dist tag is set only for packages that were available in the previous
// major version. A spy has already been installed on the function.
(externalCommands.invokeSetNpmDistCommand as jasmine.Spy).and.callFake(() => {
expect(gitClient.head.ref?.name).toBe('10.0.x');
return Promise.resolve();
});
expect(externalCommands.invokeSetNpmDistCommand).toHaveBeenCalledTimes(1);
expect(externalCommands.invokeSetNpmDistCommand)
.toHaveBeenCalledWith('latest', matchesVersion('10.0.0'));
});
});