From 9d75687f62526616f4abf1096c231d4a246e06e4 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Mon, 17 May 2021 19:02:39 +0200 Subject: [PATCH] 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 --- dev-infra/ng-dev.js | 65 +++++++++- dev-infra/release/publish/actions.ts | 4 +- .../release/publish/actions/cut-stable.ts | 14 ++- dev-infra/release/publish/actions/index.ts | 2 + .../actions/tag-recent-major-as-latest.ts | 53 ++++++++ dev-infra/release/publish/index.ts | 2 +- .../release/publish/test/cut-stable.spec.ts | 4 +- .../test/tag-recent-major-as-latest.spec.ts | 116 ++++++++++++++++++ 8 files changed, 251 insertions(+), 9 deletions(-) create mode 100644 dev-infra/release/publish/actions/tag-recent-major-as-latest.ts create mode 100644 dev-infra/release/publish/test/tag-recent-major-as-latest.spec.ts diff --git a/dev-infra/ng-dev.js b/dev-infra/ng-dev.js index 6d799e1296..f200d96d10 100755 --- a/dev-infra/ng-dev.js +++ b/dev-infra/ng-dev.js @@ -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 }); } diff --git a/dev-infra/release/publish/actions.ts b/dev-infra/release/publish/actions.ts index 8318bf8643..39358a4092 100644 --- a/dev-infra/release/publish/actions.ts +++ b/dev-infra/release/publish/actions.ts @@ -48,7 +48,7 @@ export interface PullRequest { /** Constructor type for instantiating a release action */ export interface ReleaseActionConstructor { /** Whether the release action is currently active. */ - isActive(active: ActiveReleaseTrains): Promise; + isActive(active: ActiveReleaseTrains, config: ReleaseConfig): Promise; /** Constructs a release action. */ new(...args: [ActiveReleaseTrains, GitClient, ReleaseConfig, string]): T; } @@ -60,7 +60,7 @@ export interface ReleaseActionConstructor { + static isActive(_trains: ActiveReleaseTrains, _config: ReleaseConfig): Promise { throw Error('Not implemented.'); } diff --git a/dev-infra/release/publish/actions/cut-stable.ts b/dev-infra/release/publish/actions/cut-stable.ts index 523b0b5222..d6905f28b6 100644 --- a/dev-infra/release/publish/actions/cut-stable.ts +++ b/dev-infra/release/publish/actions/cut-stable.ts @@ -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). diff --git a/dev-infra/release/publish/actions/index.ts b/dev-infra/release/publish/actions/index.ts index 85ad7ba156..67352466a7 100644 --- a/dev-infra/release/publish/actions/index.ts +++ b/dev-infra/release/publish/actions/index.ts @@ -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, diff --git a/dev-infra/release/publish/actions/tag-recent-major-as-latest.ts b/dev-infra/release/publish/actions/tag-recent-major-as-latest.ts new file mode 100644 index 0000000000..742cd8170b --- /dev/null +++ b/dev-infra/release/publish/actions/tag-recent-major-as-latest.ts @@ -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; + } +} diff --git a/dev-infra/release/publish/index.ts b/dev-infra/release/publish/index.ts index 061d7f5aab..e4ad00de2e 100644 --- a/dev-infra/release/publish/index.ts +++ b/dev-infra/release/publish/index.ts @@ -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}); diff --git a/dev-infra/release/publish/test/cut-stable.spec.ts b/dev-infra/release/publish/test/cut-stable.spec.ts index 8a740b7316..850a57b5a3 100644 --- a/dev-infra/release/publish/test/cut-stable.spec.ts +++ b/dev-infra/release/publish/test/cut-stable.spec.ts @@ -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')); diff --git a/dev-infra/release/publish/test/tag-recent-major-as-latest.spec.ts b/dev-infra/release/publish/test/tag-recent-major-as-latest.spec.ts new file mode 100644 index 0000000000..0bd42463c2 --- /dev/null +++ b/dev-infra/release/publish/test/tag-recent-major-as-latest.spec.ts @@ -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')); + }); +});