|
| 1 | +/** |
| 2 | + * @license |
| 3 | + * Copyright Google LLC All Rights Reserved. |
| 4 | + * |
| 5 | + * Use of this source code is governed by an MIT-style license that can be |
| 6 | + * found in the LICENSE file at https://angular.io/license |
| 7 | + */ |
| 8 | + |
| 9 | +import * as semver from 'semver'; |
| 10 | +import {GithubClient} from '../../../utils/git/github'; |
| 11 | + |
| 12 | +/** Type describing a Github repository with corresponding API client. */ |
| 13 | +export interface GithubRepo { |
| 14 | + /** API client that can access the repository. */ |
| 15 | + api: GithubClient; |
| 16 | + /** Owner login of the repository. */ |
| 17 | + owner: string; |
| 18 | + /** Name of the repository. */ |
| 19 | + repo: string; |
| 20 | + /** |
| 21 | + * NPM package representing this repository. Angular repositories usually contain |
| 22 | + * multiple packages in a monorepo scheme, but packages commonly are released with |
| 23 | + * the same versions. This means that a single package can be used for querying |
| 24 | + * NPM about previously published versions (e.g. to determine active LTS versions). |
| 25 | + * */ |
| 26 | + npmPackageName: string; |
| 27 | +} |
| 28 | + |
| 29 | +/** Type describing a version-branch. */ |
| 30 | +export interface VersionBranch { |
| 31 | + /** Name of the branch in Git. e.g. `10.0.x`. */ |
| 32 | + name: string; |
| 33 | + /** |
| 34 | + * Parsed SemVer version for the version-branch. Version branches technically do |
| 35 | + * not follow the SemVer format, but we can have representative SemVer versions |
| 36 | + * that can be used for comparisons, sorting and other checks. |
| 37 | + */ |
| 38 | + parsed: semver.SemVer; |
| 39 | +} |
| 40 | + |
| 41 | +/** Branch name for the `next` branch. */ |
| 42 | +export const nextBranchName = 'master'; |
| 43 | + |
| 44 | +/** Regular expression that matches version-branches for a release-train. */ |
| 45 | +const releaseTrainBranchNameRegex = /(\d+)\.(\d+)\.x/; |
| 46 | + |
| 47 | +/** |
| 48 | + * Fetches the active release train and its branches for the specified major version. i.e. |
| 49 | + * the latest active release-train branch name is resolved and an optional version-branch for |
| 50 | + * a currently active feature-freeze/release-candidate release-train. |
| 51 | + */ |
| 52 | +export async function fetchActiveReleaseTrainBranches( |
| 53 | + repo: GithubRepo, nextVersion: semver.SemVer): Promise<{ |
| 54 | + /** |
| 55 | + * Name of the currently active release-candidate branch. Null if no |
| 56 | + * feature-freeze/release-candidate is currently active. |
| 57 | + */ |
| 58 | + releaseCandidateBranch: string | null, |
| 59 | + /** Name of the latest non-prerelease version branch (i.e. the patch branch). */ |
| 60 | + latestVersionBranch: string |
| 61 | +}> { |
| 62 | + const majorVersionsToConsider: number[] = []; |
| 63 | + let expectedReleaseCandidateMajor: number; |
| 64 | + |
| 65 | + // If the `next` branch (i.e. `master` branch) is for an upcoming major version, we know |
| 66 | + // that there is no patch branch or feature-freeze/release-candidate branch for this major |
| 67 | + // digit. If the current `next` version is the first minor of a major version, we know that |
| 68 | + // the feature-freeze/release-candidate branch can only be the actual major branch. The |
| 69 | + // patch branch is based on that, either the actual major branch or the last minor from the |
| 70 | + // preceding major version. In all other cases, the patch branch and feature-freeze or |
| 71 | + // release-candidate branch are part of the same major version. Consider the following: |
| 72 | + // |
| 73 | + // CASE 1. next: 11.0.0-next.0: patch and feature-freeze/release-candidate can only be |
| 74 | + // most recent `10.<>.x` branches. The FF/RC branch can only be the last-minor of v10. |
| 75 | + // CASE 2. next: 11.1.0-next.0: patch can be either `11.0.x` or last-minor in v10 based |
| 76 | + // on whether there is a feature-freeze/release-candidate branch (=> `11.0.x`). |
| 77 | + // CASE 3. next: 10.6.0-next.0: patch can be either `10.5.x` or `10.4.x` based on whether |
| 78 | + // there is a feature-freeze/release-candidate branch (=> `10.5.x`) |
| 79 | + if (nextVersion.minor === 0) { |
| 80 | + expectedReleaseCandidateMajor = nextVersion.major - 1; |
| 81 | + majorVersionsToConsider.push(nextVersion.major - 1); |
| 82 | + } else if (nextVersion.minor === 1) { |
| 83 | + expectedReleaseCandidateMajor = nextVersion.major; |
| 84 | + majorVersionsToConsider.push(nextVersion.major, nextVersion.major - 1); |
| 85 | + } else { |
| 86 | + expectedReleaseCandidateMajor = nextVersion.major; |
| 87 | + majorVersionsToConsider.push(nextVersion.major); |
| 88 | + } |
| 89 | + |
| 90 | + // Collect all version-branches that should be considered for the latest version-branch, |
| 91 | + // or the feature-freeze/release-candidate. |
| 92 | + const branches = (await getBranchesForMajorVersions(repo, majorVersionsToConsider)); |
| 93 | + const {latestVersionBranch, releaseCandidateBranch} = |
| 94 | + await findActiveVersionBranches(repo, nextVersion, branches, expectedReleaseCandidateMajor); |
| 95 | + |
| 96 | + if (latestVersionBranch === null) { |
| 97 | + throw Error( |
| 98 | + `Unable to determine the latest release-train. The following branches ` + |
| 99 | + `have been considered: [${branches.join(', ')}]`); |
| 100 | + } |
| 101 | + |
| 102 | + return {releaseCandidateBranch, latestVersionBranch}; |
| 103 | +} |
| 104 | + |
| 105 | +/** Gets the version of a given branch by reading the `package.json` upstream. */ |
| 106 | +export async function getVersionOfBranch( |
| 107 | + repo: GithubRepo, branchName: string): Promise<semver.SemVer> { |
| 108 | + const {data} = |
| 109 | + await repo.api.repos.getContents({...repo, path: '/package.json', ref: branchName}); |
| 110 | + const {version} = JSON.parse(Buffer.from(data.content, 'base64').toString()); |
| 111 | + const parsedVersion = semver.parse(version); |
| 112 | + if (parsedVersion === null) { |
| 113 | + throw Error(`Invalid version detected in following branch: ${branchName}.`); |
| 114 | + } |
| 115 | + return parsedVersion; |
| 116 | +} |
| 117 | + |
| 118 | +/** Whether the given branch corresponds to a release-train branch. */ |
| 119 | +export function isReleaseTrainBranch(branchName: string): boolean { |
| 120 | + return releaseTrainBranchNameRegex.test(branchName); |
| 121 | +} |
| 122 | + |
| 123 | +/** |
| 124 | + * Converts a given version-branch into a SemVer version that can be used with SemVer |
| 125 | + * utilities. e.g. to determine semantic order, extract major digit, compare. |
| 126 | + * |
| 127 | + * For example `10.0.x` will become `10.0.0` in SemVer. The patch digit is not |
| 128 | + * relevant but needed for parsing. SemVer does not allow `x` as patch digit. |
| 129 | + */ |
| 130 | +export function getVersionForReleaseTrainBranch(branchName: string): semver.SemVer|null { |
| 131 | + // Convert a given version-branch into a SemVer version that can be used |
| 132 | + // with the SemVer utilities. i.e. to determine semantic order. |
| 133 | + return semver.parse(branchName.replace(releaseTrainBranchNameRegex, '$1.$2.0')); |
| 134 | +} |
| 135 | + |
| 136 | +/** |
| 137 | + * Gets the version branches for the specified major versions in descending |
| 138 | + * order. i.e. latest version branches first. |
| 139 | + */ |
| 140 | +export async function getBranchesForMajorVersions( |
| 141 | + repo: GithubRepo, majorVersions: number[]): Promise<VersionBranch[]> { |
| 142 | + const {data: branchData} = await repo.api.repos.listBranches({...repo, protected: true}); |
| 143 | + const branches: VersionBranch[] = []; |
| 144 | + |
| 145 | + for (const {name} of branchData) { |
| 146 | + if (!isReleaseTrainBranch(name)) { |
| 147 | + continue; |
| 148 | + } |
| 149 | + // Convert the version-branch into a SemVer version that can be used with the |
| 150 | + // SemVer utilities. e.g. to determine semantic order, compare versions. |
| 151 | + const parsed = getVersionForReleaseTrainBranch(name); |
| 152 | + // Collect all version-branches that match the specified major versions. |
| 153 | + if (parsed !== null && majorVersions.includes(parsed.major)) { |
| 154 | + branches.push({name, parsed}); |
| 155 | + } |
| 156 | + } |
| 157 | + |
| 158 | + // Sort captured version-branches in descending order. |
| 159 | + return branches.sort((a, b) => semver.rcompare(a.parsed, b.parsed)); |
| 160 | +} |
| 161 | + |
| 162 | +export async function findActiveVersionBranches( |
| 163 | + repo: GithubRepo, nextVersion: semver.SemVer, branches: VersionBranch[], |
| 164 | + expectedReleaseCandidateMajor: number): Promise<{ |
| 165 | + latestVersionBranch: string | null, |
| 166 | + releaseCandidateBranch: string | null, |
| 167 | +}> { |
| 168 | + let latestVersionBranch: string|null = null; |
| 169 | + let releaseCandidateBranch: string|null = null; |
| 170 | + |
| 171 | + // Iterate through the captured branches and find the latest non-prerelease branch and a |
| 172 | + // potential release candidate branch. From the collected branches we iterate descending |
| 173 | + // order (most recent semantic version-branch first). The first branch is either the latest |
| 174 | + // active version branch (i.e. patch) or a feature-freeze/release-candidate branch. A FF/RC |
| 175 | + // branch cannot be older than the latest active version-branch, so we stop iterating once |
| 176 | + // we found such a branch. Otherwise, if we found a FF/RC branch, we continue looking for the |
| 177 | + // next version-branch as that one is supposed to be the latest active version-branch. If it |
| 178 | + // is not, then an error will be thrown due to two FF/RC branches existing at the same time. |
| 179 | + for (const {name, parsed} of branches) { |
| 180 | + // It can happen that version branches that are more recent than the version in the next |
| 181 | + // branch (i.e. `master`) have been created. We could ignore such branches silently, but |
| 182 | + // it might actually be symptomatic for an outdated version in the `next` branch, or an |
| 183 | + // accidentally created branch by the caretaker. In either way we want to raise awareness. |
| 184 | + if (semver.gte(parsed, nextVersion)) { |
| 185 | + throw Error( |
| 186 | + `Discovered unexpected version-branch that is representing a minor ` + |
| 187 | + `version more recent than the one in the "${nextBranchName}" branch. Consider ` + |
| 188 | + `deleting the branch, or check if the version in "${nextBranchName}" is outdated.`); |
| 189 | + } |
| 190 | + |
| 191 | + const version = await getVersionOfBranch(repo, name); |
| 192 | + const isPrerelease = version.prerelease[0] === 'rc' || version.prerelease[0] === 'next'; |
| 193 | + if (isPrerelease) { |
| 194 | + if (releaseCandidateBranch !== null) { |
| 195 | + throw Error( |
| 196 | + `Unable to determine latest release-train. Found two consecutive ` + |
| 197 | + `branches in feature-freeze/release-candidate phase. Did not expect both "${name}" ` + |
| 198 | + `and "${releaseCandidateBranch}" to be in feature-freeze/release-candidate mode.`); |
| 199 | + } else if (version.major !== expectedReleaseCandidateMajor) { |
| 200 | + throw Error( |
| 201 | + `Discovered unexpected old feature-freeze/release-candidate branch. Expected no ` + |
| 202 | + `version-branch in feature-freeze/release-candidate mode for v${version.major}.`); |
| 203 | + } |
| 204 | + releaseCandidateBranch = name; |
| 205 | + } else { |
| 206 | + latestVersionBranch = name; |
| 207 | + break; |
| 208 | + } |
| 209 | + } |
| 210 | + |
| 211 | + return {releaseCandidateBranch, latestVersionBranch}; |
| 212 | +} |
0 commit comments