From 9dccaa9570841cc8a774b3eb421175bdf2aefba7 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Wed, 9 Sep 2020 14:42:34 +0200 Subject: [PATCH] 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 --- .ng-dev/merge.ts | 5 +- dev-infra/pr/merge/BUILD.bazel | 4 + dev-infra/pr/merge/defaults/index.ts | 1 - .../pr/merge/defaults/integration.spec.ts | 48 ++++---- dev-infra/pr/merge/defaults/labels.ts | 24 ++-- dev-infra/pr/merge/defaults/lts-branch.ts | 51 +------- dev-infra/release/versioning/BUILD.bazel | 18 +++ dev-infra/release/versioning/README.md | 5 + .../versioning/active-release-trains.ts} | 116 +++--------------- dev-infra/release/versioning/index.ts | 13 ++ .../release/versioning/long-term-support.ts | 37 ++++++ dev-infra/release/versioning/npm-registry.ts | 66 ++++++++++ .../release/versioning/release-trains.ts | 21 ++++ .../release/versioning/version-branches.ts | 89 ++++++++++++++ 14 files changed, 320 insertions(+), 178 deletions(-) create mode 100644 dev-infra/release/versioning/BUILD.bazel create mode 100644 dev-infra/release/versioning/README.md rename dev-infra/{pr/merge/defaults/branches.ts => release/versioning/active-release-trains.ts} (59%) create mode 100644 dev-infra/release/versioning/index.ts create mode 100644 dev-infra/release/versioning/long-term-support.ts create mode 100644 dev-infra/release/versioning/npm-registry.ts create mode 100644 dev-infra/release/versioning/release-trains.ts create mode 100644 dev-infra/release/versioning/version-branches.ts diff --git a/.ng-dev/merge.ts b/.ng-dev/merge.ts index 500fb0d40b..797c2d4547 100644 --- a/.ng-dev/merge.ts +++ b/.ng-dev/merge.ts @@ -1,6 +1,7 @@ import {DevInfraMergeConfig} from '../dev-infra/pr/merge/config'; import {getDefaultTargetLabelConfiguration} from '../dev-infra/pr/merge/defaults'; import {github} from './github'; +import {release} from './release'; /** * Configuration for the merge tool in `ng-dev`. This sets up the labels which @@ -13,7 +14,9 @@ export const merge: DevInfraMergeConfig['merge'] = async api => { mergeReadyLabel: /^action: merge(-assistance)?/, caretakerNoteLabel: 'action: merge-assistance', commitMessageFixupLabel: 'commit message fixup', - labels: await getDefaultTargetLabelConfiguration(api, github, '@angular/core'), + // We can pick any of the NPM packages as we are in a monorepo where all packages are + // published together with the same version and branching. + labels: await getDefaultTargetLabelConfiguration(api, github, release), requiredBaseCommits: { // PRs that target either `master` or the patch branch, need to be rebased // on top of the latest commit message validation fix. diff --git a/dev-infra/pr/merge/BUILD.bazel b/dev-infra/pr/merge/BUILD.bazel index ccfb9cb507..c5aa520c7c 100644 --- a/dev-infra/pr/merge/BUILD.bazel +++ b/dev-infra/pr/merge/BUILD.bazel @@ -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", diff --git a/dev-infra/pr/merge/defaults/index.ts b/dev-infra/pr/merge/defaults/index.ts index b633d82825..292bf3fb3e 100644 --- a/dev-infra/pr/merge/defaults/index.ts +++ b/dev-infra/pr/merge/defaults/index.ts @@ -7,5 +7,4 @@ */ export * from './labels'; -export * from './branches'; export * from './lts-branch'; diff --git a/dev-infra/pr/merge/defaults/integration.spec.ts b/dev-infra/pr/merge/defaults/integration.spec.ts index 71d8318c14..6db2cfc12f 100644 --- a/dev-infra/pr/merge/defaults/integration.spec.ts +++ b/dev-infra/pr/merge/defaults/integration.spec.ts @@ -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 { - 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) { + _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']); }); diff --git a/dev-infra/pr/merge/defaults/labels.ts b/dev-infra/pr/merge/defaults/labels.ts index 09a67659b9..e24bea6fed 100644 --- a/dev-infra/pr/merge/defaults/labels.ts +++ b/dev-infra/pr/merge/defaults/labels.ts @@ -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 { - 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 { + 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]; }, }, diff --git a/dev-infra/pr/merge/defaults/lts-branch.ts b/dev-infra/pr/merge/defaults/lts-branch.ts index 29bbc3b2e5..b64158b072 100644 --- a/dev-infra/pr/merge/defaults/lts-branch.ts +++ b/dev-infra/pr/merge/defaults/lts-branch.ts @@ -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`; -} diff --git a/dev-infra/release/versioning/BUILD.bazel b/dev-infra/release/versioning/BUILD.bazel new file mode 100644 index 0000000000..8f575498bf --- /dev/null +++ b/dev-infra/release/versioning/BUILD.bazel @@ -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", + ], +) diff --git a/dev-infra/release/versioning/README.md b/dev-infra/release/versioning/README.md new file mode 100644 index 0000000000..c505a96ec7 --- /dev/null +++ b/dev-infra/release/versioning/README.md @@ -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. diff --git a/dev-infra/pr/merge/defaults/branches.ts b/dev-infra/release/versioning/active-release-trains.ts similarity index 59% rename from dev-infra/pr/merge/defaults/branches.ts rename to dev-infra/release/versioning/active-release-trains.ts index 151ec0aa72..50ac8b406a 100644 --- a/dev-infra/pr/merge/defaults/branches.ts +++ b/dev-infra/release/versioning/active-release-trains.ts @@ -7,52 +7,28 @@ */ 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; -} +import {ReleaseTrain} from './release-trains'; +import {getBranchesForMajorVersions, getVersionOfBranch, GithubRepoWithApi, VersionBranch} from './version-branches'; -/** 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; +/** 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'; -/** 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 -}> { +/** Fetches the active release trains for the configured project. */ +export async function fetchActiveReleaseTrains(repo: GithubRepoWithApi): + Promise { + const nextVersion = await getVersionOfBranch(repo, nextBranchName); + const next = new ReleaseTrain(nextBranchName, nextVersion); const majorVersionsToConsider: number[] = []; let expectedReleaseCandidateMajor: number; @@ -93,65 +69,7 @@ export async function fetchActiveReleaseTrainBranches( `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 { - 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 { - 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)); + return {releaseCandidate, latest, next}; } /** Finds the currently active release trains from the specified version branches. */ @@ -196,7 +114,7 @@ export async function findActiveReleaseTrainsFromVersionBranches( } const version = await getVersionOfBranch(repo, name); - const releaseTrain: ReleaseTrain = {branchName: name, version}; + const releaseTrain = new ReleaseTrain(name, version); const isPrerelease = version.prerelease[0] === 'rc' || version.prerelease[0] === 'next'; if (isPrerelease) { diff --git a/dev-infra/release/versioning/index.ts b/dev-infra/release/versioning/index.ts new file mode 100644 index 0000000000..fa834458cd --- /dev/null +++ b/dev-infra/release/versioning/index.ts @@ -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'; diff --git a/dev-infra/release/versioning/long-term-support.ts b/dev-infra/release/versioning/long-term-support.ts new file mode 100644 index 0000000000..379fbb9b0e --- /dev/null +++ b/dev-infra/release/versioning/long-term-support.ts @@ -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`; +} diff --git a/dev-infra/release/versioning/npm-registry.ts b/dev-infra/release/versioning/npm-registry.ts new file mode 100644 index 0000000000..1ac5c271ef --- /dev/null +++ b/dev-infra/release/versioning/npm-registry.ts @@ -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} = {}; + +/** + * 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 { + 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 { + 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 { + 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; +} diff --git a/dev-infra/release/versioning/release-trains.ts b/dev-infra/release/versioning/release-trains.ts new file mode 100644 index 0000000000..ee72c603f6 --- /dev/null +++ b/dev-infra/release/versioning/release-trains.ts @@ -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) {} +} diff --git a/dev-infra/release/versioning/version-branches.ts b/dev-infra/release/versioning/version-branches.ts new file mode 100644 index 0000000000..fbd72ef5a6 --- /dev/null +++ b/dev-infra/release/versioning/version-branches.ts @@ -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 { + 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 { + 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)); +}