diff --git a/ng-dev/caretaker/check/ci.spec.ts b/ng-dev/caretaker/check/ci.spec.ts index 4b60bb3ff..fe8c3cf3d 100644 --- a/ng-dev/caretaker/check/ci.spec.ts +++ b/ng-dev/caretaker/check/ci.spec.ts @@ -68,6 +68,12 @@ describe('CiModule', () => { status: 'not found', }); expect(data[1]).toEqual({ + active: false, + name: 'exceptionalMinor', + label: '', + status: 'not found', + }); + expect(data[2]).toEqual({ active: true, name: 'latest-branch', label: 'latest (latest-branch)', @@ -120,5 +126,7 @@ function buildMockActiveReleaseTrains(withRc: boolean): ActiveReleaseTrains { releaseCandidate: withRc ? {branchName: 'rc-branch', ...baseResult} : null, latest: {branchName: 'latest-branch', ...baseResult}, next: {branchName: 'next-branch', ...baseResult}, + // TODO: Consider testing exceptional minor status too. + exceptionalMinor: null, }); } diff --git a/ng-dev/caretaker/check/ci.ts b/ng-dev/caretaker/check/ci.ts index 0a58f04d3..92053d731 100644 --- a/ng-dev/caretaker/check/ci.ts +++ b/ng-dev/caretaker/check/ci.ts @@ -36,8 +36,10 @@ export class CiModule extends BaseModule { ...this.git.remoteConfig, nextBranchName, }; - const {latest, next, releaseCandidate} = await ActiveReleaseTrains.fetch(repo); - const ciResultPromises = Object.entries({releaseCandidate, latest, next}).map( + const {latest, next, releaseCandidate, exceptionalMinor} = await ActiveReleaseTrains.fetch( + repo, + ); + const ciResultPromises = Object.entries({releaseCandidate, exceptionalMinor, latest, next}).map( async ([trainName, train]: [string, ReleaseTrain | null]) => { if (train === null) { return { diff --git a/ng-dev/pr/common/targeting/lts-branch.ts b/ng-dev/pr/common/targeting/lts-branch.ts index 7bbdcce16..505cdf649 100644 --- a/ng-dev/pr/common/targeting/lts-branch.ts +++ b/ng-dev/pr/common/targeting/lts-branch.ts @@ -13,7 +13,7 @@ import { computeLtsEndDateOfMajor, fetchProjectNpmPackageInfo, getLtsNpmDistTagOfMajor, - getVersionOfBranch, + getVersionInfoForBranch, ReleaseRepoWithApi, } from '../../../release/versioning/index.js'; import {Prompt} from '../../../utils/prompt.js'; @@ -35,7 +35,7 @@ export async function assertActiveLtsBranch( releaseConfig: ReleaseConfig, branchName: string, ) { - const version = await getVersionOfBranch(repo, branchName); + const {version} = await getVersionInfoForBranch(repo, branchName); const {'dist-tags': distTags, time} = await fetchProjectNpmPackageInfo(releaseConfig); // LTS versions should be tagged in NPM in the following format: `v{major}-lts`. diff --git a/ng-dev/pr/merge/integration.spec.ts b/ng-dev/pr/merge/integration.spec.ts index 889156558..e6d54415b 100644 --- a/ng-dev/pr/merge/integration.spec.ts +++ b/ng-dev/pr/merge/integration.spec.ts @@ -441,9 +441,7 @@ describe('default target labels', () => { interceptBranchesListRequest(['10.3.x', '10.4.x']); await expectAsync(getBranchesForLabel('target: patch')).toBeRejectedWithError( - 'Unable to determine latest release-train. Found two consecutive ' + - 'branches in feature-freeze/release-candidate phase. Did not expect both ' + - '"10.3.x" and "10.4.x" to be in feature-freeze/release-candidate mode.', + /No exceptional minors are allowed.+cannot be multiple feature-freeze\/release-candidate branches: "10.3.x"/, ); }); }); diff --git a/ng-dev/release/publish/test/common.spec.ts b/ng-dev/release/publish/test/common.spec.ts index 56bfcb50b..246495aa7 100644 --- a/ng-dev/release/publish/test/common.spec.ts +++ b/ng-dev/release/publish/test/common.spec.ts @@ -39,6 +39,7 @@ import { describe('common release action logic', () => { const baseReleaseTrains = new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: null, next: new ReleaseTrain('master', parse('10.1.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.1')), @@ -47,6 +48,7 @@ describe('common release action logic', () => { describe('version computation', () => { it('should not modify release train versions and cause invalid other actions', async () => { const testReleaseTrain = new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-next.3')), next: new ReleaseTrain('master', parse('10.2.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.1')), @@ -76,6 +78,7 @@ describe('common release action logic', () => { it('should properly show descriptions when a major is in RC-phase', async () => { const testReleaseTrain = new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: new ReleaseTrain('15.0.x', parse('15.0.0-rc.1')), next: new ReleaseTrain('main', parse('15.1.0-next.0')), latest: new ReleaseTrain('14.3.x', parse('14.3.1')), diff --git a/ng-dev/release/publish/test/configure-next-as-major.spec.ts b/ng-dev/release/publish/test/configure-next-as-major.spec.ts index 5231a79b7..9ff6fbda5 100644 --- a/ng-dev/release/publish/test/configure-next-as-major.spec.ts +++ b/ng-dev/release/publish/test/configure-next-as-major.spec.ts @@ -17,6 +17,7 @@ describe('configure next as major action', () => { expect( await ConfigureNextAsMajorAction.isActive( new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: null, next: new ReleaseTrain('master', parse('10.1.0-next.3')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), @@ -29,6 +30,7 @@ describe('configure next as major action', () => { expect( await ConfigureNextAsMajorAction.isActive( new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-rc.1')), next: new ReleaseTrain('master', parse('10.2.0-next.3')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), @@ -41,6 +43,7 @@ describe('configure next as major action', () => { expect( await ConfigureNextAsMajorAction.isActive( new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: null, next: new ReleaseTrain('master', parse('11.0.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), @@ -53,6 +56,7 @@ describe('configure next as major action', () => { const action = setupReleaseActionForTesting( ConfigureNextAsMajorAction, new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: null, next: new ReleaseTrain('master', parse('10.1.0-next.3')), latest: new ReleaseTrain('10.0.x', parse('10.0.2')), diff --git a/ng-dev/release/publish/test/cut-lts-patch.spec.ts b/ng-dev/release/publish/test/cut-lts-patch.spec.ts index 9b1d8e080..4ccfe533e 100644 --- a/ng-dev/release/publish/test/cut-lts-patch.spec.ts +++ b/ng-dev/release/publish/test/cut-lts-patch.spec.ts @@ -30,6 +30,7 @@ describe('cut an LTS patch action', () => { expect( await CutLongTermSupportPatchAction.isActive( new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: null, next: new ReleaseTrain('master', parse('10.1.0-next.3')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), @@ -42,6 +43,7 @@ describe('cut an LTS patch action', () => { expect( await CutLongTermSupportPatchAction.isActive( new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-next.3')), next: new ReleaseTrain('master', parse('10.2.0-next.3')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), @@ -54,6 +56,7 @@ describe('cut an LTS patch action', () => { expect( await CutLongTermSupportPatchAction.isActive( new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-rc.0')), next: new ReleaseTrain('master', parse('10.2.0-next.3')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), @@ -66,6 +69,7 @@ describe('cut an LTS patch action', () => { const action = setupReleaseActionForTesting( CutLongTermSupportPatchAction, new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: null, next: new ReleaseTrain('master', parse('10.1.0-next.3')), latest: new ReleaseTrain('10.0.x', parse('10.0.2')), @@ -85,6 +89,7 @@ describe('cut an LTS patch action', () => { const action = setupReleaseActionForTesting( CutLongTermSupportPatchAction, new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: null, next: new ReleaseTrain('master', parse('10.1.0-next.3')), latest: new ReleaseTrain('10.0.x', parse('10.0.2')), @@ -127,6 +132,7 @@ describe('cut an LTS patch action', () => { const {releaseConfig, githubConfig} = getTestConfigurationsForAction(); const gitClient = getMockGitClient(githubConfig, /* useSandboxGitClient */ false); const activeReleaseTrains = new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: null, next: new ReleaseTrain('master', parse('10.1.0-next.3')), latest: new ReleaseTrain('10.0.x', parse('10.0.2')), diff --git a/ng-dev/release/publish/test/cut-new-patch.spec.ts b/ng-dev/release/publish/test/cut-new-patch.spec.ts index 6c4636a1c..846b16a02 100644 --- a/ng-dev/release/publish/test/cut-new-patch.spec.ts +++ b/ng-dev/release/publish/test/cut-new-patch.spec.ts @@ -22,6 +22,7 @@ describe('cut new patch action', () => { expect( await CutNewPatchAction.isActive( new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: null, next: new ReleaseTrain('master', parse('10.1.0-next.3')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), @@ -34,6 +35,7 @@ describe('cut new patch action', () => { const action = setupReleaseActionForTesting( CutNewPatchAction, new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: null, next: new ReleaseTrain('master', parse('10.1.0-next.3')), latest: new ReleaseTrain('10.0.x', parse('10.0.2')), @@ -47,6 +49,7 @@ describe('cut new patch action', () => { const action = setupReleaseActionForTesting( CutNewPatchAction, new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-next.3')), next: new ReleaseTrain('master', parse('10.2.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.9')), @@ -60,6 +63,7 @@ describe('cut new patch action', () => { const action = setupReleaseActionForTesting( CutNewPatchAction, new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-rc.0')), next: new ReleaseTrain('master', parse('10.2.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.9')), @@ -73,6 +77,7 @@ describe('cut new patch action', () => { const action = setupReleaseActionForTesting( CutNewPatchAction, new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: null, next: new ReleaseTrain('master', parse('10.1.0-next.3')), latest: new ReleaseTrain('10.0.x', parse('10.0.2')), diff --git a/ng-dev/release/publish/test/cut-next-prerelease.spec.ts b/ng-dev/release/publish/test/cut-next-prerelease.spec.ts index 7fdb4c46f..8887b5f3b 100644 --- a/ng-dev/release/publish/test/cut-next-prerelease.spec.ts +++ b/ng-dev/release/publish/test/cut-next-prerelease.spec.ts @@ -30,6 +30,7 @@ describe('cut next pre-release action', () => { const action = setupReleaseActionForTesting( CutNextPrereleaseAction, new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: null, next: new ReleaseTrain('master', parse('10.2.0-next.0')), latest: new ReleaseTrain('10.1.x', parse('10.1.2')), @@ -51,6 +52,7 @@ describe('cut next pre-release action', () => { const action = setupReleaseActionForTesting( CutNextPrereleaseAction, new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: null, next: new ReleaseTrain('master', parse('10.2.0-next.0')), latest: new ReleaseTrain('10.1.x', parse('10.1.0')), @@ -75,6 +77,7 @@ describe('cut next pre-release action', () => { const action = setupReleaseActionForTesting( CutNextPrereleaseAction, new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: null, next: new ReleaseTrain('master', parse('10.2.0-next.0')), latest: new ReleaseTrain('10.1.x', parse('10.1.0')), @@ -120,6 +123,7 @@ describe('cut next pre-release action', () => { const action = setupReleaseActionForTesting( CutNextPrereleaseAction, new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-next.4')), next: new ReleaseTrain('master', parse('10.2.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.2')), @@ -133,6 +137,7 @@ describe('cut next pre-release action', () => { const action = setupReleaseActionForTesting( CutNextPrereleaseAction, new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-next.4')), next: new ReleaseTrain('master', parse('10.2.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.2')), @@ -171,6 +176,7 @@ describe('cut next pre-release action', () => { const action = setupReleaseActionForTesting( CutNextPrereleaseAction, new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-rc.0')), next: new ReleaseTrain('master', parse('10.2.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.2')), @@ -184,6 +190,7 @@ describe('cut next pre-release action', () => { const action = setupReleaseActionForTesting( CutNextPrereleaseAction, new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-rc.0')), next: new ReleaseTrain('master', parse('10.2.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.2')), diff --git a/ng-dev/release/publish/test/cut-release-candidate-for-feature-freeze.spec.ts b/ng-dev/release/publish/test/cut-release-candidate-for-feature-freeze.spec.ts index 17104905d..df7f34324 100644 --- a/ng-dev/release/publish/test/cut-release-candidate-for-feature-freeze.spec.ts +++ b/ng-dev/release/publish/test/cut-release-candidate-for-feature-freeze.spec.ts @@ -22,6 +22,7 @@ describe('cut release candidate for feature-freeze action', () => { expect( await CutReleaseCandidateForFeatureFreezeAction.isActive( new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-next.1')), next: new ReleaseTrain('master', parse('10.2.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), @@ -34,6 +35,7 @@ describe('cut release candidate for feature-freeze action', () => { expect( await CutReleaseCandidateForFeatureFreezeAction.isActive( new ActiveReleaseTrains({ + exceptionalMinor: null, // No longer in feature-freeze but in release-candidate phase. releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-rc.0')), next: new ReleaseTrain('master', parse('10.2.0-next.0')), @@ -47,6 +49,7 @@ describe('cut release candidate for feature-freeze action', () => { expect( await CutReleaseCandidateForFeatureFreezeAction.isActive( new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: null, next: new ReleaseTrain('master', parse('10.1.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), @@ -59,6 +62,7 @@ describe('cut release candidate for feature-freeze action', () => { const action = setupReleaseActionForTesting( CutReleaseCandidateForFeatureFreezeAction, new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-next.1')), next: new ReleaseTrain('master', parse('10.2.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), @@ -72,6 +76,7 @@ describe('cut release candidate for feature-freeze action', () => { const action = setupReleaseActionForTesting( CutReleaseCandidateForFeatureFreezeAction, new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-next.1')), next: new ReleaseTrain('master', parse('10.2.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), diff --git a/ng-dev/release/publish/test/cut-stable.spec.ts b/ng-dev/release/publish/test/cut-stable.spec.ts index c8b4e7191..5bbb4ec8e 100644 --- a/ng-dev/release/publish/test/cut-stable.spec.ts +++ b/ng-dev/release/publish/test/cut-stable.spec.ts @@ -26,6 +26,7 @@ describe('cut stable action', () => { expect( await CutStableAction.isActive( new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-next.1')), next: new ReleaseTrain('master', parse('10.2.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), @@ -38,6 +39,7 @@ describe('cut stable action', () => { expect( await CutStableAction.isActive( new ActiveReleaseTrains({ + exceptionalMinor: null, // No longer in feature-freeze but in release-candidate phase. releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-rc.0')), next: new ReleaseTrain('master', parse('10.2.0-next.0')), @@ -51,6 +53,7 @@ describe('cut stable action', () => { expect( await CutStableAction.isActive( new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: null, next: new ReleaseTrain('master', parse('10.1.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), @@ -63,6 +66,7 @@ describe('cut stable action', () => { const action = setupReleaseActionForTesting( CutStableAction, new ActiveReleaseTrains({ + exceptionalMinor: null, // No longer in feature-freeze but in release-candidate phase. releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-rc.0')), next: new ReleaseTrain('master', parse('10.2.0-next.0')), @@ -77,6 +81,7 @@ describe('cut stable action', () => { const action = setupReleaseActionForTesting( CutStableAction, new ActiveReleaseTrains({ + exceptionalMinor: null, // No longer in feature-freeze but in release-candidate phase. releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-rc.0')), next: new ReleaseTrain('master', parse('10.2.0-next.0')), @@ -92,6 +97,7 @@ describe('cut stable action', () => { const action = setupReleaseActionForTesting( CutStableAction, new ActiveReleaseTrains({ + exceptionalMinor: null, // No longer in feature-freeze but in release-candidate phase. releaseCandidate: new ReleaseTrain('11.0.x', parse('11.0.0-rc.0')), next: new ReleaseTrain('master', parse('10.2.0-next.0')), @@ -126,6 +132,7 @@ describe('cut stable action', () => { const action = setupReleaseActionForTesting( CutStableAction, new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-rc.0')), next: new ReleaseTrain('master', parse('10.2.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), @@ -192,6 +199,7 @@ describe('cut stable action', () => { const action = setupReleaseActionForTesting( CutStableAction, new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-rc.0')), next: new ReleaseTrain('master', parse('10.2.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), diff --git a/ng-dev/release/publish/test/move-next-into-feature-freeze.spec.ts b/ng-dev/release/publish/test/move-next-into-feature-freeze.spec.ts index 1395e92c6..be60c3179 100644 --- a/ng-dev/release/publish/test/move-next-into-feature-freeze.spec.ts +++ b/ng-dev/release/publish/test/move-next-into-feature-freeze.spec.ts @@ -22,6 +22,7 @@ describe('move next into feature-freeze action', () => { expect( await MoveNextIntoFeatureFreezeAction.isActive( new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-next.1')), next: new ReleaseTrain('master', parse('10.2.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), @@ -34,6 +35,7 @@ describe('move next into feature-freeze action', () => { expect( await MoveNextIntoFeatureFreezeAction.isActive( new ActiveReleaseTrains({ + exceptionalMinor: null, // No longer in feature-freeze but in release-candidate phase. releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-rc.0')), next: new ReleaseTrain('master', parse('10.2.0-next.0')), @@ -47,6 +49,7 @@ describe('move next into feature-freeze action', () => { expect( await MoveNextIntoFeatureFreezeAction.isActive( new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: null, next: new ReleaseTrain('master', parse('10.1.0-next.2')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), @@ -59,6 +62,7 @@ describe('move next into feature-freeze action', () => { expect( await MoveNextIntoFeatureFreezeAction.isActive( new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: null, next: new ReleaseTrain('master', parse('11.0.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), @@ -71,6 +75,7 @@ describe('move next into feature-freeze action', () => { await expectBranchOffActionToRun( MoveNextIntoFeatureFreezeAction, new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: null, next: new ReleaseTrain('master', parse('10.1.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), @@ -91,6 +96,7 @@ describe('move next into feature-freeze action', () => { await expectBranchOffActionToRun( MoveNextIntoFeatureFreezeAction, new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: null, next: new ReleaseTrain('master', parse('10.1.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), @@ -109,6 +115,7 @@ describe('move next into feature-freeze action', () => { const {action, buildChangelog} = prepareBranchOffActionForChangelog( MoveNextIntoFeatureFreezeAction, new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: null, next: new ReleaseTrain('master', parse('10.1.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), @@ -149,6 +156,7 @@ describe('move next into feature-freeze action', () => { const {action, buildChangelog} = prepareBranchOffActionForChangelog( MoveNextIntoFeatureFreezeAction, new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: null, next: new ReleaseTrain('master', parse('10.1.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), diff --git a/ng-dev/release/publish/test/move-next-into-release-candidate.spec.ts b/ng-dev/release/publish/test/move-next-into-release-candidate.spec.ts index 72577bb9a..9bf2df1c7 100644 --- a/ng-dev/release/publish/test/move-next-into-release-candidate.spec.ts +++ b/ng-dev/release/publish/test/move-next-into-release-candidate.spec.ts @@ -21,6 +21,7 @@ describe('move next into release-candidate action', () => { expect( await MoveNextIntoReleaseCandidateAction.isActive( new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-next.1')), next: new ReleaseTrain('master', parse('10.2.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), @@ -33,6 +34,7 @@ describe('move next into release-candidate action', () => { expect( await MoveNextIntoReleaseCandidateAction.isActive( new ActiveReleaseTrains({ + exceptionalMinor: null, // No longer in feature-freeze but in release-candidate phase. releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-rc.0')), next: new ReleaseTrain('master', parse('10.2.0-next.0')), @@ -46,6 +48,7 @@ describe('move next into release-candidate action', () => { expect( await MoveNextIntoReleaseCandidateAction.isActive( new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: null, next: new ReleaseTrain('master', parse('11.0.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), @@ -58,6 +61,7 @@ describe('move next into release-candidate action', () => { expect( await MoveNextIntoReleaseCandidateAction.isActive( new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: null, next: new ReleaseTrain('master', parse('10.1.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), @@ -73,6 +77,7 @@ describe('move next into release-candidate action', () => { await expectBranchOffActionToRun( MoveNextIntoReleaseCandidateAction, new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: null, next: new ReleaseTrain('master', parse('10.1.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), @@ -91,6 +96,7 @@ describe('move next into release-candidate action', () => { const {action, buildChangelog} = prepareBranchOffActionForChangelog( MoveNextIntoReleaseCandidateAction, new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: null, next: new ReleaseTrain('master', parse('10.1.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), @@ -131,6 +137,7 @@ describe('move next into release-candidate action', () => { const {action, buildChangelog} = prepareBranchOffActionForChangelog( MoveNextIntoReleaseCandidateAction, new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: null, next: new ReleaseTrain('master', parse('10.1.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), @@ -163,6 +170,7 @@ describe('move next into release-candidate action', () => { await expectBranchOffActionToRun( MoveNextIntoReleaseCandidateAction, new ActiveReleaseTrains({ + exceptionalMinor: null, releaseCandidate: null, next: new ReleaseTrain('master', parse('10.1.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), diff --git a/ng-dev/release/publish/test/output-checks.spec.ts b/ng-dev/release/publish/test/output-checks.spec.ts index f3af1dbe6..7d7703a70 100644 --- a/ng-dev/release/publish/test/output-checks.spec.ts +++ b/ng-dev/release/publish/test/output-checks.spec.ts @@ -15,6 +15,7 @@ import {parse, setupReleaseActionForTesting, writePackageJson} from './test-util describe('package output checks', () => { const baseReleaseTrains = new ActiveReleaseTrains({ releaseCandidate: null, + exceptionalMinor: null, latest: new ReleaseTrain('13.0.x', parse('13.0.1')), next: new ReleaseTrain('main', parse('13.1.0-next.2')), }); diff --git a/ng-dev/release/publish/test/tag-recent-major-as-latest.spec.ts b/ng-dev/release/publish/test/tag-recent-major-as-latest.spec.ts index c1b52b807..446f51217 100644 --- a/ng-dev/release/publish/test/tag-recent-major-as-latest.spec.ts +++ b/ng-dev/release/publish/test/tag-recent-major-as-latest.spec.ts @@ -25,6 +25,7 @@ describe('tag recent major as latest action', () => { await TagRecentMajorAsLatest.isActive( new ActiveReleaseTrains({ releaseCandidate: null, + exceptionalMinor: null, next: new ReleaseTrain('master', parse('10.1.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.1')), }), @@ -50,6 +51,7 @@ describe('tag recent major as latest action', () => { await TagRecentMajorAsLatest.isActive( new ActiveReleaseTrains({ releaseCandidate: null, + exceptionalMinor: null, next: new ReleaseTrain('master', parse('10.1.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.0')), }), @@ -77,6 +79,7 @@ describe('tag recent major as latest action', () => { await TagRecentMajorAsLatest.isActive( new ActiveReleaseTrains({ releaseCandidate: null, + exceptionalMinor: null, next: new ReleaseTrain('master', parse('10.1.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.0')), }), @@ -101,6 +104,7 @@ describe('tag recent major as latest action', () => { await TagRecentMajorAsLatest.isActive( new ActiveReleaseTrains({ releaseCandidate: null, + exceptionalMinor: null, next: new ReleaseTrain('master', parse('10.1.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.0')), }), @@ -115,6 +119,7 @@ describe('tag recent major as latest action', () => { TagRecentMajorAsLatest, new ActiveReleaseTrains({ releaseCandidate: null, + exceptionalMinor: null, next: new ReleaseTrain('master', parse('10.1.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.0')), }), diff --git a/ng-dev/release/versioning/active-release-trains.ts b/ng-dev/release/versioning/active-release-trains.ts index c42fb3d90..6c23a9639 100644 --- a/ng-dev/release/versioning/active-release-trains.ts +++ b/ng-dev/release/versioning/active-release-trains.ts @@ -11,23 +11,32 @@ import semver from 'semver'; import {ReleaseTrain} from './release-trains.js'; import { getBranchesForMajorVersions, - getVersionOfBranch, + getVersionInfoForBranch, ReleaseRepoWithApi, VersionBranch, } from './version-branches.js'; +interface DeterminationCheckFns { + canHaveExceptionalMinor: (rc: ReleaseTrain | null) => boolean; + isValidReleaseCandidateVersion: (v: semver.SemVer) => boolean; + isValidExceptionalMinorVersion: (v: semver.SemVer, rc: ReleaseTrain | null) => boolean; +} + /** The active release trains for a project. */ export class ActiveReleaseTrains { /** Release-train currently in the "release-candidate" or "feature-freeze" phase. */ - readonly releaseCandidate: ReleaseTrain | null = this.trains.releaseCandidate || null; + readonly releaseCandidate: ReleaseTrain | null = this.trains.releaseCandidate; /** Release-train in the `next` phase. */ readonly next: ReleaseTrain = this.trains.next; /** Release-train currently in the "latest" phase. */ readonly latest: ReleaseTrain = this.trains.latest; + /** Release-train for an exceptional minor in progress. */ + readonly exceptionalMinor: ReleaseTrain | null = this.trains.exceptionalMinor; constructor( private trains: { releaseCandidate: ReleaseTrain | null; + exceptionalMinor: ReleaseTrain | null; next: ReleaseTrain; latest: ReleaseTrain; }, @@ -47,45 +56,59 @@ export class ActiveReleaseTrains { /** Fetches the active release trains for the configured project. */ async function fetchActiveReleaseTrains(repo: ReleaseRepoWithApi): Promise { const nextBranchName = repo.nextBranchName; - const nextVersion = await getVersionOfBranch(repo, nextBranchName); + const {version: nextVersion} = await getVersionInfoForBranch(repo, nextBranchName); const next = new ReleaseTrain(nextBranchName, nextVersion); - const majorVersionsToConsider: number[] = []; - let expectedReleaseCandidateMajor: number; - - // If the `next` branch (i.e. `main` branch) is for an upcoming major version, we - // know that there is no patch branch or feature-freeze/release-candidate branch for this major - // digit. If the current `next` version is the first minor of a major version, we know that - // the feature-freeze/release-candidate branch can only be the actual major branch. The - // patch branch is based on that, either the actual major branch or the last minor from the - // preceding major version. In all other cases, the patch branch and feature-freeze or - // release-candidate branch are part of the same major version. Consider the following: - // - // CASE 1. next: 11.0.0-next.0: patch and feature-freeze/release-candidate can only be - // most recent `10.<>.x` branches. The FF/RC branch can only be the last-minor of v10. - // CASE 2. next: 11.1.0-next.0: patch can be either `11.0.x` or last-minor in v10 based - // on whether there is a feature-freeze/release-candidate branch (=> `11.0.x`). - // CASE 3. next: 10.6.0-next.0: patch can be either `10.5.x` or `10.4.x` based on whether - // there is a feature-freeze/release-candidate branch (=> `10.5.x`) + const majorVersionsToFetch: number[] = []; + const checks: DeterminationCheckFns = { + canHaveExceptionalMinor: () => false, + isValidReleaseCandidateVersion: () => false, + isValidExceptionalMinorVersion: () => false, + }; + if (nextVersion.minor === 0) { - expectedReleaseCandidateMajor = nextVersion.major - 1; - majorVersionsToConsider.push(nextVersion.major - 1); + // CASE 1: Next is for a new major. Potential release-candidate/feature-freeze train + // can only be for the previous major. Usually patch is in the same minor as for RC/FF, + // but technically two majors can be in the works, so we also need to consider the second + // previous major + + // Example scenarios: + // * next = v15.0.x, rc/ff = v14.4.x, exc-minor = disallowed, patch = v14.3.x + // * next = v15.0.x rc/ff = null, exc-minor = null, patch = v14.3.x + // * next = v15.0.x rc/ff = null, exc-minor = v14.4.x, patch = v14.3.x + // Cases where two majors are in the works (unlikely- but technically possible) + // * next = v15.0.x, rc/ff = v14.0.0, exc-minor = null, patch = v13.2.x + // * next = v15.0.x, rc/ff = v14.0.0, exc-minor = v13.3.x, patch = v13.2.x + majorVersionsToFetch.push(nextVersion.major - 1, nextVersion.major - 2); + checks.isValidReleaseCandidateVersion = (v) => v.major === nextVersion.major - 1; + checks.canHaveExceptionalMinor = (rc) => rc === null || rc.isMajor; + checks.isValidExceptionalMinorVersion = (v, rc) => + v.major === (rc === null ? nextVersion.major : rc.version.major) - 1; } else if (nextVersion.minor === 1) { - expectedReleaseCandidateMajor = nextVersion.major; - majorVersionsToConsider.push(nextVersion.major, nextVersion.major - 1); + // CASE 2: Next is for the first minor of a major release. Potential release-candidate/feature-freeze + // train is always guaranteed to be in the same major. Depending on if there is RC/FF, the patch train + // would be in the same major, or in the previous one. Example scenarios: + // * next = v15.1.x, rc/ff = v15.0.x, exc-minor = null, patch = v14.5.x + // * next = v15.1.x, rc/ff = v15.0.x, exc-minor = v14.6.x, patch = v14.5.x + // * next = v15.1.x, rc/ff = null, exc-minor = disallowed, patch = v15.0.x + majorVersionsToFetch.push(nextVersion.major, nextVersion.major - 1); + checks.isValidReleaseCandidateVersion = (v) => v.major === nextVersion.major; + checks.canHaveExceptionalMinor = (rc) => rc !== null && rc.isMajor; + checks.isValidExceptionalMinorVersion = (v, rc) => v.major === rc!.version.major - 1; } else { - expectedReleaseCandidateMajor = nextVersion.major; - majorVersionsToConsider.push(nextVersion.major); + // CASE 3: Next for a normal minor (other cases as above). Potential release-candidate/feature-freeze + // train and the patch train are always guaranteed to be in the same major. Example scenarios: + // * next = v15.2.x, rc/ff = v15.1.x, exc-minor = disallowed, patch = v15.0.x + // * next = v15.2.x, rc/ff = null, exc-minor = disallowed, patch = v15.1.x + majorVersionsToFetch.push(nextVersion.major); + checks.isValidReleaseCandidateVersion = (v) => v.major === nextVersion.major; + checks.canHaveExceptionalMinor = () => false; } // Collect all version-branches that should be considered for the latest version-branch, - // or the feature-freeze/release-candidate. - const branches = await getBranchesForMajorVersions(repo, majorVersionsToConsider); - const {latest, releaseCandidate} = await findActiveReleaseTrainsFromVersionBranches( - repo, - nextVersion, - branches, - expectedReleaseCandidateMajor, - ); + // a potential exceptional minor train or feature-freeze/release-candidate train. + const branches = await getBranchesForMajorVersions(repo, majorVersionsToFetch); + const {latest, releaseCandidate, exceptionalMinor} = + await findActiveReleaseTrainsFromVersionBranches(repo, next, branches, checks); if (latest === null) { throw Error( @@ -94,39 +117,44 @@ async function fetchActiveReleaseTrains(repo: ReleaseRepoWithApi): Promise { // Version representing the release-train currently in the next phase. Note that we ignore // patch and pre-release segments in order to be able to compare the next release train to // other release trains from version branches (which follow the `N.N.x` pattern). - const nextReleaseTrainVersion = semver.parse(`${nextVersion.major}.${nextVersion.minor}.0`)!; + const nextReleaseTrainVersion = semver.parse(`${next.version.major}.${next.version.minor}.0`)!; const nextBranchName = repo.nextBranchName; let latest: ReleaseTrain | null = null; let releaseCandidate: ReleaseTrain | null = null; + let exceptionalMinor: ReleaseTrain | null = null; // Iterate through the captured branches and find the latest non-prerelease branch and a // potential release candidate branch. From the collected branches we iterate descending // order (most recent semantic version-branch first). The first branch is either the latest - // active version branch (i.e. patch) or a feature-freeze/release-candidate branch. A FF/RC - // branch cannot be older than the latest active version-branch, so we stop iterating once - // we found such a branch. Otherwise, if we found a FF/RC branch, we continue looking for the - // next version-branch as that one is supposed to be the latest active version-branch. If it - // is not, then an error will be thrown due to two FF/RC branches existing at the same time. + // active version branch (i.e. patch), a feature-freeze/release-candidate branch (ff/rc) or + // an in-progress exceptional minor: + // * A FF/RC or exceptional minor branch cannot be more recent than the current next + // version-branch, so we stop iterating once we found such a branch. + // * As soon as we discover a version-branch not being an RC/FF or exceptional minor, + // we know it is the active patch branch. We stop looking further. + // * If we find a FF/RC branch, we continue looking for the next version-branch as + // that one has to be an exceptional minor, or the latest active version-branch. for (const {name, parsed} of branches) { // It can happen that version branches have been accidentally created which are more recent - // than the release-train in the next branch (i.e. `master`). We could ignore such branches + // than the release-train in the next branch (i.e. `main`). We could ignore such branches // silently, but it might be symptomatic for an outdated version in the `next` branch, or an // accidentally created branch by the caretaker. In either way we want to raise awareness. if (semver.gt(parsed, nextReleaseTrainVersion)) { @@ -144,29 +172,64 @@ async function findActiveReleaseTrainsFromVersionBranches( ); } - const version = await getVersionOfBranch(repo, name); + const {version, isExceptionalMinor} = await getVersionInfoForBranch(repo, name); const releaseTrain = new ReleaseTrain(name, version); const isPrerelease = version.prerelease[0] === 'rc' || version.prerelease[0] === 'next'; + if (isExceptionalMinor) { + if (exceptionalMinor !== null) { + throw Error( + `Unable to determine latest release-train. Found an additional exceptional minor ` + + `version branch: "${name}". Already discovered: ${exceptionalMinor.branchName}.`, + ); + } + if (!checks.canHaveExceptionalMinor(releaseCandidate)) { + throw Error( + `Unable to determine latest release-train. Found an unexpected exceptional minor ` + + `version branch: "${name}". No exceptional minor is currently allowed.`, + ); + } + if (!checks.isValidExceptionalMinorVersion(version, releaseCandidate)) { + throw Error( + `Unable to determine latest release-train. Found an invalid exceptional ` + + `minor version branch: "${name}". Invalid version: ${version}.`, + ); + } + exceptionalMinor = releaseTrain; + continue; + } + if (isPrerelease) { + if (exceptionalMinor !== null) { + throw Error( + `Unable to determine latest release-train. Discovered a feature-freeze/release-candidate ` + + `version branch (${name}) that is older than an in-progress exceptional ` + + `minor (${exceptionalMinor.branchName}).`, + ); + } if (releaseCandidate !== null) { throw Error( `Unable to determine latest release-train. Found two consecutive ` + - `branches in feature-freeze/release-candidate phase. Did not expect both "${name}" ` + - `and "${releaseCandidate.branchName}" to be in feature-freeze/release-candidate mode.`, + `pre-release version branches. No exceptional minors are allowed currently, and ` + + `there cannot be multiple feature-freeze/release-candidate branches: "${name}".`, ); - } else if (version.major !== expectedReleaseCandidateMajor) { + } + if (!checks.isValidReleaseCandidateVersion(version)) { throw Error( `Discovered unexpected old feature-freeze/release-candidate branch. Expected no ` + `version-branch in feature-freeze/release-candidate mode for v${version.major}.`, ); } releaseCandidate = releaseTrain; - } else { - latest = releaseTrain; - break; + continue; } + + // The first non-prerelease and non-exceptional-minor branch is always picked up + // as the release-train for `latest`. Once we discovered the latest release train, + // we skip looking further as there are no possible older active release trains. + latest = releaseTrain; + break; } - return {releaseCandidate, latest}; + return {releaseCandidate: releaseCandidate, exceptionalMinor, latest}; } diff --git a/ng-dev/release/versioning/test/active-release-trains.spec.ts b/ng-dev/release/versioning/test/active-release-trains.spec.ts index 57d933db9..ad83d3df2 100644 --- a/ng-dev/release/versioning/test/active-release-trains.spec.ts +++ b/ng-dev/release/versioning/test/active-release-trains.spec.ts @@ -1,16 +1,294 @@ import {ActiveReleaseTrains} from '../active-release-trains.js'; -import {getNextBranchName, ReleaseRepoWithApi} from '../version-branches.js'; +import { + exceptionalMinorPackageIndicator, + getNextBranchName, + ReleaseRepoWithApi, +} from '../version-branches.js'; import {GithubClient} from '../../../utils/git/github.js'; import nock from 'nock'; import {fakeGithubPaginationResponse, matchesVersion} from '../../../utils/testing/index.js'; import {GithubConfig} from '../../../utils/config.js'; +import {ReleaseTrain} from '../release-trains.js'; + +interface FakeBranch { + name: string; + version: string; + isExceptionalMinor: boolean; +} + +interface Scenario { + name: string; + branches: FakeBranch[]; + expected: + | { + next: jasmine.ObjectContaining; + latest: jasmine.ObjectContaining; + releaseCandidate: jasmine.ObjectContaining | null; + exceptionalMinor: jasmine.ObjectContaining | null; + } + | RegExp; +} + +const scenarios: Scenario[] = [ + { + name: 'Basic scenario. Next minor in the works.', + branches: [create('main', '14.4.0-next.10'), create('14.3.x', '14.3.2')], + expected: { + next: matchesTrain('main', '14.4.0-next.10'), + latest: matchesTrain('14.3.x', '14.3.2'), + releaseCandidate: null, + exceptionalMinor: null, + }, + }, + { + name: 'Basic scenario. Next major in the works.', + branches: [create('main', '15.0.0-next.1'), create('14.3.x', '14.3.2')], + expected: { + next: matchesTrain('main', '15.0.0-next.1'), + latest: matchesTrain('14.3.x', '14.3.2'), + releaseCandidate: null, + exceptionalMinor: null, + }, + }, + { + name: 'Minor in feature-freeze, new minor as next train.', + branches: [ + create('main', '15.2.0-next.2'), + create('15.1.x', '15.1.0-next.1'), + create('15.0.x', '15.0.2'), + ], + expected: { + next: matchesTrain('main', '15.2.0-next.2'), + releaseCandidate: matchesTrain('15.1.x', '15.1.0-next.1'), + latest: matchesTrain('15.0.x', '15.0.2'), + exceptionalMinor: null, + }, + }, + { + name: 'Major in feature-freeze, new minor as next train.', + branches: [ + create('main', '15.1.0-next.2'), + create('15.0.x', '15.0.0-next.1'), + create('14.5.x', '14.5.6'), + ], + expected: { + next: matchesTrain('main', '15.1.0-next.2'), + releaseCandidate: matchesTrain('15.0.x', '15.0.0-next.1'), + latest: matchesTrain('14.5.x', '14.5.6'), + exceptionalMinor: null, + }, + }, + { + name: 'Minor in release-candidate, new minor as next train.', + branches: [ + create('main', '15.2.0-next.2'), + create('15.1.x', '15.1.0-rc.2'), + create('15.0.x', '15.0.3'), + ], + expected: { + next: matchesTrain('main', '15.2.0-next.2'), + releaseCandidate: matchesTrain('15.1.x', '15.1.0-rc.2'), + latest: matchesTrain('15.0.x', '15.0.3'), + exceptionalMinor: null, + }, + }, + { + name: 'Major in release-candidate, new minor as next train.', + branches: [ + create('main', '15.1.0-next.4'), + create('15.0.x', '15.0.0-rc.3'), + create('14.6.x', '14.6.0'), + ], + expected: { + next: matchesTrain('main', '15.1.0-next.4'), + releaseCandidate: matchesTrain('15.0.x', '15.0.0-rc.3'), + latest: matchesTrain('14.6.x', '14.6.0'), + exceptionalMinor: null, + }, + }, + { + name: 'Two majors in-progress at the same time', + branches: [ + create('main', '16.0.0-next.4'), + create('15.0.x', '15.0.0-rc.3'), + create('14.6.x', '14.6.0'), + ], + expected: { + next: matchesTrain('main', '16.0.0-next.4'), + releaseCandidate: matchesTrain('15.0.x', '15.0.0-rc.3'), + latest: matchesTrain('14.6.x', '14.6.0'), + exceptionalMinor: null, + }, + }, + { + name: 'Major in feature-freeze & exceptional minor as `-next`', + branches: [ + create('main', '15.1.0-next.4'), + create('15.0.x', '15.0.0-next.3'), + create('14.6.x', '14.6.0-next.0', {exceptionalMinor: true}), + create('14.5.x', '14.5.4'), + ], + expected: { + next: matchesTrain('main', '15.1.0-next.4'), + releaseCandidate: matchesTrain('15.0.x', '15.0.0-next.3'), + exceptionalMinor: matchesTrain('14.6.x', '14.6.0-next.0'), + latest: matchesTrain('14.5.x', '14.5.4'), + }, + }, + { + name: 'Major in feature-freeze & exceptional minor in RC', + branches: [ + create('main', '15.1.0-next.4'), + create('15.0.x', '15.0.0-next.3'), + create('14.6.x', '14.6.0-rc.1', {exceptionalMinor: true}), + create('14.5.x', '14.5.4'), + ], + expected: { + next: matchesTrain('main', '15.1.0-next.4'), + releaseCandidate: matchesTrain('15.0.x', '15.0.0-next.3'), + exceptionalMinor: matchesTrain('14.6.x', '14.6.0-rc.1'), + latest: matchesTrain('14.5.x', '14.5.4'), + }, + }, + { + name: 'Major in the next train & exceptional minor', + branches: [ + create('main', '15.0.0-next.4'), + create('14.6.x', '14.6.0-next.3', {exceptionalMinor: true}), + create('14.5.x', '14.5.4'), + ], + expected: { + next: matchesTrain('main', '15.0.0-next.4'), + // Technically an exceptional minor could be created and just be put into + // the `rc/ff` train. Since we need a second train for exceptional minors + // when a major is already in RC, we do not want to mismatch here. Exceptional + // minors, when created, should always end up in their own dedicated train. + releaseCandidate: null, + exceptionalMinor: matchesTrain('14.6.x', '14.6.0-next.3'), + latest: matchesTrain('14.5.x', '14.5.4'), + }, + }, + { + name: 'Major in the next train & exceptional minor in RC', + branches: [ + create('main', '15.0.0-next.4'), + create('14.6.x', '14.6.0-rc.1', {exceptionalMinor: true}), + create('14.5.x', '14.5.4'), + ], + expected: { + next: matchesTrain('main', '15.0.0-next.4'), + // Technically an exceptional minor could be created and just be put into + // the `rc/ff` train. Since we need a second train for exceptional minors + // when a major is already in RC, we do not want to mismatch here. Exceptional + // minors, when created, should always end up in their own dedicated train. + releaseCandidate: null, + exceptionalMinor: matchesTrain('14.6.x', '14.6.0-rc.1'), + latest: matchesTrain('14.5.x', '14.5.4'), + }, + }, + // --- + // Special cases to test faulty setup: + // --- + { + name: 'Basic scenario. Old `master` branch existing with older version', + branches: [ + create('master', '5.0.0-next.10'), + create('main', '14.4.0-next.10'), + create('14.3.x', '14.3.2'), + ], + expected: { + next: matchesTrain('main', '14.4.0-next.10'), + latest: matchesTrain('14.3.x', '14.3.2'), + releaseCandidate: null, + exceptionalMinor: null, + }, + }, + { + name: 'Unexpected exceptional minor when no major is in-progress', + branches: [ + create('main', '14.5.0-next.10'), + create('14.4.x', '14.4.0-next.3', {exceptionalMinor: true}), + create('14.3.x', '14.3.2'), + ], + expected: /Found an unexpected exceptional minor.+No exceptional minor is currently allowed/, + }, + { + name: 'Unexpected multiple release-candidates', + branches: [ + create('main', '14.5.0-next.10'), + create('14.4.x', '14.4.0-next.3'), + create('14.3.x', '14.3.0-next.4'), + create('14.2.x', '14.2.2'), + ], + expected: /there cannot be multiple feature-freeze\/release-candidate branches/, + }, + { + name: 'Unexpected multiple exceptional minors', + branches: [ + create('main', '15.0.0-next.10'), + create('14.4.x', '14.4.0-next.3', {exceptionalMinor: true}), + create('14.3.x', '14.3.0-next.4', {exceptionalMinor: true}), + create('14.2.x', '14.2.2'), + ], + expected: /Found an additional exceptional minor version branch:/, + }, + { + name: 'Unexpected order of exceptional minor. Exceptional minor is more recent than RC/FF', + branches: [ + create('main', '15.0.0-next.10'), + create('14.4.x', '14.4.0-next.3', {exceptionalMinor: true}), + create('14.3.x', '14.3.0-next.4'), + create('14.2.x', '14.2.2'), + ], + expected: + /Discovered a feature-freeze\/release-candidate version branch.+is older than an in-progress exceptional minor \(14.4.x\)/, + }, + { + name: 'No patch branch found', + branches: [create('main', '15.0.0-next.10'), create('14.3.x', '14.3.0-next.4')], + expected: + /Unable to determine the latest release-train. The following branches have been considered:/, + }, + { + name: 'Should not continue checking branches after found "latest" train.', + branches: [ + create('main', '15.0.0-next.10'), + create('14.3.x', '14.3.0'), + // this is a broken branch- but should not be picked up incorrectly. + create('14.2.x', '14.2.0-next.3'), + ], + expected: { + next: matchesTrain('main', '15.0.0-next.10'), + latest: matchesTrain('14.3.x', '14.3.0'), + releaseCandidate: null, + exceptionalMinor: null, + }, + }, +]; + +/** Creates a fake branch fixture. */ +function create( + branchName: string, + version: string, + opts?: {exceptionalMinor: boolean}, +): FakeBranch { + return {name: branchName, version, isExceptionalMinor: opts?.exceptionalMinor === true}; +} + +/** Creates a jasmine matcher for matching a release train. */ +function matchesTrain(branchName: string, version: string): jasmine.ObjectContaining { + return jasmine.objectContaining({ + branchName, + version: matchesVersion(version), + }); +} describe('active release train determination', () => { let api: GithubClient; let repo: ReleaseRepoWithApi; beforeEach(() => { - setup({owner: 'angular', name: 'dev-infra-test', mainBranchName: 'master'}); + setup({owner: 'angular', name: 'dev-infra-test', mainBranchName: 'main'}); }); afterEach(() => nock.cleanAll()); @@ -35,25 +313,22 @@ describe('active release train determination', () => { * Mocks a branch `package.json` version API request. * https://docs.github.com/en/rest/reference/repos#get-repository-content. */ - function interceptBranchVersionRequest(branchName: string, version: string) { + function interceptBranchVersionRequest( + branchName: string, + version: string, + isExceptionalMinor: boolean, + ) { + const pkgJson: {version: string; [exceptionalMinorPackageIndicator]?: boolean} = {version}; + if (isExceptionalMinor) { + pkgJson[exceptionalMinorPackageIndicator] = true; + } + nock(getRepoApiRequestUrl()) .get('/contents/%2Fpackage.json') .query((params) => params.ref === branchName) - .reply(200, {content: Buffer.from(JSON.stringify({version})).toString('base64')}); - } - - /** - * Mocks a repository branch list API request. - * https://docs.github.com/en/rest/reference/repos#list-branches. - */ - function interceptBranchesListRequest(branches: string[]) { - nock(getRepoApiRequestUrl()) - .get('/branches') - .query(true) - .reply( - 200, - branches.slice(0, 29).map((name) => ({name})), - ); + .reply(200, { + content: Buffer.from(JSON.stringify(pkgJson)).toString('base64'), + }); } /** @@ -68,140 +343,24 @@ describe('active release train determination', () => { ); } - it('should detect the next release train', async () => { - interceptBranchVersionRequest('master', '10.1.0-next.1'); - interceptBranchVersionRequest('10.0.x', '10.0.1'); - interceptBranchesListRequest(['10.0.x', '9.5.x']); - - const active = await ActiveReleaseTrains.fetch(repo); - expect(active.releaseCandidate).toBe(null); - expect(active.next).toEqual( - jasmine.objectContaining({ - branchName: 'master', - version: matchesVersion('10.1.0-next.1'), - }), - ); - }); - - it('should detect the next release train if a main branch is configured', async () => { - setup({owner: 'angular', name: 'dev-infra-test', mainBranchName: 'main'}); - - // Note: We keep the `master` branch to ensure the logic does not accidentally - // pick up an older `master` branch if the `main` branch is configured. - interceptBranchVersionRequest('master', '0.4.0-old-branch.1'); - interceptBranchVersionRequest('main', '10.1.0-next.1'); - interceptBranchVersionRequest('10.0.x', '10.0.1'); - interceptBranchesListRequest(['10.0.x', '9.5.x']); - - const active = await ActiveReleaseTrains.fetch(repo); - expect(active.releaseCandidate).toBe(null); - expect(active.next).toEqual( - jasmine.objectContaining({ - branchName: 'main', - version: matchesVersion('10.1.0-next.1'), - }), - ); - }); + for (const scenario of scenarios) { + it(`scenario: ${scenario.name}`, async () => { + for (const branch of scenario.branches) { + interceptBranchVersionRequest(branch.name, branch.version, branch.isExceptionalMinor); + } + interceptBranchesListRequestWithPagination(scenario.branches.map((b) => b.name)); - it('should deal with branch pagination response from github', async () => { - interceptBranchVersionRequest('master', '10.1.0-next.1'); - interceptBranchVersionRequest('10.0.x', '10.0.1'); - - // Note: We add a few more branches here to ensure that branches API requests are - // paginated properly. In Angular projects, there are usually many branches so that - // pagination is ultimately needed to detect the active release trains. - // See: https://github.com/angular/angular/commit/261b060fa168754db00248d1c5c9574bb19a72b4. - interceptBranchesListRequestWithPagination(['8.4.x', '9.5.x', '10.0.x']); - - const active = await ActiveReleaseTrains.fetch(repo); - expect(active.latest).toEqual( - jasmine.objectContaining({ - branchName: '10.0.x', - version: matchesVersion('10.0.1'), - }), - ); - }); - - describe('without release-candidate/feature-freeze', () => { - it('should detect the latest release train', async () => { - interceptBranchVersionRequest('master', '10.1.0-next.1'); - interceptBranchVersionRequest('10.0.x', '10.0.1'); - interceptBranchesListRequest(['10.0.x']); - - const active = await ActiveReleaseTrains.fetch(repo); - expect(active.releaseCandidate).toBe(null); - expect(active.latest).toEqual( - jasmine.objectContaining({ - branchName: '10.0.x', - version: matchesVersion('10.0.1'), - }), - ); - }); - }); - - describe('with release-candidate train', () => { - it('should detect release-candidate train', async () => { - interceptBranchVersionRequest('master', '10.2.0-next.1'); - interceptBranchVersionRequest('10.1.x', '10.1.0-rc.2'); - interceptBranchVersionRequest('10.0.x', '10.0.1'); - interceptBranchesListRequest(['10.1.x', '10.0.x']); - - const active = await ActiveReleaseTrains.fetch(repo); - expect(active.releaseCandidate).toEqual( - jasmine.objectContaining({ - branchName: '10.1.x', - version: matchesVersion('10.1.0-rc.2'), - }), - ); - }); - - it('should detect the latest release train', async () => { - interceptBranchVersionRequest('master', '10.2.0-next.1'); - interceptBranchVersionRequest('10.1.x', '10.1.0-rc.2'); - interceptBranchVersionRequest('10.0.x', '10.0.1'); - interceptBranchesListRequest(['10.1.x', '10.0.x']); - - const active = await ActiveReleaseTrains.fetch(repo); - expect(active.releaseCandidate).not.toBe(null); - expect(active.latest).toEqual( - jasmine.objectContaining({ - branchName: '10.0.x', - version: matchesVersion('10.0.1'), - }), - ); - }); - }); - - describe('with feature-freeze train', () => { - it('should detect feature-freeze train', async () => { - interceptBranchVersionRequest('master', '10.2.0-next.1'); - interceptBranchVersionRequest('10.1.x', '10.1.0-next.2'); - interceptBranchVersionRequest('10.0.x', '10.0.1'); - interceptBranchesListRequest(['10.1.x', '10.0.x']); - - const active = await ActiveReleaseTrains.fetch(repo); - expect(active.releaseCandidate).toEqual( - jasmine.objectContaining({ - branchName: '10.1.x', - version: matchesVersion('10.1.0-next.2'), - }), - ); + if (scenario.expected instanceof RegExp) { + await expectAsync(ActiveReleaseTrains.fetch(repo)).toBeRejectedWithError(scenario.expected); + } else { + const active = await ActiveReleaseTrains.fetch(repo); + expect({ + next: active.next, + latest: active.latest, + releaseCandidate: active.releaseCandidate, + exceptionalMinor: active.exceptionalMinor, + }).toEqual(scenario.expected); + } }); - - it('should detect the latest release train', async () => { - interceptBranchVersionRequest('master', '10.2.0-next.1'); - interceptBranchVersionRequest('10.1.x', '10.1.0-next.2'); - interceptBranchVersionRequest('10.0.x', '10.0.1'); - interceptBranchesListRequest(['10.1.x', '10.0.x']); - - const active = await ActiveReleaseTrains.fetch(repo); - expect(active.releaseCandidate).not.toBe(null); - expect(active.latest).toEqual( - jasmine.objectContaining({ - branchName: '10.0.x', - version: matchesVersion('10.0.1'), - }), - ); - }); - }); + } }); diff --git a/ng-dev/release/versioning/version-branches.ts b/ng-dev/release/versioning/version-branches.ts index 9cdd72f07..3edf878de 100644 --- a/ng-dev/release/versioning/version-branches.ts +++ b/ng-dev/release/versioning/version-branches.ts @@ -30,9 +30,18 @@ export interface VersionBranch { parsed: semver.SemVer; } +/** Describes the concrete version of a version branch. */ +export interface VersionInfo { + version: semver.SemVer; + isExceptionalMinor: boolean; +} + /** Regular expression that matches version-branches. */ const versionBranchNameRegex = /^(\d+)\.(\d+)\.x$/; +/** Field in `package.json` that is used to indicate an in-progress exceptional minor. */ +export const exceptionalMinorPackageIndicator = '__ngDevExceptionalMinor__'; + /** * Gets the name of the next branch from the Github configuration. * @@ -43,11 +52,11 @@ export function getNextBranchName(github: GithubConfig): string { return github.mainBranchName; } -/** Gets the version of a given branch by reading the `package.json` upstream. */ -export async function getVersionOfBranch( +/** Gets the version info for a branch by reading the `package.json` upstream. */ +export async function getVersionInfoForBranch( repo: ReleaseRepoWithApi, branchName: string, -): Promise { +): Promise { const {data} = await repo.api.repos.getContent({ owner: repo.owner, repo: repo.name, @@ -60,15 +69,19 @@ export async function getVersionOfBranch( if (!content) { throw Error(`Unable to read "package.json" file from repository.`); } - const {version} = JSON.parse(Buffer.from(content, 'base64').toString()) as { + const pkgJson = JSON.parse(Buffer.from(content, 'base64').toString()) as { version: string; + [exceptionalMinorPackageIndicator]?: boolean; [key: string]: any; }; - const parsedVersion = semver.parse(version); + const parsedVersion = semver.parse(pkgJson.version); if (parsedVersion === null) { throw Error(`Invalid version detected in following branch: ${branchName}.`); } - return parsedVersion; + return { + version: parsedVersion, + isExceptionalMinor: pkgJson[exceptionalMinorPackageIndicator] === true, + }; } /** Whether the given branch corresponds to a version branch. */ @@ -76,17 +89,6 @@ export function isVersionBranch(branchName: string): boolean { return versionBranchNameRegex.test(branchName); } -/** - * Converts a given version-branch into a SemVer version that can be used with SemVer - * utilities. e.g. to determine semantic order, extract major digit, compare. - * - * For example `10.0.x` will become `10.0.0` in SemVer. The patch digit is not - * relevant but needed for parsing. SemVer does not allow `x` as patch digit. - */ -export function getVersionForVersionBranch(branchName: string): semver.SemVer | null { - return semver.parse(branchName.replace(versionBranchNameRegex, '$1.$2.0')); -} - /** * Gets the version branches for the specified major versions in descending * order. i.e. latest version branches first. @@ -118,3 +120,14 @@ export async function getBranchesForMajorVersions( // Sort captured version-branches in descending order. return branches.sort((a, b) => semver.rcompare(a.parsed, b.parsed)); } + +/** + * Converts a given version-branch into a SemVer version that can be used with SemVer + * utilities. e.g. to determine semantic order, extract major digit, compare. + * + * For example `10.0.x` will become `10.0.0` in SemVer. The patch digit is not + * relevant but needed for parsing. SemVer does not allow `x` as patch digit. + */ +function getVersionForVersionBranch(branchName: string): semver.SemVer | null { + return semver.parse(branchName.replace(versionBranchNameRegex, '$1.$2.0')); +}