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:

committed by
Alex Rickabaugh

parent
617858df61
commit
3e9986871c
18
dev-infra/release/versioning/BUILD.bazel
Normal file
18
dev-infra/release/versioning/BUILD.bazel
Normal file
@ -0,0 +1,18 @@
|
||||
load("@npm_bazel_typescript//:index.bzl", "ts_library")
|
||||
|
||||
ts_library(
|
||||
name = "versioning",
|
||||
srcs = glob([
|
||||
"**/*.ts",
|
||||
]),
|
||||
module_name = "@angular/dev-infra-private/release/versioning",
|
||||
visibility = ["//dev-infra:__subpackages__"],
|
||||
deps = [
|
||||
"//dev-infra/release/config",
|
||||
"//dev-infra/utils",
|
||||
"@npm//@types/node-fetch",
|
||||
"@npm//@types/semver",
|
||||
"@npm//node-fetch",
|
||||
"@npm//semver",
|
||||
],
|
||||
)
|
5
dev-infra/release/versioning/README.md
Normal file
5
dev-infra/release/versioning/README.md
Normal file
@ -0,0 +1,5 @@
|
||||
## Versioning for the Angular organization
|
||||
|
||||
The folder contains common tooling needed for implementing the versioning as proposed
|
||||
by [this design document](https://docs.google.com/document/d/197kVillDwx-RZtSVOBtPb4BBIAw0E9RT3q3v6DZkykU/edit#heading=h.s3qlps8f4zq7).
|
||||
Primary tooling is the determination of _active_ release trains.
|
139
dev-infra/release/versioning/active-release-trains.ts
Normal file
139
dev-infra/release/versioning/active-release-trains.ts
Normal file
@ -0,0 +1,139 @@
|
||||
/**
|
||||
* @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 {ReleaseTrain} from './release-trains';
|
||||
import {getBranchesForMajorVersions, getVersionOfBranch, GithubRepoWithApi, VersionBranch} from './version-branches';
|
||||
|
||||
/** Interface describing determined active release trains for a project. */
|
||||
export interface ActiveReleaseTrains {
|
||||
/** Release-train currently in the "release-candidate" or "feature-freeze" phase. */
|
||||
releaseCandidate: ReleaseTrain|null;
|
||||
/** Release-train currently in the "latest" phase. */
|
||||
latest: ReleaseTrain;
|
||||
/** Release-train in the `next` phase */
|
||||
next: ReleaseTrain;
|
||||
}
|
||||
|
||||
/** Branch name for the `next` branch. */
|
||||
export const nextBranchName = 'master';
|
||||
|
||||
/** Fetches the active release trains for the configured project. */
|
||||
export async function fetchActiveReleaseTrains(repo: GithubRepoWithApi):
|
||||
Promise<ActiveReleaseTrains> {
|
||||
const nextVersion = await getVersionOfBranch(repo, nextBranchName);
|
||||
const next = new ReleaseTrain(nextBranchName, nextVersion);
|
||||
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, next};
|
||||
}
|
||||
|
||||
/** 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 = new ReleaseTrain(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};
|
||||
}
|
13
dev-infra/release/versioning/index.ts
Normal file
13
dev-infra/release/versioning/index.ts
Normal file
@ -0,0 +1,13 @@
|
||||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
export * from './active-release-trains';
|
||||
export * from './release-trains';
|
||||
export * from './long-term-support';
|
||||
export * from './version-branches';
|
||||
export * from './npm-registry';
|
37
dev-infra/release/versioning/long-term-support.ts
Normal file
37
dev-infra/release/versioning/long-term-support.ts
Normal file
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* 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`;
|
||||
}
|
66
dev-infra/release/versioning/npm-registry.ts
Normal file
66
dev-infra/release/versioning/npm-registry.ts
Normal file
@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @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 fetch from 'node-fetch';
|
||||
import * as semver from 'semver';
|
||||
|
||||
import {ReleaseConfig} from '../config/index';
|
||||
|
||||
/** Type describing an NPM package fetched from the registry. */
|
||||
export interface NpmPackageInfo {
|
||||
/** Maps of versions and their package JSON objects. */
|
||||
'versions': {[name: string]: undefined|object};
|
||||
/** Map of NPM dist-tags and their chosen version. */
|
||||
'dist-tags': {[tagName: string]: string|undefined};
|
||||
/** Map of versions and their ISO release time. */
|
||||
'time': {[name: string]: string};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache for requested NPM package information. A cache is desirable as the NPM
|
||||
* registry requests are usually very large and slow.
|
||||
*/
|
||||
export const _npmPackageInfoCache: {[pkgName: string]: Promise<NpmPackageInfo>} = {};
|
||||
|
||||
/**
|
||||
* Fetches the NPM package representing the project. Angular repositories usually contain
|
||||
* multiple packages in a monorepo scheme, but packages dealt with as part of the release
|
||||
* tooling are released together with the same versioning and branching. This means that
|
||||
* a single package can be used as source of truth for NPM package queries.
|
||||
*/
|
||||
export async function fetchProjectNpmPackageInfo(config: ReleaseConfig): Promise<NpmPackageInfo> {
|
||||
const pkgName = getRepresentativeNpmPackage(config);
|
||||
return await fetchPackageInfoFromNpmRegistry(pkgName);
|
||||
}
|
||||
|
||||
/** Gets whether the given version is published to NPM or not */
|
||||
export async function isVersionPublishedToNpm(
|
||||
version: semver.SemVer, config: ReleaseConfig): Promise<boolean> {
|
||||
const {versions} = await fetchProjectNpmPackageInfo(config);
|
||||
return versions[version.format()] !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the representative NPM package for the specified release configuration. Angular
|
||||
* repositories usually contain multiple packages in a monorepo scheme, but packages dealt with
|
||||
* as part of the release tooling are released together with the same versioning and branching.
|
||||
* This means that a single package can be used as source of truth for NPM package queries.
|
||||
*/
|
||||
function getRepresentativeNpmPackage(config: ReleaseConfig) {
|
||||
return config.npmPackages[0];
|
||||
}
|
||||
|
||||
/** Fetches the specified NPM package from the NPM registry. */
|
||||
async function fetchPackageInfoFromNpmRegistry(pkgName: string): Promise<NpmPackageInfo> {
|
||||
if (_npmPackageInfoCache[pkgName] !== undefined) {
|
||||
return await _npmPackageInfoCache[pkgName];
|
||||
}
|
||||
const result = _npmPackageInfoCache[pkgName] =
|
||||
fetch(`https://registry.npmjs.org/${pkgName}`).then(r => r.json());
|
||||
return await result;
|
||||
}
|
21
dev-infra/release/versioning/release-trains.ts
Normal file
21
dev-infra/release/versioning/release-trains.ts
Normal file
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @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';
|
||||
|
||||
/** Class describing a release-train. */
|
||||
export class ReleaseTrain {
|
||||
/** Whether the release train is currently targeting a major. */
|
||||
isMajor = this.version.minor === 0 && this.version.patch === 0;
|
||||
|
||||
constructor(
|
||||
/** Name of the branch for this release-train. */
|
||||
public branchName: string,
|
||||
/** Most recent version for this release train. */
|
||||
public version: semver.SemVer) {}
|
||||
}
|
89
dev-infra/release/versioning/version-branches.ts
Normal file
89
dev-infra/release/versioning/version-branches.ts
Normal file
@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
|
||||
/** Regular expression that matches version-branches. */
|
||||
const versionBranchNameRegex = /(\d+)\.(\d+)\.x/;
|
||||
|
||||
/** 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 version branch. */
|
||||
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 {
|
||||
// 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(versionBranchNameRegex, '$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 (!isVersionBranch(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 = getVersionForVersionBranch(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));
|
||||
}
|
Reference in New Issue
Block a user