feat(dev-infra): provide organization-wide merge-tool label configuration (#38223)

Previously, each Angular repository had its own strategy/configuration
for merging pull requests and cherry-picking. We worked out a new
strategy for labeling/branching/versioning that should be the canonical
strategy for all actively maintained projects in the Angular organization.

This PR provides a `ng-dev` merge configuration that implements the
labeling/branching/merging as per the approved proposal.

See the following document for the proposal this commit is based on
for the merge script labeling/branching: https://docs.google.com/document/d/197kVillDwx-RZtSVOBtPb4BBIAw0E9RT3q3v6DZkykU

The merge tool label configuration can be conveniently accesed
within each `.ng-dev` configuration, and can also be extended
if there are special labels on individual projects. This is one
of the reasons why the labels are not directly built into the
merge script. The script should remain unopinionated and flexible.

The configuration is conceptually powerful enough to achieve the
procedures as outlined in the versioning/branching/labeling proposal.

PR Close #38223
This commit is contained in:
Paul Gschwendtner 2020-07-24 17:47:30 +02:00 committed by Andrew Kushnir
parent 6f0f0d3ea2
commit f0766a4474
11 changed files with 955 additions and 72 deletions

View File

@ -1,8 +1,12 @@
load("@npm_bazel_typescript//:index.bzl", "ts_library")
load("//tools:defaults.bzl", "jasmine_node_test")
ts_library(
name = "merge",
srcs = glob(["**/*.ts"]),
srcs = glob(
["**/*.ts"],
exclude = ["**/*.spec.ts"],
),
module_name = "@angular/dev-infra-private/pr/merge",
visibility = ["//dev-infra:__subpackages__"],
deps = [
@ -11,8 +15,37 @@ ts_library(
"@npm//@octokit/rest",
"@npm//@types/inquirer",
"@npm//@types/node",
"@npm//@types/node-fetch",
"@npm//@types/semver",
"@npm//@types/yargs",
"@npm//chalk",
],
)
ts_library(
name = "test_lib",
testonly = True,
srcs = glob(["**/*.spec.ts"]),
deps = [
":merge",
"//dev-infra/utils",
"@npm//@types/jasmine",
"@npm//@types/node",
"@npm//@types/node-fetch",
"@npm//nock",
],
)
jasmine_node_test(
name = "test",
# Disable the Bazel patched module resolution. It always loads ".mjs" files first. This
# breaks NodeJS execution for "node-fetch" as it uses experimental modules which are not
# enabled in NodeJS. TODO: Remove this with rules_nodejs 3.x where patching is optional.
# https://github.com/bazelbuild/rules_nodejs/commit/7d070ffadf9c3b41711382a4737b995f987c14fa.
args = ["--nobazel_patch_module_resolver"],
deps = [
":test_lib",
"@npm//node-fetch",
"@npm//semver",
],
)

View File

@ -0,0 +1,212 @@
/**
* @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} from '../../../utils/git/github';
/** Type describing a Github repository with corresponding API client. */
export interface GithubRepo {
/** API client that can access the repository. */
api: GithubClient;
/** Owner login of the repository. */
owner: string;
/** Name of the repository. */
repo: string;
/**
* NPM package representing this 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).
* */
npmPackageName: string;
}
/** 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;
}
/** 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: GithubRepo, nextVersion: semver.SemVer): Promise<{
/**
* Name of the currently active release-candidate branch. Null if no
* feature-freeze/release-candidate is currently active.
*/
releaseCandidateBranch: string | null,
/** Name of the latest non-prerelease version branch (i.e. the patch branch). */
latestVersionBranch: string
}> {
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 {latestVersionBranch, releaseCandidateBranch} =
await findActiveVersionBranches(repo, nextVersion, branches, expectedReleaseCandidateMajor);
if (latestVersionBranch === null) {
throw Error(
`Unable to determine the latest release-train. The following branches ` +
`have been considered: [${branches.join(', ')}]`);
}
return {releaseCandidateBranch, latestVersionBranch};
}
/** Gets the version of a given branch by reading the `package.json` upstream. */
export async function getVersionOfBranch(
repo: GithubRepo, branchName: string): Promise<semver.SemVer> {
const {data} =
await repo.api.repos.getContents({...repo, 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: GithubRepo, majorVersions: number[]): Promise<VersionBranch[]> {
const {data: branchData} = await repo.api.repos.listBranches({...repo, 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));
}
export async function findActiveVersionBranches(
repo: GithubRepo, nextVersion: semver.SemVer, branches: VersionBranch[],
expectedReleaseCandidateMajor: number): Promise<{
latestVersionBranch: string | null,
releaseCandidateBranch: string | null,
}> {
let latestVersionBranch: string|null = null;
let releaseCandidateBranch: string|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 that are more recent than the version in the next
// branch (i.e. `master`) have been created. We could ignore such branches silently, but
// it might actually 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.gte(parsed, nextVersion)) {
throw Error(
`Discovered unexpected version-branch that is representing a minor ` +
`version more recent than the one in the "${nextBranchName}" branch. Consider ` +
`deleting the branch, or check if the version in "${nextBranchName}" is outdated.`);
}
const version = await getVersionOfBranch(repo, name);
const isPrerelease = version.prerelease[0] === 'rc' || version.prerelease[0] === 'next';
if (isPrerelease) {
if (releaseCandidateBranch !== 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 "${releaseCandidateBranch}" 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}.`);
}
releaseCandidateBranch = name;
} else {
latestVersionBranch = name;
break;
}
}
return {releaseCandidateBranch, latestVersionBranch};
}

View File

@ -0,0 +1,11 @@
/**
* @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 './labels';
export * from './branches';
export * from './lts-branch';

View File

@ -0,0 +1,455 @@
/**
* @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 nock from 'nock';
import * as nodeFetch from 'node-fetch';
import {GithubConfig} from '../../../utils/config';
import * as console from '../../../utils/console';
import {GithubClient} from '../../../utils/git/github';
import {TargetLabel} from '../config';
import {getBranchesFromTargetLabel, getTargetLabelFromPullRequest} from '../target-label';
import {getDefaultTargetLabelConfiguration} from './index';
const API_ENDPOINT = `https://api.github.com`;
describe('default target labels', () => {
let api: GithubClient;
let config: GithubConfig;
let npmPackageName: string;
beforeEach(() => {
api = new GithubClient();
config = {owner: 'angular', name: 'dev-infra-test'};
npmPackageName = '@angular/dev-infra-test-pkg';
// The label determination will print warn messages. These should not be
// printed to the console, so we turn `console.warn` into a spy.
spyOn(console, 'warn');
});
afterEach(() => nock.cleanAll());
async function computeTargetLabels(): Promise<TargetLabel[]> {
return getDefaultTargetLabelConfiguration(api, config, npmPackageName);
}
function getRepoApiRequestUrl(): string {
return `${API_ENDPOINT}/repos/${config.owner}/${config.name}`;
}
/**
* 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) {
nock(getRepoApiRequestUrl())
.get('/contents//package.json')
.query(params => params.ref === branchName)
.reply(200, {content: Buffer.from(JSON.stringify({version})).toString('base64')});
}
/** Fakes a prompt confirm question with the given value. */
function fakePromptConfirmValue(returnValue: boolean) {
spyOn(console, 'promptConfirm').and.resolveTo(returnValue);
}
/** 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);
}
/**
* 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.map(name => ({name})));
}
async function getBranchesForLabel(
name: string, githubTargetBranch = 'master', labels?: TargetLabel[]): Promise<string[]|null> {
if (labels === undefined) {
labels = await computeTargetLabels();
}
const label = getTargetLabelFromPullRequest({labels}, [name]);
if (label === null) {
return null;
}
return await getBranchesFromTargetLabel(label, githubTargetBranch);
}
it('should detect "master" as branch for target: minor', async () => {
interceptBranchVersionRequest('master', '11.0.0-next.0');
interceptBranchVersionRequest('10.2.x', '10.2.4');
interceptBranchesListRequest(['10.2.x']);
expect(await getBranchesForLabel('target: minor')).toEqual(['master']);
});
it('should error if non version-branch is targeted with "target: lts"', async () => {
interceptBranchVersionRequest('master', '11.0.0-next.0');
interceptBranchVersionRequest('10.2.x', '10.2.4');
interceptBranchesListRequest(['10.2.x']);
await expectAsync(getBranchesForLabel('target: lts', 'master'))
.toBeRejectedWith(jasmine.objectContaining({
failureMessage:
'PR cannot be merged as it does not target a long-term support branch: "master"'
}));
});
it('should error if patch branch is targeted with "target: lts"', async () => {
interceptBranchVersionRequest('master', '11.0.0-next.0');
interceptBranchVersionRequest('10.2.x', '10.2.4');
interceptBranchesListRequest(['10.2.x']);
await expectAsync(getBranchesForLabel('target: lts', '10.2.x'))
.toBeRejectedWith(jasmine.objectContaining({
failureMessage:
'PR cannot be merged with "target: lts" into patch branch. Consider changing the ' +
'label to "target: patch" if this is intentional.'
}));
});
it('should error if feature-freeze branch is targeted with "target: lts"', async () => {
interceptBranchVersionRequest('master', '11.0.0-next.0');
interceptBranchVersionRequest('10.2.x', '10.2.0-next.0');
interceptBranchVersionRequest('10.1.x', '10.1.0');
interceptBranchesListRequest(['10.1.x', '10.2.x']);
await expectAsync(getBranchesForLabel('target: lts', '10.2.x'))
.toBeRejectedWith(jasmine.objectContaining({
failureMessage:
'PR cannot be merged with "target: lts" into feature-freeze/release-candidate branch. ' +
'Consider changing the label to "target: rc" if this is intentional.'
}));
});
it('should error if release-candidate branch is targeted with "target: lts"', async () => {
interceptBranchVersionRequest('master', '11.0.0-next.0');
interceptBranchVersionRequest('10.2.x', '10.2.0-rc.0');
interceptBranchVersionRequest('10.1.x', '10.1.0');
interceptBranchesListRequest(['10.1.x', '10.2.x']);
await expectAsync(getBranchesForLabel('target: lts', '10.2.x'))
.toBeRejectedWith(jasmine.objectContaining({
failureMessage:
'PR cannot be merged with "target: lts" into feature-freeze/release-candidate branch. ' +
'Consider changing the label to "target: rc" if this is intentional.'
}));
});
it('should error if branch targeted with "target: lts" is no longer active', async () => {
interceptBranchVersionRequest('master', '11.1.0-next.0');
interceptBranchVersionRequest('11.0.x', '11.0.0');
interceptBranchVersionRequest('10.5.x', '10.5.1');
interceptBranchesListRequest(['10.5.x', '11.0.x']);
// We support forcibly proceeding with merging if a given branch previously was in LTS mode
// but no longer is (after a period of time). In this test, we are not forcibly proceeding.
fakePromptConfirmValue(false);
fakeNpmPackageQueryRequest({
'dist-tags': {
'v10-lts': '10.5.1',
},
'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),
}
});
await expectAsync(getBranchesForLabel('target: lts', '10.5.x'))
.toBeRejectedWith(jasmine.objectContaining({
failureMessage:
'Long-term supported ended for v10 on 12/23/1913. Pull request cannot be merged ' +
'into the 10.5.x branch.'
}));
});
it('should error if branch targeted with "target: lts" is not latest LTS for given major',
async () => {
interceptBranchVersionRequest('master', '11.1.0-next.0');
interceptBranchVersionRequest('11.0.x', '11.0.0');
interceptBranchVersionRequest('10.5.x', '10.5.1');
interceptBranchVersionRequest('10.4.x', '10.4.4');
interceptBranchesListRequest(['10.4.x', '10.5.x', '11.0.x']);
fakeNpmPackageQueryRequest({
'dist-tags': {
'v10-lts': '10.5.1',
}
});
await expectAsync(getBranchesForLabel('target: lts', '10.4.x'))
.toBeRejectedWith(jasmine.objectContaining({
failureMessage:
'Not using last-minor branch for v10 LTS version. PR should be updated to ' +
'target: 10.5.x'
}));
});
it('should error if branch targeted with "target: lts" is not a major version with LTS',
async () => {
interceptBranchVersionRequest('master', '11.1.0-next.0');
interceptBranchVersionRequest('11.0.x', '11.0.0');
interceptBranchVersionRequest('10.5.x', '10.5.1');
interceptBranchesListRequest(['10.5.x', '11.0.x']);
fakeNpmPackageQueryRequest({'dist-tags': {}});
await expectAsync(getBranchesForLabel('target: lts', '10.5.x'))
.toBeRejectedWith(
jasmine.objectContaining({failureMessage: 'No LTS version tagged for v10 in NPM.'}));
});
it('should allow forcibly proceeding with merge if branch targeted with "target: lts" is no ' +
'longer active',
async () => {
interceptBranchVersionRequest('master', '11.1.0-next.0');
interceptBranchVersionRequest('11.0.x', '11.0.0');
interceptBranchVersionRequest('10.5.x', '10.5.1');
interceptBranchesListRequest(['10.5.x', '11.0.x']);
// We support forcibly proceeding with merging if a given branch previously was in LTS mode
// but no longer is (after a period of time). In this test, we are forcibly proceeding and
// expect the Github target branch to be picked up as branch for the `target: lts` label.
fakePromptConfirmValue(true);
fakeNpmPackageQueryRequest({
'dist-tags': {
'v10-lts': '10.5.1',
},
'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),
}
});
expect(await getBranchesForLabel('target: lts', '10.5.x')).toEqual(['10.5.x']);
});
it('should use target branch for "target: lts" if it matches an active LTS branch', async () => {
interceptBranchVersionRequest('master', '11.1.0-next.0');
interceptBranchVersionRequest('11.0.x', '11.0.0');
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(),
}
}),
}));
expect(await getBranchesForLabel('target: lts', '10.5.x')).toEqual(['10.5.x']);
});
it('should error if no active branch for given major version could be found', async () => {
interceptBranchVersionRequest('master', '12.0.0-next.0');
interceptBranchesListRequest(['9.0.x', '9.1.x']);
await expectAsync(getBranchesForLabel('target: lts', '10.2.x'))
.toBeRejectedWithError(
'Unable to determine the latest release-train. The following branches have ' +
'been considered: []');
});
it('should error if invalid version is set for version-branch', async () => {
interceptBranchVersionRequest('master', '11.2.0-next.0');
interceptBranchVersionRequest('11.1.x', '11.1.x');
interceptBranchesListRequest(['11.1.x']);
await expectAsync(getBranchesForLabel('target: lts', '10.2.x'))
.toBeRejectedWithError('Invalid version detected in following branch: 11.1.x.');
});
it('should error if branch more recent than version in "next" branch is found', async () => {
interceptBranchVersionRequest('master', '11.2.0-next.0');
interceptBranchVersionRequest('11.2.x', '11.2.0-next.0');
interceptBranchVersionRequest('11.1.x', '11.1.5');
interceptBranchesListRequest(['11.1.x', '11.2.x']);
await expectAsync(getBranchesForLabel('target: lts', '10.2.x'))
.toBeRejectedWithError(
'Discovered unexpected version-branch that is representing a minor version more ' +
'recent than the one in the "master" branch. Consider deleting the branch, or check ' +
'if the version in "master" is outdated.');
});
it('should allow merging PR only into patch branch with "target: patch"', async () => {
interceptBranchVersionRequest('master', '11.2.0-next.0');
interceptBranchVersionRequest('11.1.x', '11.1.0');
interceptBranchesListRequest(['11.1.x']);
expect(await getBranchesForLabel('target: patch', '11.1.x')).toEqual(['11.1.x']);
});
describe('next: major release', () => {
it('should detect "master" as branch for target: major', async () => {
interceptBranchVersionRequest('master', '11.0.0-next.0');
interceptBranchVersionRequest('10.2.x', '10.2.4');
interceptBranchesListRequest(['10.2.x']);
expect(await getBranchesForLabel('target: major')).toEqual(['master']);
});
describe('without active release-candidate', () => {
it('should detect last-minor from previous major as branch for target: patch', async () => {
interceptBranchVersionRequest('master', '11.0.0-next.0');
interceptBranchVersionRequest('10.2.x', '10.2.4');
interceptBranchesListRequest(['10.0.x', '10.1.x', '10.2.x']);
expect(await getBranchesForLabel('target: patch')).toEqual(['master', '10.2.x']);
});
it('should error if "target: rc" is applied', async () => {
interceptBranchVersionRequest('master', '11.0.0-next.0');
interceptBranchVersionRequest('10.2.x', '10.2.4');
interceptBranchesListRequest(['10.0.x', '10.1.x', '10.2.x']);
await expectAsync(getBranchesForLabel('target: rc'))
.toBeRejectedWith(jasmine.objectContaining({
failureMessage:
'No active feature-freeze/release-candidate branch. Unable to merge ' +
'pull request using "target: rc" label.'
}));
});
});
describe('with active release-candidate', () => {
it('should detect most recent non-prerelease minor branch from previous major for ' +
'target: patch',
async () => {
interceptBranchVersionRequest('master', '11.0.0-next.0');
interceptBranchVersionRequest('10.2.x', '10.2.0-rc.0');
interceptBranchVersionRequest('10.1.x', '10.2.3');
interceptBranchesListRequest(['10.1.x', '10.2.x']);
// Pull requests should also be merged into the RC and `next` (i.e. `master`) branch.
expect(await getBranchesForLabel('target: patch')).toEqual([
'master', '10.1.x', '10.2.x'
]);
});
it('should detect release-candidate branch for "target: rc"', async () => {
interceptBranchVersionRequest('master', '11.0.0-next.0');
interceptBranchVersionRequest('10.2.x', '10.2.0-rc.0');
interceptBranchVersionRequest('10.1.x', '10.1.0');
interceptBranchesListRequest(['10.0.x', '10.1.x', '10.2.x']);
expect(await getBranchesForLabel('target: rc')).toEqual(['master', '10.2.x']);
});
it('should detect feature-freeze branch with "target: rc"', async () => {
interceptBranchVersionRequest('master', '11.0.0-next.0');
interceptBranchVersionRequest('10.2.x', '10.2.0-next.0');
interceptBranchVersionRequest('10.1.x', '10.1.0');
interceptBranchesListRequest(['10.0.x', '10.1.x', '10.2.x']);
expect(await getBranchesForLabel('target: rc')).toEqual(['master', '10.2.x']);
});
it('should error if multiple consecutive release-candidate branches are found', async () => {
interceptBranchVersionRequest('master', '11.0.0-next.0');
interceptBranchVersionRequest('10.4.x', '10.4.0-next.0');
interceptBranchVersionRequest('10.3.x', '10.4.0-rc.5');
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.');
});
});
});
describe('next: minor release', () => {
it('should error if "target: major" is applied', async () => {
interceptBranchVersionRequest('master', '11.2.0-next.0');
interceptBranchVersionRequest('11.1.x', '11.1.4');
interceptBranchesListRequest(['11.1.x']);
await expectAsync(getBranchesForLabel('target: major'))
.toBeRejectedWith(jasmine.objectContaining({
failureMessage:
'Unable to merge pull request. The "master" branch will be released as ' +
'a minor version.',
}));
});
describe('without active release-candidate', () => {
it('should detect last-minor from previous major as branch for target: patch', async () => {
interceptBranchVersionRequest('master', '11.2.0-next.0');
interceptBranchVersionRequest('11.1.x', '11.1.0');
interceptBranchesListRequest(['11.1.x']);
expect(await getBranchesForLabel('target: patch')).toEqual(['master', '11.1.x']);
});
it('should error if "target: rc" is applied', async () => {
interceptBranchVersionRequest('master', '11.2.0-next.0');
interceptBranchVersionRequest('11.1.x', '11.1.0');
interceptBranchesListRequest(['11.1.x']);
await expectAsync(getBranchesForLabel('target: rc'))
.toBeRejectedWith(jasmine.objectContaining({
failureMessage:
'No active feature-freeze/release-candidate branch. Unable to merge pull ' +
'request using "target: rc" label.'
}));
});
});
describe('with active release-candidate', () => {
it('should detect most recent non-prerelease minor branch from previous major for ' +
'target: patch',
async () => {
interceptBranchVersionRequest('master', '11.2.0-next.0');
interceptBranchVersionRequest('11.1.x', '11.1.0-rc.0');
interceptBranchVersionRequest('11.0.x', '11.0.0');
interceptBranchesListRequest(['11.0.x', '11.1.x']);
// Pull requests should also be merged into the RC and `next` (i.e. `master`) branch.
expect(await getBranchesForLabel('target: patch')).toEqual([
'master', '11.0.x', '11.1.x'
]);
});
it('should detect release-candidate branch for "target: rc"', async () => {
interceptBranchVersionRequest('master', '11.2.0-next.0');
interceptBranchVersionRequest('11.1.x', '11.1.0-rc.0');
interceptBranchVersionRequest('11.0.x', '10.0.0');
interceptBranchesListRequest(['11.0.x', '11.1.x']);
expect(await getBranchesForLabel('target: rc')).toEqual(['master', '11.1.x']);
});
it('should detect feature-freeze branch with "target: rc"', async () => {
interceptBranchVersionRequest('master', '11.2.0-next.0');
interceptBranchVersionRequest('11.1.x', '11.1.0-next.0');
interceptBranchVersionRequest('11.0.x', '10.0.0');
interceptBranchesListRequest(['11.0.x', '11.1.x']);
expect(await getBranchesForLabel('target: rc')).toEqual(['master', '11.1.x']);
});
});
});
});

View File

@ -0,0 +1,124 @@
/**
* @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 {GithubConfig} from '../../../utils/config';
import {GithubClient} from '../../../utils/git/github';
import {TargetLabel} from '../config';
import {InvalidTargetBranchError, InvalidTargetLabelError} from '../target-label';
import {fetchActiveReleaseTrainBranches, getVersionOfBranch, GithubRepo, isReleaseTrainBranch, nextBranchName} from './branches';
import {assertActiveLtsBranch} from './lts-branch';
/**
* Gets a label configuration for the merge tooling that reflects the default Angular
* organization-wide labeling and branching semantics as outlined in the specification.
*
* https://docs.google.com/document/d/197kVillDwx-RZtSVOBtPb4BBIAw0E9RT3q3v6DZkykU
*/
export async function getDefaultTargetLabelConfiguration(
api: GithubClient, github: GithubConfig, npmPackageName: string): Promise<TargetLabel[]> {
const repo: GithubRepo = {owner: github.owner, repo: github.name, api, npmPackageName};
const nextVersion = await getVersionOfBranch(repo, nextBranchName);
const hasNextMajorTrain = nextVersion.minor === 0;
const {latestVersionBranch, releaseCandidateBranch} =
await fetchActiveReleaseTrainBranches(repo, nextVersion);
return [
{
pattern: 'target: major',
branches: () => {
// If `next` is currently not designated to be a major version, we do not
// allow merging of PRs with `target: major`.
if (!hasNextMajorTrain) {
throw new InvalidTargetLabelError(
`Unable to merge pull request. The "${nextBranchName}" branch will be ` +
`released as a minor version.`);
}
return [nextBranchName];
},
},
{
pattern: 'target: minor',
// Changes labeled with `target: minor` are merged most commonly into the next branch
// (i.e. `master`). In rare cases of an exceptional minor version while being already
// on a major release train, this would need to be overridden manually.
// TODO: Consider handling this automatically by checking if the NPM version matches
// the last-minor. If not, then an exceptional minor might be in progress. See:
// https://docs.google.com/document/d/197kVillDwx-RZtSVOBtPb4BBIAw0E9RT3q3v6DZkykU/edit#heading=h.h7o5pjq6yqd0
branches: [nextBranchName],
},
{
pattern: 'target: patch',
branches: githubTargetBranch => {
// If a PR is targeting the latest active version-branch through the Github UI,
// and is also labeled with `target: patch`, then we merge it directly into the
// branch without doing any cherry-picking. This is useful if a PR could not be
// applied cleanly, and a separate PR for the patch branch has been created.
if (githubTargetBranch === latestVersionBranch) {
return [latestVersionBranch];
}
// Otherwise, patch changes are always merged into the next and patch branch.
const branches = [nextBranchName, latestVersionBranch];
// Additionally, if there is a release-candidate/feature-freeze release-train
// currently active, also merge the PR into that version-branch.
if (releaseCandidateBranch !== null) {
branches.push(releaseCandidateBranch);
}
return branches;
}
},
{
pattern: 'target: rc',
branches: githubTargetBranch => {
// The `target: rc` label cannot be applied if there is no active feature-freeze
// or release-candidate release train.
if (releaseCandidateBranch === null) {
throw new InvalidTargetLabelError(
`No active feature-freeze/release-candidate branch. ` +
`Unable to merge pull request using "target: rc" label.`);
}
// If the PR is targeting the active release-candidate/feature-freeze version branch
// directly through the Github UI and has the `target: rc` label applied, merge it
// only into the release candidate branch. This is useful if a PR did not apply cleanly
// into the release-candidate/feature-freeze branch, and a separate PR has been created.
if (githubTargetBranch === releaseCandidateBranch) {
return [releaseCandidateBranch];
}
// Otherwise, merge into the next and active release-candidate/feature-freeze branch.
return [nextBranchName, releaseCandidateBranch];
},
},
{
// LTS changes are rare enough that we won't worry about cherry-picking changes into all
// active LTS branches for PRs created against any other branch. Instead, PR authors need
// to manually create separate PRs for desired LTS branches. Additionally, active LT branches
// commonly diverge quickly. This makes cherry-picking not an option for LTS changes.
pattern: 'target: lts',
branches: async githubTargetBranch => {
if (!isReleaseTrainBranch(githubTargetBranch)) {
throw new InvalidTargetBranchError(
`PR cannot be merged as it does not target a long-term support ` +
`branch: "${githubTargetBranch}"`);
}
if (githubTargetBranch === latestVersionBranch) {
throw new InvalidTargetBranchError(
`PR cannot be merged with "target: lts" into patch branch. ` +
`Consider changing the label to "target: patch" if this is intentional.`);
}
if (githubTargetBranch === releaseCandidateBranch && releaseCandidateBranch !== null) {
throw new InvalidTargetBranchError(
`PR cannot be merged with "target: lts" into feature-freeze/release-candidate ` +
`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, githubTargetBranch);
return [githubTargetBranch];
},
},
];
}

View File

@ -0,0 +1,80 @@
/**
* @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 {promptConfirm, red, warn, yellow} from '../../../utils/console';
import {InvalidTargetBranchError} from '../target-label';
import {getVersionOfBranch, GithubRepo} from './branches';
/**
* Number of months a major version in Angular is actively supported. See:
* https://angular.io/guide/releases#support-policy-and-schedule.
*/
const majorActiveSupportDuration = 6;
/**
* Number of months a major version has active long-term support. See:
* https://angular.io/guide/releases#support-policy-and-schedule.
*/
const majorActiveTermSupportDuration = 12;
/**
* Asserts that the given branch corresponds to an active LTS version-branch that can receive
* backported fixes. Throws an error if LTS expired or an invalid branch is selected.
*/
export async function assertActiveLtsBranch(repo: GithubRepo, branchName: string) {
const version = await getVersionOfBranch(repo, branchName);
const {'dist-tags': distTags, time} =
await (await fetch(`https://registry.npmjs.org/${repo.npmPackageName}`)).json();
// LTS versions should be tagged in NPM in the following format: `v{major}-lts`.
const ltsVersion = semver.parse(distTags[`v${version.major}-lts`]);
// Ensure that there is a LTS version tagged for the given version-branch major. e.g.
// if the version branch is `9.2.x` then we want to make sure that there is a LTS
// version tagged in NPM for `v9`, following the `v{major}-lts` tag convention.
if (ltsVersion === null) {
throw new InvalidTargetBranchError(`No LTS version tagged for v${version.major} in NPM.`);
}
// Ensure that the correct branch is used for the LTS version. We do not want to merge
// changes to older minor version branches that do not reflect the current LTS version.
if (branchName !== `${ltsVersion.major}.${ltsVersion.minor}.x`) {
throw new InvalidTargetBranchError(
`Not using last-minor branch for v${version.major} LTS version. PR ` +
`should be updated to target: ${ltsVersion.major}.${ltsVersion.minor}.x`);
}
const today = new Date();
const releaseDate = new Date(time[`${version.major}.0.0`]);
const ltsEndDate = new Date(
releaseDate.getFullYear(),
releaseDate.getMonth() + majorActiveSupportDuration + majorActiveTermSupportDuration,
releaseDate.getDate(), releaseDate.getHours(), releaseDate.getMinutes(),
releaseDate.getSeconds(), releaseDate.getMilliseconds());
// Check if LTS has already expired for the targeted major version. If so, we do not
// allow the merge as per our LTS guarantees. Can be forcibly overridden if desired.
// See: https://angular.io/guide/releases#support-policy-and-schedule.
if (today > ltsEndDate) {
const ltsEndDateText = ltsEndDate.toLocaleDateString();
warn(red(`Long-term support ended for v${version.major} on ${ltsEndDateText}.`));
warn(yellow(
`Merging of pull requests for this major is generally not ` +
`desired, but can be forcibly ignored.`));
if (await promptConfirm('Do you want to forcibly proceed with merging?')) {
return;
}
throw new InvalidTargetBranchError(
`Long-term supported ended for v${version.major} on ${ltsEndDateText}. ` +
`Pull request cannot be merged into the ${branchName} branch.`);
}
}

View File

@ -1,68 +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 {exec} from '../../utils/shelljs';
/**
* Helper function that can be used to determine merge branches based on a given
* project version. The function determines merge branches primarily through the
* specified version, but falls back to consulting the NPM registry when needed.
*
* Consulting the NPM registry for determining the patch branch may slow down merging,
* so whenever possible, the branches are determined statically based on the current
* version. In some cases, consulting the NPM registry is inevitable because for major
* pre-releases, we cannot determine the latest stable minor version from the current
* pre-release version.
*/
export function determineMergeBranches(
currentVersion: string, npmPackageName: string): {minor: string, patch: string} {
const projectVersion = semver.parse(currentVersion);
if (projectVersion === null) {
throw Error('Cannot parse version set in project "package.json" file.');
}
const {major, minor, patch, prerelease} = projectVersion;
const isMajor = minor === 0 && patch === 0;
const isMinor = minor !== 0 && patch === 0;
// If there is no prerelease, then we compute patch and minor branches based
// on the current version major and minor.
if (prerelease.length === 0) {
return {minor: `${major}.x`, patch: `${major}.${minor}.x`};
}
// If current version is set to a minor prerelease, we can compute the merge branches
// statically. e.g. if we are set to `9.3.0-next.0`, then our merge branches should
// be set to `9.x` and `9.2.x`.
if (isMinor) {
return {minor: `${major}.x`, patch: `${major}.${minor - 1}.x`};
} else if (!isMajor) {
throw Error('Unexpected version. Cannot have prerelease for patch version.');
}
// If we are set to a major prerelease, we cannot statically determine the stable patch
// branch (as the latest minor segment is unknown). We determine it by looking in the NPM
// registry for the latest stable release that will tell us about the current minor segment.
// e.g. if the current major is `v10.0.0-next.0`, then we need to look for the latest release.
// Let's say this is `v9.2.6`. Our patch branch will then be called `9.2.x`.
const latestVersion = exec(`yarn -s info ${npmPackageName} dist-tags.latest`).trim();
if (!latestVersion) {
throw Error('Could not determine version of latest release.');
}
const expectedMajor = major - 1;
const parsedLatestVersion = semver.parse(latestVersion);
if (parsedLatestVersion === null) {
throw Error(`Could not parse latest version from NPM registry: ${latestVersion}`);
} else if (parsedLatestVersion.major !== expectedMajor) {
throw Error(
`Expected latest release to have major version: v${expectedMajor}, ` +
`but got: v${latestVersion}`);
}
return {patch: `${expectedMajor}.${parsedLatestVersion.minor}.x`, minor: `${expectedMajor}.x`};
}

View File

@ -26,8 +26,8 @@ export class InvalidTargetLabelError {
}
/** Gets the target label from the specified pull request labels. */
export function getTargetLabelFromPullRequest(config: MergeConfig, labels: string[]): TargetLabel|
null {
export function getTargetLabelFromPullRequest(
config: Pick<MergeConfig, 'labels'>, labels: string[]): TargetLabel|null {
for (const label of labels) {
const match = config.labels.find(({pattern}) => matchesPattern(label, pattern));
if (match !== undefined) {

View File

@ -19,6 +19,7 @@
"inquirer": "<from-root>",
"minimatch": "<from-root>",
"multimatch": "<from-root>",
"node-fetch": "<from-root>",
"node-uuid": "<from-root>",
"semver": "<from-root>",
"shelljs": "<from-root>",

View File

@ -82,6 +82,7 @@
"@types/jasminewd2": "^2.0.8",
"@types/minimist": "^1.2.0",
"@types/node": "^12.11.1",
"@types/node-fetch": "^2.5.7",
"@types/selenium-webdriver": "3.0.7",
"@types/semver": "^6.0.2",
"@types/shelljs": "^0.8.6",
@ -127,6 +128,7 @@
"materialize-css": "1.0.0",
"minimatch": "^3.0.4",
"minimist": "1.2.0",
"node-fetch": "^2.6.0",
"node-uuid": "1.4.8",
"nodejs-websocket": "^1.7.2",
"protractor": "^5.4.2",
@ -185,6 +187,7 @@
"madge": "^3.6.0",
"multimatch": "^4.0.0",
"mutation-observer": "^1.0.3",
"nock": "^13.0.3",
"rewire": "2.5.2",
"sauce-connect": "https://saucelabs.com/downloads/sc-4.5.1-linux.tar.gz",
"semver": "^6.3.0",

View File

@ -2243,6 +2243,14 @@
resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.0.tgz#69a23a3ad29caf0097f06eda59b361ee2f0639f6"
integrity sha1-aaI6OtKcrwCX8G7aWbNh7i8GOfY=
"@types/node-fetch@^2.5.7":
version "2.5.7"
resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.5.7.tgz#20a2afffa882ab04d44ca786449a276f9f6bbf3c"
integrity sha512-o2WVNf5UhWRkxlf6eq+jMZDu7kjgpgJfl4xVNlvryc95O/6F2ld8ztKX+qu+Rjyet93WAWm5LjeX9H5FGkODvw==
dependencies:
"@types/node" "*"
form-data "^3.0.0"
"@types/node@*", "@types/node@>= 8":
version "13.11.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-13.11.0.tgz#390ea202539c61c8fa6ba4428b57e05bc36dc47b"
@ -4418,7 +4426,7 @@ colors@~1.2.1:
resolved "https://registry.yarnpkg.com/colors/-/colors-1.2.5.tgz#89c7ad9a374bc030df8013241f68136ed8835afc"
integrity sha512-erNRLao/Y3Fv54qUa0LBB+//Uf3YwMUmdJinN20yMXm9zdKKqH9wt7R9IIVZ+K7ShzfpLV/Zg8+VyrBJYB4lpg==
combined-stream@^1.0.5, combined-stream@^1.0.6, combined-stream@~1.0.5, combined-stream@~1.0.6:
combined-stream@^1.0.5, combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.5, combined-stream@~1.0.6:
version "1.0.8"
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
@ -6792,6 +6800,15 @@ forever-agent@~0.6.1:
resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=
form-data@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.0.tgz#31b7e39c85f1355b7139ee0c647cf0de7f83c682"
integrity sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg==
dependencies:
asynckit "^0.4.0"
combined-stream "^1.0.8"
mime-types "^2.1.12"
form-data@~2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.0.0.tgz#6f0aebadcc5da16c13e1ecc11137d85f9b883b25"
@ -10742,6 +10759,16 @@ nice-try@^1.0.4:
resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
nock@^13.0.3:
version "13.0.3"
resolved "https://registry.yarnpkg.com/nock/-/nock-13.0.3.tgz#9f81f04499af6a87f9c419a023920b623d715110"
integrity sha512-hDscKS5chEfyEiF8J1syz8mkkH6Wetp04ECAAPNdL5k6e6WmRgx9FZZNnCrjePNdykgiiPXORBcXbNmMzFOP5w==
dependencies:
debug "^4.1.0"
json-stringify-safe "^5.0.1"
lodash.set "^4.3.2"
propagate "^2.0.0"
node-emoji@^1.4.1:
version "1.10.0"
resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-1.10.0.tgz#8886abd25d9c7bb61802a658523d1f8d2a89b2da"
@ -12306,6 +12333,11 @@ promzard@0.3.0:
dependencies:
read "1"
propagate@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/propagate/-/propagate-2.0.1.tgz#40cdedab18085c792334e64f0ac17256d38f9a45"
integrity sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==
protobufjs@6.8.8:
version "6.8.8"
resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-6.8.8.tgz#c8b4f1282fd7a90e6f5b109ed11c84af82908e7c"