refactor(dev-infra): move common versioning tooling to shared location (#38656)

We initially added logic for determining active release trains into
the merge script. Given we now build more tools that rely on this
information, we move the logic into a more general "versioning" folder
that can contain common logic following the versioning document for the
Angular organization.

PR Close #38656
This commit is contained in:
Paul Gschwendtner
2020-09-09 14:42:34 +02:00
committed by Alex Rickabaugh
parent 617858df61
commit 3e9986871c
14 changed files with 320 additions and 178 deletions

View File

@ -11,6 +11,8 @@ ts_library(
visibility = ["//dev-infra:__subpackages__"],
deps = [
"//dev-infra/commit-message",
"//dev-infra/release/config",
"//dev-infra/release/versioning",
"//dev-infra/utils",
"@npm//@octokit/rest",
"@npm//@types/inquirer",
@ -28,6 +30,8 @@ ts_library(
srcs = glob(["**/*.spec.ts"]),
deps = [
":merge",
"//dev-infra/release/config",
"//dev-infra/release/versioning",
"//dev-infra/utils",
"@npm//@types/jasmine",
"@npm//@types/node",

View File

@ -1,221 +0,0 @@
/**
* @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 {GithubClient, GithubRepo} from '../../../utils/git/github';
/** Type describing a Github repository with corresponding API client. */
export interface GithubRepoWithApi extends GithubRepo {
/** API client that can access the repository. */
api: GithubClient;
}
/** Type describing a version-branch. */
export interface VersionBranch {
/** Name of the branch in Git. e.g. `10.0.x`. */
name: string;
/**
* Parsed SemVer version for the version-branch. Version branches technically do
* not follow the SemVer format, but we can have representative SemVer versions
* that can be used for comparisons, sorting and other checks.
*/
parsed: semver.SemVer;
}
/** Type describing a release-train. */
export interface ReleaseTrain {
/** Name of the branch for this release-train. */
branchName: string;
/** Current latest version for this release train. */
version: semver.SemVer;
}
/** Branch name for the `next` branch. */
export const nextBranchName = 'master';
/** Regular expression that matches version-branches for a release-train. */
const releaseTrainBranchNameRegex = /(\d+)\.(\d+)\.x/;
/**
* Fetches the active release train and its branches for the specified major version. i.e.
* the latest active release-train branch name is resolved and an optional version-branch for
* a currently active feature-freeze/release-candidate release-train.
*/
export async function fetchActiveReleaseTrainBranches(
repo: GithubRepoWithApi, nextVersion: semver.SemVer): Promise<{
/** Release-train currently in active release-candidate/feature-freeze phase. */
releaseCandidate: ReleaseTrain | null,
/** Latest non-prerelease release train (i.e. for the patch branch). */
latest: ReleaseTrain
}> {
const majorVersionsToConsider: number[] = [];
let expectedReleaseCandidateMajor: number;
// If the `next` branch (i.e. `master` 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`)
if (nextVersion.minor === 0) {
expectedReleaseCandidateMajor = nextVersion.major - 1;
majorVersionsToConsider.push(nextVersion.major - 1);
} else if (nextVersion.minor === 1) {
expectedReleaseCandidateMajor = nextVersion.major;
majorVersionsToConsider.push(nextVersion.major, nextVersion.major - 1);
} else {
expectedReleaseCandidateMajor = nextVersion.major;
majorVersionsToConsider.push(nextVersion.major);
}
// 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);
if (latest === null) {
throw Error(
`Unable to determine the latest release-train. The following branches ` +
`have been considered: [${branches.map(b => b.name).join(', ')}]`);
}
return {releaseCandidate, latest};
}
/** Gets the version of a given branch by reading the `package.json` upstream. */
export async function getVersionOfBranch(
repo: GithubRepoWithApi, branchName: string): Promise<semver.SemVer> {
const {data} = await repo.api.repos.getContents(
{owner: repo.owner, repo: repo.name, path: '/package.json', ref: branchName});
const {version} = JSON.parse(Buffer.from(data.content, 'base64').toString());
const parsedVersion = semver.parse(version);
if (parsedVersion === null) {
throw Error(`Invalid version detected in following branch: ${branchName}.`);
}
return parsedVersion;
}
/** Whether the given branch corresponds to a release-train branch. */
export function isReleaseTrainBranch(branchName: string): boolean {
return releaseTrainBranchNameRegex.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 getVersionForReleaseTrainBranch(branchName: string): semver.SemVer|null {
// Convert a given version-branch into a SemVer version that can be used
// with the SemVer utilities. i.e. to determine semantic order.
return semver.parse(branchName.replace(releaseTrainBranchNameRegex, '$1.$2.0'));
}
/**
* Gets the version branches for the specified major versions in descending
* order. i.e. latest version branches first.
*/
export async function getBranchesForMajorVersions(
repo: GithubRepoWithApi, majorVersions: number[]): Promise<VersionBranch[]> {
const {data: branchData} =
await repo.api.repos.listBranches({owner: repo.owner, repo: repo.name, protected: true});
const branches: VersionBranch[] = [];
for (const {name} of branchData) {
if (!isReleaseTrainBranch(name)) {
continue;
}
// Convert the version-branch into a SemVer version that can be used with the
// SemVer utilities. e.g. to determine semantic order, compare versions.
const parsed = getVersionForReleaseTrainBranch(name);
// Collect all version-branches that match the specified major versions.
if (parsed !== null && majorVersions.includes(parsed.major)) {
branches.push({name, parsed});
}
}
// Sort captured version-branches in descending order.
return branches.sort((a, b) => semver.rcompare(a.parsed, b.parsed));
}
/** Finds the currently active release trains from the specified version branches. */
export async function findActiveReleaseTrainsFromVersionBranches(
repo: GithubRepoWithApi, nextVersion: semver.SemVer, branches: VersionBranch[],
expectedReleaseCandidateMajor: number): Promise<{
latest: ReleaseTrain | null,
releaseCandidate: ReleaseTrain | null,
}> {
// 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`)!;
let latest: ReleaseTrain|null = null;
let releaseCandidate: 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.
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
// 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)) {
throw Error(
`Discovered unexpected version-branch "${name}" for a release-train that is ` +
`more recent than the release-train currently in the "${nextBranchName}" branch. ` +
`Please either delete the branch if created by accident, or update the outdated ` +
`version in the next branch (${nextBranchName}).`);
} else if (semver.eq(parsed, nextReleaseTrainVersion)) {
throw Error(
`Discovered unexpected version-branch "${name}" for a release-train that is already ` +
`active in the "${nextBranchName}" branch. Please either delete the branch if ` +
`created by accident, or update the version in the next branch (${nextBranchName}).`);
}
const version = await getVersionOfBranch(repo, name);
const releaseTrain: ReleaseTrain = {branchName: name, version};
const isPrerelease = version.prerelease[0] === 'rc' || version.prerelease[0] === 'next';
if (isPrerelease) {
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.`);
} else if (version.major !== expectedReleaseCandidateMajor) {
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;
}
}
return {releaseCandidate, latest};
}

View File

@ -7,5 +7,4 @@
*/
export * from './labels';
export * from './branches';
export * from './lts-branch';

View File

@ -7,8 +7,9 @@
*/
import * as nock from 'nock';
import * as nodeFetch from 'node-fetch';
import {ReleaseConfig} from '../../../release/config/index';
import {_npmPackageInfoCache, NpmPackageInfo} from '../../../release/versioning/npm-registry';
import {GithubConfig} from '../../../utils/config';
import * as console from '../../../utils/console';
import {GithubClient} from '../../../utils/git/github';
@ -21,13 +22,17 @@ const API_ENDPOINT = `https://api.github.com`;
describe('default target labels', () => {
let api: GithubClient;
let config: GithubConfig;
let npmPackageName: string;
let githubConfig: GithubConfig;
let releaseConfig: ReleaseConfig;
beforeEach(() => {
api = new GithubClient();
config = {owner: 'angular', name: 'dev-infra-test'};
npmPackageName = '@angular/dev-infra-test-pkg';
githubConfig = {owner: 'angular', name: 'dev-infra-test'};
releaseConfig = {
npmPackages: ['@angular/dev-infra-test-pkg'],
buildPackages: async () => [],
generateReleaseNotesForHead: async () => {},
};
// The label determination will print warn messages. These should not be
// printed to the console, so we turn `console.warn` into a spy.
@ -37,11 +42,11 @@ describe('default target labels', () => {
afterEach(() => nock.cleanAll());
async function computeTargetLabels(): Promise<TargetLabel[]> {
return getDefaultTargetLabelConfiguration(api, config, npmPackageName);
return getDefaultTargetLabelConfiguration(api, githubConfig, releaseConfig);
}
function getRepoApiRequestUrl(): string {
return `${API_ENDPOINT}/repos/${config.owner}/${config.name}`;
return `${API_ENDPOINT}/repos/${githubConfig.owner}/${githubConfig.name}`;
}
/**
@ -61,10 +66,9 @@ describe('default target labels', () => {
}
/** Fakes a NPM package query API request. */
function fakeNpmPackageQueryRequest(data: unknown) {
// Note: We only need to mock the `json` function for a `Response`. Types
// would expect us to mock more functions, so we need to cast to `any`.
spyOn(nodeFetch, 'default').and.resolveTo({json: async () => data} as any);
function fakeNpmPackageQueryRequest(data: Partial<NpmPackageInfo>) {
_npmPackageInfoCache[releaseConfig.npmPackages[0]] =
Promise.resolve({'dist-tags': {}, versions: {}, time: {}, ...data});
}
/**
@ -167,7 +171,7 @@ describe('default target labels', () => {
'time': {
// v10 has been released at the given specified date. We pick a date that
// guarantees that the version is no longer considered as active LTS version.
'10.0.0': new Date(1912, 5, 23),
'10.0.0': new Date(1912, 5, 23).toISOString(),
}
});
@ -234,7 +238,7 @@ describe('default target labels', () => {
'time': {
// v10 has been released at the given specified date. We pick a date that
// guarantees that the version is no longer considered as active LTS version.
'10.0.0': new Date(1912, 5, 23),
'10.0.0': new Date(1912, 5, 23).toISOString(),
}
});
@ -247,16 +251,14 @@ describe('default target labels', () => {
interceptBranchVersionRequest('10.5.x', '10.5.1');
interceptBranchesListRequest(['10.5.x', '11.0.x']);
spyOn(require('node-fetch'), 'default').and.callFake(() => ({
json: () => ({
'dist-tags': {
'v10-lts': '10.5.1',
},
'time': {
'10.0.0': new Date().toISOString(),
}
}),
}));
fakeNpmPackageQueryRequest({
'dist-tags': {
'v10-lts': '10.5.1',
},
'time': {
'10.0.0': new Date().toISOString(),
}
});
expect(await getBranchesForLabel('target: lts', '10.5.x')).toEqual(['10.5.x']);
});

View File

@ -6,12 +6,13 @@
* found in the LICENSE file at https://angular.io/license
*/
import {ReleaseConfig} from '../../../release/config/index';
import {fetchActiveReleaseTrains, isVersionBranch, nextBranchName} from '../../../release/versioning';
import {GithubConfig} from '../../../utils/config';
import {GithubClient} from '../../../utils/git/github';
import {TargetLabel} from '../config';
import {InvalidTargetBranchError, InvalidTargetLabelError} from '../target-label';
import {fetchActiveReleaseTrainBranches, getVersionOfBranch, GithubRepoWithApi, isReleaseTrainBranch, nextBranchName} from './branches';
import {assertActiveLtsBranch} from './lts-branch';
/**
@ -19,13 +20,18 @@ import {assertActiveLtsBranch} from './lts-branch';
* organization-wide labeling and branching semantics as outlined in the specification.
*
* https://docs.google.com/document/d/197kVillDwx-RZtSVOBtPb4BBIAw0E9RT3q3v6DZkykU
*
* @param api Instance of an authenticated Github client.
* @param githubConfig Configuration for the Github remote. Used as Git remote
* for the release train branches.
* @param releaseConfig Configuration for the release packages. Used to fetch
* NPM version data when LTS version branches are validated.
*/
export async function getDefaultTargetLabelConfiguration(
api: GithubClient, github: GithubConfig, npmPackageName: string): Promise<TargetLabel[]> {
const repo: GithubRepoWithApi = {owner: github.owner, name: github.name, api};
const nextVersion = await getVersionOfBranch(repo, nextBranchName);
const hasNextMajorTrain = nextVersion.minor === 0;
const {latest, releaseCandidate} = await fetchActiveReleaseTrainBranches(repo, nextVersion);
api: GithubClient, githubConfig: GithubConfig,
releaseConfig: ReleaseConfig): Promise<TargetLabel[]> {
const repo = {owner: githubConfig.owner, name: githubConfig.name, api};
const {latest, releaseCandidate, next} = await fetchActiveReleaseTrains(repo);
return [
{
@ -33,7 +39,7 @@ export async function getDefaultTargetLabelConfiguration(
branches: () => {
// If `next` is currently not designated to be a major version, we do not
// allow merging of PRs with `target: major`.
if (!hasNextMajorTrain) {
if (!next.isMajor) {
throw new InvalidTargetLabelError(
`Unable to merge pull request. The "${nextBranchName}" branch will be ` +
`released as a minor version.`);
@ -99,7 +105,7 @@ export async function getDefaultTargetLabelConfiguration(
// commonly diverge quickly. This makes cherry-picking not an option for LTS changes.
pattern: 'target: lts',
branches: async githubTargetBranch => {
if (!isReleaseTrainBranch(githubTargetBranch)) {
if (!isVersionBranch(githubTargetBranch)) {
throw new InvalidTargetBranchError(
`PR cannot be merged as it does not target a long-term support ` +
`branch: "${githubTargetBranch}"`);
@ -115,7 +121,7 @@ export async function getDefaultTargetLabelConfiguration(
`branch. Consider changing the label to "target: rc" if this is intentional.`);
}
// Assert that the selected branch is an active LTS branch.
await assertActiveLtsBranch(repo, npmPackageName, githubTargetBranch);
await assertActiveLtsBranch(repo, releaseConfig, githubTargetBranch);
return [githubTargetBranch];
},
},

View File

@ -6,46 +6,25 @@
* found in the LICENSE file at https://angular.io/license
*/
import fetch from 'node-fetch';
import * as semver from 'semver';
import {ReleaseConfig} from '../../../release/config/index';
import {computeLtsEndDateOfMajor, fetchProjectNpmPackageInfo, getLtsNpmDistTagOfMajor, getVersionOfBranch, GithubRepoWithApi} from '../../../release/versioning';
import {promptConfirm, red, warn, yellow} from '../../../utils/console';
import {InvalidTargetBranchError} from '../target-label';
import {getVersionOfBranch, GithubRepoWithApi} from './branches';
/**
* Number of months a major version in Angular is actively supported. See:
* https://angular.io/guide/releases#support-policy-and-schedule.
*/
export const majorActiveSupportDuration = 6;
/**
* Number of months a major version has active long-term support. See:
* https://angular.io/guide/releases#support-policy-and-schedule.
*/
export const majorActiveTermSupportDuration = 12;
/** Regular expression that matches LTS NPM dist tags. */
export const ltsNpmDistTagRegex = /^v(\d+)-lts$/;
/**
* Asserts that the given branch corresponds to an active LTS version-branch that can receive
* backport fixes. Throws an error if LTS expired or an invalid branch is selected.
*
* @param repo Github repository for which the given branch exists.
* @param representativeNpmPackage NPM package representing the given repository. Angular
* repositories usually contain multiple packages in a monorepo scheme, but packages commonly
* are released with the same versions. This means that a single package can be used for querying
* NPM about previously published versions (e.g. to determine active LTS versions). The package
* name is used to check if the given branch is containing an active LTS version.
* @param repo Repository containing the given branch. Used for Github API queries.
* @param releaseConfig Configuration for releases. Used to query NPM about past publishes.
* @param branchName Branch that is checked to be an active LTS version-branch.
* */
export async function assertActiveLtsBranch(
repo: GithubRepoWithApi, representativeNpmPackage: string, branchName: string) {
repo: GithubRepoWithApi, releaseConfig: ReleaseConfig, branchName: string) {
const version = await getVersionOfBranch(repo, branchName);
const {'dist-tags': distTags, time} =
await (await fetch(`https://registry.npmjs.org/${representativeNpmPackage}`)).json();
const {'dist-tags': distTags, time} = await fetchProjectNpmPackageInfo(releaseConfig);
// LTS versions should be tagged in NPM in the following format: `v{major}-lts`.
const ltsNpmTag = getLtsNpmDistTagOfMajor(version.major);
@ -87,21 +66,3 @@ export async function assertActiveLtsBranch(
`Pull request cannot be merged into the ${branchName} branch.`);
}
}
/**
* Computes the date when long-term support ends for a major released at the
* specified date.
*/
export function computeLtsEndDateOfMajor(majorReleaseDate: Date): Date {
return new Date(
majorReleaseDate.getFullYear(),
majorReleaseDate.getMonth() + majorActiveSupportDuration + majorActiveTermSupportDuration,
majorReleaseDate.getDate(), majorReleaseDate.getHours(), majorReleaseDate.getMinutes(),
majorReleaseDate.getSeconds(), majorReleaseDate.getMilliseconds());
}
/** Gets the long-term support NPM dist tag for a given major version. */
export function getLtsNpmDistTagOfMajor(major: number): string {
// LTS versions should be tagged in NPM in the following format: `v{major}-lts`.
return `v${major}-lts`;
}