From 3fd550c285e8c77375ed74003a74c4c67a18fe51 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Thu, 5 Aug 2021 19:26:20 +0200 Subject: [PATCH] fix(ng-dev): publishing fails if pre-release is cut for new minor/major Currently publishing fails if a pre-release is cut for an upcoming major/minor. This is because the `next` release-train has some special logic where its version is already bumped, but the version has not been released. Our release notes logic currently when building a pre-release for next, looks for a tag named similar to the version in the `package.json`. This will always fail. To fix this, we consult the release trains for building up the Git range that is used for the release notes. We still rely on tags, but we determine the tags from the active release trains (which is more robust than relying on the package json value). For the special `next` release-train situation, we build up the release notes from the most recent patch to `HEAD` of next. While doing this, we also dedupe any commits that have already landed in patch, RC, or FF releases. --- WORKSPACE | 6 + ng-dev/commit-message/parse.ts | 7 +- ng-dev/release/notes/cli.ts | 10 +- .../notes/commits/get-commits-in-range.png | Bin 0 -> 35161 bytes .../notes/commits/get-commits-in-range.ts | 94 ++++++ .../release/notes/commits/unique-commit-id.ts | 28 ++ ng-dev/release/notes/release-notes.ts | 29 +- ng-dev/release/publish/actions.ts | 48 ++- .../publish/actions/branch-off-next-branch.ts | 11 +- .../release/publish/actions/cut-lts-patch.ts | 3 + .../release/publish/actions/cut-new-patch.ts | 5 +- .../publish/actions/cut-next-prerelease.ts | 20 +- ...ut-release-candidate-for-feature-freeze.ts | 2 + ng-dev/release/publish/actions/cut-stable.ts | 5 + ng-dev/release/publish/cli.ts | 5 +- ng-dev/release/publish/test/BUILD.bazel | 2 + .../test/branch-off-next-branch-testing.ts | 95 ++++-- ng-dev/release/publish/test/common.spec.ts | 44 +-- .../test/configure-next-as-major.spec.ts | 3 +- .../publish/test/cut-lts-patch.spec.ts | 63 +++- .../publish/test/cut-new-patch.spec.ts | 46 ++- .../publish/test/cut-next-prerelease.spec.ts | 157 ++++++++-- ...lease-candidate-for-feature-freeze.spec.ts | 46 ++- .../release/publish/test/cut-stable.spec.ts | 75 ++++- .../move-next-into-feature-freeze.spec.ts | 108 ++++++- .../move-next-into-release-candidate.spec.ts | 109 ++++++- .../test/release-notes/release-notes-utils.ts | 43 --- .../test/tag-recent-major-as-latest.spec.ts | 13 +- ng-dev/release/publish/test/test-utils.ts | 287 ------------------ .../publish/test/test-utils/action-mocks.ts | 121 ++++++++ .../{ => test-utils}/github-api-testing.ts | 0 .../test/test-utils/sandbox-git-client.ts | 65 ++++ .../test/test-utils/sandbox-testing.ts | 91 ++++++ .../publish/test/test-utils/staging-test.ts | 165 ++++++++++ .../publish/test/test-utils/test-action.ts | 47 +++ .../publish/test/test-utils/test-utils.ts | 98 ++++++ .../versioning/next-prerelease-version.ts | 21 ++ ng-dev/release/versioning/version-tags.ts | 14 + ng-dev/utils/git/git-client.ts | 43 +-- ng-dev/utils/testing/dedent.ts | 28 ++ ng-dev/utils/testing/virtual-git-client.ts | 19 +- tools/git-toolchain/BUILD.bazel | 65 ++++ tools/git-toolchain/alias.bzl | 17 ++ tools/git-toolchain/toolchain.bzl | 14 + tsconfig.json | 2 + 45 files changed, 1649 insertions(+), 525 deletions(-) create mode 100644 ng-dev/release/notes/commits/get-commits-in-range.png create mode 100644 ng-dev/release/notes/commits/get-commits-in-range.ts create mode 100644 ng-dev/release/notes/commits/unique-commit-id.ts delete mode 100644 ng-dev/release/publish/test/release-notes/release-notes-utils.ts delete mode 100644 ng-dev/release/publish/test/test-utils.ts create mode 100644 ng-dev/release/publish/test/test-utils/action-mocks.ts rename ng-dev/release/publish/test/{ => test-utils}/github-api-testing.ts (100%) create mode 100644 ng-dev/release/publish/test/test-utils/sandbox-git-client.ts create mode 100644 ng-dev/release/publish/test/test-utils/sandbox-testing.ts create mode 100644 ng-dev/release/publish/test/test-utils/staging-test.ts create mode 100644 ng-dev/release/publish/test/test-utils/test-action.ts create mode 100644 ng-dev/release/publish/test/test-utils/test-utils.ts create mode 100644 ng-dev/release/versioning/version-tags.ts create mode 100644 ng-dev/utils/testing/dedent.ts create mode 100644 tools/git-toolchain/BUILD.bazel create mode 100644 tools/git-toolchain/alias.bzl create mode 100644 tools/git-toolchain/toolchain.bzl diff --git a/WORKSPACE b/WORKSPACE index 1932519b1..f2343bea6 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -44,3 +44,9 @@ browser_repositories() load("@npm//@bazel/esbuild:esbuild_repositories.bzl", "esbuild_repositories") esbuild_repositories() + +register_toolchains( + "//tools/git-toolchain:git_linux_toolchain", + "//tools/git-toolchain:git_macos_toolchain", + "//tools/git-toolchain:git_windows_toolchain", +) diff --git a/ng-dev/commit-message/parse.ts b/ng-dev/commit-message/parse.ts index 6bdb793b2..6e8686837 100644 --- a/ng-dev/commit-message/parse.ts +++ b/ng-dev/commit-message/parse.ts @@ -142,10 +142,9 @@ function parseInternal(fullText: string | Buffer): CommitFromGitLog | Commit { // Extract the commit message notes by marked types into their respective lists. commit.notes.forEach((note: ParsedCommit.Note) => { if (note.title === NoteSections.BREAKING_CHANGE) { - return breakingChanges.push(note); - } - if (note.title === NoteSections.DEPRECATED) { - return deprecations.push(note); + breakingChanges.push(note); + } else if (note.title === NoteSections.DEPRECATED) { + deprecations.push(note); } }); diff --git a/ng-dev/release/notes/cli.ts b/ng-dev/release/notes/cli.ts index 06c728cb9..c3888768f 100644 --- a/ng-dev/release/notes/cli.ts +++ b/ng-dev/release/notes/cli.ts @@ -12,13 +12,12 @@ import {SemVer} from 'semver'; import {Arguments, Argv, CommandModule} from 'yargs'; import {info} from '../../utils/console'; -import {GitClient} from '../../utils/git/git-client'; import {ReleaseNotes} from './release-notes'; /** Command line options for building a release. */ export interface ReleaseNotesOptions { - from?: string; + from: string; to: string; outFile?: string; releaseVersion: SemVer; @@ -36,7 +35,7 @@ function builder(argv: Argv): Argv { .option('from', { type: 'string', description: 'The git tag or ref to start the changelog entry from', - defaultDescription: 'The latest semver tag', + demandOption: true, }) .option('to', { type: 'string', @@ -58,11 +57,8 @@ function builder(argv: Argv): Argv { /** Yargs command handler for generating release notes. */ async function handler({releaseVersion, from, to, outFile, type}: Arguments) { - // Since `yargs` evaluates defaults even if a value as been provided, if no value is provided to - // the handler, the latest semver tag on the branch is used. - from = from || GitClient.get().getLatestSemverTag().format(); /** The ReleaseNotes instance to generate release notes. */ - const releaseNotes = await ReleaseNotes.fromRange(releaseVersion, from, to); + const releaseNotes = await ReleaseNotes.forRange(releaseVersion, from, to); /** The requested release notes entry. */ const releaseNotesEntry = await (type === 'changelog' diff --git a/ng-dev/release/notes/commits/get-commits-in-range.png b/ng-dev/release/notes/commits/get-commits-in-range.png new file mode 100644 index 0000000000000000000000000000000000000000..5788395419ae82db4e983e5cb8f6bb81e5fac204 GIT binary patch literal 35161 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

%iFeNcU}3aOm;VdvyhulOnLkagJX0yAeIJ^ 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

^)3_s=D^ZUUw8morG6C@JYndo6 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..` revision range as per Git. + * https://git-scm.com/book/en/v2/Git-Tools-Revision-Selection. + * + * Branches in the Angular organization are diverging quickly due to multiple factors + * concerning the versioning and merging. i.e. Commits are cherry-picked into branches, + * resulting in different SHAs for each branch. Additionally, branches diverge quickly + * because changes can be made only for specific branches (e.g. a master-only change). + * + * In order to allow for comparisons that follow similar semantics as Git's double-dot + * revision range syntax, the logic re-implementing the semantics need to account for + * the mentioned semi-diverged branches. We achieve this by excluding commits in the + * head branch which have a similarly-named commit in the base branch. We cannot rely on + * SHAs for determining common commits between the two branches (as explained above). + * + * More details can be found in the `get-commits-in-range.png` file which illustrates a + * scenario where commits from the patch branch need to be excluded from the main branch. + */ +export function getCommitsForRangeWithDeduping( + client: GitClient, + baseRef: string, + headRef: string, +): CommitFromGitLog[] { + const commits: CommitFromGitLog[] = []; + const commitsForHead = fetchCommitsForRevisionRange(client, `${baseRef}..${headRef}`); + const commitsForBase = fetchCommitsForRevisionRange(client, `${headRef}..${baseRef}`); + + // Map that keeps track of commits within the base branch. Commits are + // stored with an unique id based on the commit message. If a similarly-named + // commit appears multiple times, the value number will reflect that. + const knownCommitsOnlyInBase = new Map(); + + for (const commit of commitsForBase) { + const id = computeUniqueIdFromCommitMessage(commit); + const numSimilarCommits = knownCommitsOnlyInBase.get(id) ?? 0; + knownCommitsOnlyInBase.set(id, numSimilarCommits + 1); + } + + for (const commit of commitsForHead) { + const id = computeUniqueIdFromCommitMessage(commit); + const numSimilarCommits = knownCommitsOnlyInBase.get(id) ?? 0; + + // If there is a similar commit in the base branch, the current commit in the head branch + // needs to be skipped. We keep track of the number of similar commits so that we do not + // accidentally "dedupe" a commit. e.g. consider a case where commit `X` lands in the + // patch branch and next branch. Then a similar similarly named commits lands only in the + // next branch. We would not want to omit that one as it is not part of the patch branch. + if (numSimilarCommits > 0) { + knownCommitsOnlyInBase.set(id, numSimilarCommits - 1); + continue; + } + + commits.push(commit); + } + + return commits; +} + +/** Fetches commits for the given revision range using `git log`. */ +export function fetchCommitsForRevisionRange( + client: GitClient, + revisionRange: string, +): CommitFromGitLog[] { + const splitDelimiter = '-------------ɵɵ------------'; + const output = client.run([ + 'log', + `--format=${gitLogFormatForParsing}${splitDelimiter}`, + revisionRange, + ]); + + return output.stdout + .split(splitDelimiter) + .filter((entry) => !!entry.trim()) + .map((entry) => parseCommitFromGitLog(Buffer.from(entry, 'utf-8'))); +} diff --git a/ng-dev/release/notes/commits/unique-commit-id.ts b/ng-dev/release/notes/commits/unique-commit-id.ts new file mode 100644 index 000000000..4114fe182 --- /dev/null +++ b/ng-dev/release/notes/commits/unique-commit-id.ts @@ -0,0 +1,28 @@ +/** + * @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 {Commit} from '../../../commit-message/parse'; + +/** + * Fields from a `Commit` to incorporate when building up an unique + * id for a commit message. + * + * Note: The header incorporates the commit type, scope and message + * but lacks information for fixup, revert or squash commits.. + */ +const fieldsToIncorporateForId: (keyof Commit)[] = ['header', 'isFixup', 'isRevert', 'isSquash']; + +/** + * Computes an unique id for the given commit using its commit message. + * This can be helpful for comparisons, if commits differ in SHAs due + * to cherry-picking. + */ +export function computeUniqueIdFromCommitMessage(commit: Commit): string { + // Join all resolved fields with a special character to build up an id. + return fieldsToIncorporateForId.map((f) => commit[f]).join('ɵɵ'); +} diff --git a/ng-dev/release/notes/release-notes.ts b/ng-dev/release/notes/release-notes.ts index 46d77d57f..452bde7f8 100644 --- a/ng-dev/release/notes/release-notes.ts +++ b/ng-dev/release/notes/release-notes.ts @@ -9,7 +9,6 @@ import {render} from 'ejs'; import * as semver from 'semver'; import {CommitFromGitLog} from '../../commit-message/parse'; -import {getCommitsInRange} from '../../commit-message/utils'; import {promptInput} from '../../utils/console'; import {GitClient} from '../../utils/git/git-client'; import {DevInfraReleaseConfig, getReleaseConfig, ReleaseNotesConfig} from '../config/index'; @@ -17,11 +16,14 @@ import {RenderContext} from './context'; import changelogTemplate from './templates/changelog'; import githubReleaseTemplate from './templates/github-release'; +import {getCommitsForRangeWithDeduping} from './commits/get-commits-in-range'; /** Release note generation. */ export class ReleaseNotes { - static async fromRange(version: semver.SemVer, startingRef: string, endingRef: string) { - return new ReleaseNotes(version, startingRef, endingRef); + static async forRange(version: semver.SemVer, baseRef: string, headRef: string) { + const client = GitClient.get(); + const commits = getCommitsForRangeWithDeduping(client, baseRef, headRef); + return new ReleaseNotes(version, commits); } /** An instance of GitClient. */ @@ -30,19 +32,10 @@ export class ReleaseNotes { private renderContext: RenderContext | undefined; /** The title to use for the release. */ private title: string | false | undefined; - /** A promise resolving to a list of Commits since the latest semver tag on the branch. */ - private commits: Promise = this.getCommitsInRange( - this.startingRef, - this.endingRef, - ); /** The configuration for release notes. */ private config: ReleaseNotesConfig = this.getReleaseConfig().releaseNotes; - protected constructor( - public version: semver.SemVer, - private startingRef: string, - private endingRef: string, - ) {} + protected constructor(public version: semver.SemVer, private commits: CommitFromGitLog[]) {} /** Retrieve the release note generated for a Github Release. */ async getGithubReleaseEntry(): Promise { @@ -75,7 +68,7 @@ export class ReleaseNotes { private async generateRenderContext(): Promise { if (!this.renderContext) { this.renderContext = new RenderContext({ - commits: await this.commits, + commits: this.commits, github: this.git.remoteConfig, version: this.version.format(), groupOrder: this.config.groupOrder, @@ -86,12 +79,8 @@ export class ReleaseNotes { return this.renderContext; } - // These methods are used for access to the utility functions while allowing them to be - // overwritten in subclasses during testing. - protected async getCommitsInRange(from: string, to?: string) { - return getCommitsInRange(from, to); - } - + // These methods are used for access to the utility functions while allowing them + // to be overwritten in subclasses during testing. protected getReleaseConfig(config?: Partial) { return getReleaseConfig(config); } diff --git a/ng-dev/release/publish/actions.ts b/ng-dev/release/publish/actions.ts index ca6b8f698..a41db2c4b 100644 --- a/ng-dev/release/publish/actions.ts +++ b/ng-dev/release/publish/actions.ts @@ -27,6 +27,7 @@ import {changelogPath, packageJsonPath, waitForPullRequestInterval} from './cons import {invokeReleaseBuildCommand, invokeYarnInstallCommand} from './external-commands'; import {findOwnedForksOfRepoQuery} from './graphql-queries'; import {getPullRequestState} from './pull-request-state'; +import {getReleaseTagForVersion} from '../versioning/version-tags'; /** Interface describing a Github repository. */ export interface GithubRepo { @@ -377,33 +378,44 @@ export abstract class ReleaseAction { /** * Creates a commit for the specified files with the given message. * @param message Message for the created commit - * @param files List of project-relative file paths to be commited. + * @param files List of project-relative file paths to be committed. */ protected async createCommit(message: string, files: string[]) { + // Note: `git add` would not be needed if the files are already known to + // Git, but the specified files could also be newly created, and unknown. + this.git.run(['add', ...files]); this.git.run(['commit', '-q', '--no-verify', '-m', message, ...files]); } /** - * Stages the specified new version for the current branch and creates a - * pull request that targets the given base branch. + * Stages the specified new version for the current branch and creates a pull request + * that targets the given base branch. Assumes the staging branch is already checked-out. + * + * @param newVersion New version to be staged. + * @param compareVersionForReleaseNotes Version used for comparing with the current + * `HEAD` in order build the release notes. + * @param pullRequestTargetBranch Branch the pull request should target. * @returns an object describing the created pull request. */ protected async stageVersionForBranchAndCreatePullRequest( newVersion: semver.SemVer, - pullRequestBaseBranch: string, + compareVersionForReleaseNotes: semver.SemVer, + pullRequestTargetBranch: string, ): Promise<{releaseNotes: ReleaseNotes; pullRequest: PullRequest}> { - /** - * The current version of the project for the branch from the root package.json. This must be - * retrieved prior to updating the project version. - */ - const currentVersion = this.git.getMatchingTagForSemver(await this.getProjectVersion()); - const releaseNotes = await ReleaseNotes.fromRange(newVersion, currentVersion, 'HEAD'); + const releaseNotesCompareTag = getReleaseTagForVersion(compareVersionForReleaseNotes); + + // Fetch the compare tag so that commits for the release notes can be determined. + this.git.run(['fetch', this.git.getRepoGitUrl(), `refs/tags/${releaseNotesCompareTag}`]); + + // Build release notes for commits from `..HEAD`. + const releaseNotes = await ReleaseNotes.forRange(newVersion, releaseNotesCompareTag, 'HEAD'); + await this.updateProjectVersion(newVersion); await this.prependReleaseNotesToChangelog(releaseNotes); await this.waitForEditsAndCreateReleaseCommit(newVersion); const pullRequest = await this.pushChangesToForkAndCreatePullRequest( - pullRequestBaseBranch, + pullRequestTargetBranch, `release-stage-${newVersion}`, `Bump version to "v${newVersion}" with changelog.`, ); @@ -417,15 +429,25 @@ export abstract class ReleaseAction { /** * Checks out the specified target branch, verifies its CI status and stages * the specified new version in order to create a pull request. + * + * @param newVersion New version to be staged. + * @param compareVersionForReleaseNotes Version used for comparing with `HEAD` of + * the staging branch in order build the release notes. + * @param stagingBranch Branch within the new version should be staged. * @returns an object describing the created pull request. */ protected async checkoutBranchAndStageVersion( newVersion: semver.SemVer, + compareVersionForReleaseNotes: semver.SemVer, stagingBranch: string, ): Promise<{releaseNotes: ReleaseNotes; pullRequest: PullRequest}> { await this.verifyPassingGithubStatus(stagingBranch); await this.checkoutUpstreamBranch(stagingBranch); - return await this.stageVersionForBranchAndCreatePullRequest(newVersion, stagingBranch); + return await this.stageVersionForBranchAndCreatePullRequest( + newVersion, + compareVersionForReleaseNotes, + stagingBranch, + ); } /** @@ -481,7 +503,7 @@ export abstract class ReleaseAction { versionBumpCommitSha: string, prerelease: boolean, ) { - const tagName = releaseNotes.version.format(); + const tagName = getReleaseTagForVersion(releaseNotes.version); await this.git.github.git.createRef({ ...this.git.remoteParams, ref: `refs/tags/${tagName}`, diff --git a/ng-dev/release/publish/actions/branch-off-next-branch.ts b/ng-dev/release/publish/actions/branch-off-next-branch.ts index 4460f9de7..b7a53d09d 100644 --- a/ng-dev/release/publish/actions/branch-off-next-branch.ts +++ b/ng-dev/release/publish/actions/branch-off-next-branch.ts @@ -11,8 +11,10 @@ import * as semver from 'semver'; import {green, info, yellow} from '../../../utils/console'; import {semverInc} from '../../../utils/semver'; import {ReleaseNotes} from '../../notes/release-notes'; -import {ActiveReleaseTrains} from '../../versioning/active-release-trains'; -import {computeNewPrereleaseVersionForNext} from '../../versioning/next-prerelease-version'; +import { + computeNewPrereleaseVersionForNext, + getReleaseNotesCompareVersionForNext, +} from '../../versioning/next-prerelease-version'; import {ReleaseAction} from '../actions'; import { getCommitMessageForExceptionalNextVersionBump, @@ -42,6 +44,10 @@ export abstract class BranchOffNextBranchBaseAction extends ReleaseAction { } override async perform() { + const compareVersionForReleaseNotes = await getReleaseNotesCompareVersionForNext( + this.active, + this.config, + ); const newVersion = await this._computeNewVersion(); const newBranch = `${newVersion.major}.${newVersion.minor}.x`; @@ -53,6 +59,7 @@ export abstract class BranchOffNextBranchBaseAction extends ReleaseAction { // created branch instead of re-fetching from the upstream. const {pullRequest, releaseNotes} = await this.stageVersionForBranchAndCreatePullRequest( newVersion, + compareVersionForReleaseNotes, newBranch, ); diff --git a/ng-dev/release/publish/actions/cut-lts-patch.ts b/ng-dev/release/publish/actions/cut-lts-patch.ts index 860fdda12..5aff9a50e 100644 --- a/ng-dev/release/publish/actions/cut-lts-patch.ts +++ b/ng-dev/release/publish/actions/cut-lts-patch.ts @@ -30,8 +30,11 @@ export class CutLongTermSupportPatchAction extends ReleaseAction { override async perform() { const ltsBranch = await this._promptForTargetLtsBranch(); const newVersion = semverInc(ltsBranch.version, 'patch'); + const compareVersionForReleaseNotes = ltsBranch.version; + const {pullRequest, releaseNotes} = await this.checkoutBranchAndStageVersion( newVersion, + compareVersionForReleaseNotes, ltsBranch.name, ); diff --git a/ng-dev/release/publish/actions/cut-new-patch.ts b/ng-dev/release/publish/actions/cut-new-patch.ts index ff69092b5..22fcff10e 100644 --- a/ng-dev/release/publish/actions/cut-new-patch.ts +++ b/ng-dev/release/publish/actions/cut-new-patch.ts @@ -16,7 +16,8 @@ import {ReleaseAction} from '../actions'; * for the new patch version, but also needs to be cherry-picked into the next development branch. */ export class CutNewPatchAction extends ReleaseAction { - private _newVersion = semverInc(this.active.latest.version, 'patch'); + private _previousVersion = this.active.latest.version; + private _newVersion = semverInc(this._previousVersion, 'patch'); override async getDescription() { const {branchName} = this.active.latest; @@ -27,9 +28,11 @@ export class CutNewPatchAction extends ReleaseAction { override async perform() { const {branchName} = this.active.latest; const newVersion = this._newVersion; + const compareVersionForReleaseNotes = this._previousVersion; const {pullRequest, releaseNotes} = await this.checkoutBranchAndStageVersion( newVersion, + compareVersionForReleaseNotes, branchName, ); diff --git a/ng-dev/release/publish/actions/cut-next-prerelease.ts b/ng-dev/release/publish/actions/cut-next-prerelease.ts index 2dab36ffd..fba51c90a 100644 --- a/ng-dev/release/publish/actions/cut-next-prerelease.ts +++ b/ng-dev/release/publish/actions/cut-next-prerelease.ts @@ -9,7 +9,10 @@ import * as semver from 'semver'; import {semverInc} from '../../../utils/semver'; -import {computeNewPrereleaseVersionForNext} from '../../versioning/next-prerelease-version'; +import { + computeNewPrereleaseVersionForNext, + getReleaseNotesCompareVersionForNext, +} from '../../versioning/next-prerelease-version'; import {ReleaseTrain} from '../../versioning/release-trains'; import {ReleaseAction} from '../actions'; @@ -31,9 +34,11 @@ export class CutNextPrereleaseAction extends ReleaseAction { const releaseTrain = this._getActivePrereleaseTrain(); const {branchName} = releaseTrain; const newVersion = await this._newVersion; + const compareVersionForReleaseNotes = await this._getCompareVersionForReleaseNotes(); const {pullRequest, releaseNotes} = await this.checkoutBranchAndStageVersion( newVersion, + compareVersionForReleaseNotes, branchName, ); @@ -66,6 +71,19 @@ export class CutNextPrereleaseAction extends ReleaseAction { } } + /** Gets the compare version for building release notes of the new pre-release.*/ + private async _getCompareVersionForReleaseNotes(): Promise { + const releaseTrain = this._getActivePrereleaseTrain(); + // If a pre-release is cut for the next release-train, the compare version is computed + // with respect to special cases surfacing with FF/RC branches. Otherwise, the current + // version from the release train is used for comparison. + if (releaseTrain === this.active.next) { + return await getReleaseNotesCompareVersionForNext(this.active, this.config); + } else { + return releaseTrain.version; + } + } + static override async isActive() { // Pre-releases for the `next` NPM dist tag can always be cut. Depending on whether // there is a feature-freeze/release-candidate branch, the next pre-releases are either diff --git a/ng-dev/release/publish/actions/cut-release-candidate-for-feature-freeze.ts b/ng-dev/release/publish/actions/cut-release-candidate-for-feature-freeze.ts index 078f584dc..78c330fba 100644 --- a/ng-dev/release/publish/actions/cut-release-candidate-for-feature-freeze.ts +++ b/ng-dev/release/publish/actions/cut-release-candidate-for-feature-freeze.ts @@ -25,9 +25,11 @@ export class CutReleaseCandidateForFeatureFreezeAction extends ReleaseAction { override async perform() { const {branchName} = this.active.releaseCandidate!; const newVersion = this._newVersion; + const compareVersionForReleaseNotes = this.active.releaseCandidate!.version; const {pullRequest, releaseNotes} = await this.checkoutBranchAndStageVersion( newVersion, + compareVersionForReleaseNotes, branchName, ); diff --git a/ng-dev/release/publish/actions/cut-stable.ts b/ng-dev/release/publish/actions/cut-stable.ts index 3ae5b55f1..0beed8245 100644 --- a/ng-dev/release/publish/actions/cut-stable.ts +++ b/ng-dev/release/publish/actions/cut-stable.ts @@ -30,8 +30,13 @@ export class CutStableAction extends ReleaseAction { const newVersion = this._newVersion; const isNewMajor = this.active.releaseCandidate?.isMajor; + // When cutting a new stable minor/major, we want to build the release notes capturing + // all changes that have landed in the individual next and RC pre-releases. + const compareVersionForReleaseNotes = this.active.latest.version; + const {pullRequest, releaseNotes} = await this.checkoutBranchAndStageVersion( newVersion, + compareVersionForReleaseNotes, branchName, ); diff --git a/ng-dev/release/publish/cli.ts b/ng-dev/release/publish/cli.ts index 5a6ec8363..cb5a2c2cd 100644 --- a/ng-dev/release/publish/cli.ts +++ b/ng-dev/release/publish/cli.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {Arguments, Argv, CommandModule} from 'yargs'; +import {Argv, CommandModule} from 'yargs'; import {getConfig} from '../../utils/config'; import {error, green, info, red, yellow} from '../../utils/console'; @@ -31,8 +31,7 @@ async function handler() { const git = GitClient.get(); const config = getConfig(); const releaseConfig = getReleaseConfig(config); - const projectDir = git.baseDir; - const task = new ReleaseTool(releaseConfig, config.github, projectDir); + const task = new ReleaseTool(releaseConfig, config.github, git.baseDir); const result = await task.run(); switch (result) { diff --git a/ng-dev/release/publish/test/BUILD.bazel b/ng-dev/release/publish/test/BUILD.bazel index fff939011..f9582cf42 100644 --- a/ng-dev/release/publish/test/BUILD.bazel +++ b/ng-dev/release/publish/test/BUILD.bazel @@ -25,5 +25,7 @@ ts_library( jasmine_node_test( name = "test", + args = ["'$(GIT_BIN_PATH)'"], specs = [":test_lib"], + toolchains = ["//tools/git-toolchain:current_git_toolchain"], ) diff --git a/ng-dev/release/publish/test/branch-off-next-branch-testing.ts b/ng-dev/release/publish/test/branch-off-next-branch-testing.ts index 75ca1393f..3baf1e79e 100644 --- a/ng-dev/release/publish/test/branch-off-next-branch-testing.ts +++ b/ng-dev/release/publish/test/branch-off-next-branch-testing.ts @@ -12,27 +12,22 @@ import * as npm from '../../versioning/npm-publish'; import {ReleaseActionConstructor} from '../actions'; import {BranchOffNextBranchBaseAction} from '../actions/branch-off-next-branch'; import * as externalCommands from '../external-commands'; - -import {setupReleaseActionForTesting, testTmpDir} from './test-utils'; +import {setupReleaseActionForTesting} from './test-utils/test-utils'; +import {testReleasePackages, testTmpDir} from './test-utils/action-mocks'; +import {readFileSync} from 'fs'; +import {TestReleaseAction} from './test-utils/test-action'; /** - * Performs the given branch-off release action and expects versions and - * branches to be determined and created properly. + * Expects and fakes the necessary Github API requests for branching-off + * the next branch to a specified new version. */ -export async function expectBranchOffActionToRun( - action: ReleaseActionConstructor, - active: ActiveReleaseTrains, - isNextPublishedToNpm: boolean, +async function expectGithubApiRequestsForBranchOff( + action: Omit, expectedNextVersion: string, expectedVersion: string, expectedNewBranch: string, ) { - const {repo, fork, instance, gitClient} = setupReleaseActionForTesting( - action, - active, - isNextPublishedToNpm, - ); - + const {repo, fork} = action; const expectedNextUpdateBranch = `next-release-train-${expectedNextVersion}`; const expectedStagingForkBranch = `release-stage-${expectedVersion}`; const expectedTagName = expectedVersion; @@ -60,6 +55,32 @@ export async function expectBranchOffActionToRun( .expectBranchRequest(expectedStagingForkBranch, null) .expectBranchRequest(expectedNextUpdateBranch, null); + return {expectedNextUpdateBranch, expectedStagingForkBranch, expectedTagName}; +} + +/** + * Performs the given branch-off release action and expects versions and + * branches to be determined and created properly. + */ +export async function expectBranchOffActionToRun( + actionType: ReleaseActionConstructor, + active: ActiveReleaseTrains, + isNextPublishedToNpm: boolean, + expectedNextVersion: string, + expectedVersion: string, + expectedNewBranch: string, +) { + const action = setupReleaseActionForTesting(actionType, active, isNextPublishedToNpm); + const {repo, fork, instance, gitClient} = action; + + const {expectedStagingForkBranch, expectedNextUpdateBranch} = + await expectGithubApiRequestsForBranchOff( + action, + expectedNextVersion, + expectedVersion, + expectedNewBranch, + ); + await instance.perform(); expect(gitClient.pushed.length).toBe(3); @@ -110,7 +131,47 @@ export async function expectBranchOffActionToRun( ); expect(externalCommands.invokeReleaseBuildCommand).toHaveBeenCalledTimes(1); - expect(npm.runNpmPublish).toHaveBeenCalledTimes(2); - expect(npm.runNpmPublish).toHaveBeenCalledWith(`${testTmpDir}/dist/pkg1`, 'next', undefined); - expect(npm.runNpmPublish).toHaveBeenCalledWith(`${testTmpDir}/dist/pkg2`, 'next', undefined); + expect(npm.runNpmPublish).toHaveBeenCalledTimes(testReleasePackages.length); + + for (const pkgName of testReleasePackages) { + expect(npm.runNpmPublish).toHaveBeenCalledWith( + `${testTmpDir}/dist/${pkgName}`, + 'next', + undefined, + ); + } +} + +/** + * Prepares the specified release action for a test run where the changelog is being + * generated. The action is not run automatically because the test author should still + * be able to operate within the sandbox git repo. + * + * A function is exposed that can be invoked to build the changelog. + */ +export function prepareBranchOffActionForChangelog( + actionType: ReleaseActionConstructor, + active: ActiveReleaseTrains, + isNextPublishedToNpm: boolean, + expectedNextVersion: string, + expectedVersion: string, + expectedNewBranch: string, +) { + const action = setupReleaseActionForTesting(actionType, active, isNextPublishedToNpm, { + useSandboxGitClient: true, + }); + + const buildChangelog = async () => { + await expectGithubApiRequestsForBranchOff( + action, + expectedNextVersion, + expectedVersion, + expectedNewBranch, + ); + await action.instance.perform(); + + return readFileSync(`${testTmpDir}/CHANGELOG.md`, 'utf8'); + }; + + return {action, buildChangelog}; } diff --git a/ng-dev/release/publish/test/common.spec.ts b/ng-dev/release/publish/test/common.spec.ts index e2ab59061..7066f24ae 100644 --- a/ng-dev/release/publish/test/common.spec.ts +++ b/ng-dev/release/publish/test/common.spec.ts @@ -19,14 +19,18 @@ import {ReleaseTrain} from '../../versioning/release-trains'; import {ReleaseAction} from '../actions'; import {actions} from '../actions/index'; import {changelogPath} from '../constants'; - import { + changelogPattern, fakeNpmPackageQueryRequest, - getTestingMocksForReleaseAction, parse, setupReleaseActionForTesting, +} from './test-utils/test-utils'; +import { + getMockGitClient, + getTestConfigurationsForAction, + testReleasePackages, testTmpDir, -} from './test-utils'; +} from './test-utils/action-mocks'; describe('common release action logic', () => { const baseReleaseTrains: ActiveReleaseTrains = { @@ -43,7 +47,8 @@ describe('common release action logic', () => { }; it('should not modify release train versions and cause invalid other actions', async () => { - const {releaseConfig, gitClient} = getTestingMocksForReleaseAction(); + const {releaseConfig, githubConfig} = getTestConfigurationsForAction(); + const gitClient = getMockGitClient(githubConfig, /* useSandboxGitClient */ false); const descriptions: string[] = []; // Fake the NPM package request as otherwise the test would rely on `npmjs.org`. @@ -86,17 +91,15 @@ describe('common release action logic', () => { await instance.testBuildAndPublish(version, branchName, 'latest'); - expect(npm.runNpmPublish).toHaveBeenCalledTimes(2); - expect(npm.runNpmPublish).toHaveBeenCalledWith( - `${testTmpDir}/dist/pkg1`, - 'latest', - customRegistryUrl, - ); - expect(npm.runNpmPublish).toHaveBeenCalledWith( - `${testTmpDir}/dist/pkg2`, - 'latest', - customRegistryUrl, - ); + expect(npm.runNpmPublish).toHaveBeenCalledTimes(testReleasePackages.length); + + for (const pkgName of testReleasePackages) { + expect(npm.runNpmPublish).toHaveBeenCalledWith( + `${testTmpDir}/dist/${pkgName}`, + 'latest', + customRegistryUrl, + ); + } }); }); @@ -123,7 +126,12 @@ describe('common release action logic', () => { await instance.testCherryPickWithPullRequest(version, branchName); const changelogContent = readFileSync(join(testTmpDir, changelogPath), 'utf8'); - expect(changelogContent).toEqual(`Changelog Entry for 10.0.1\n\nExisting changelog`); + expect(changelogContent).toMatch(changelogPattern` + # 10.0.1 <..> + + + Existing changelog + `); }); it('should push changes to a fork for creating a pull request', async () => { @@ -177,12 +185,12 @@ class TestAction extends ReleaseAction { } async testBuildAndPublish(version: semver.SemVer, publishBranch: string, distTag: NpmDistTag) { - const releaseNotes = await ReleaseNotes.fromRange(version, '', ''); + const releaseNotes = await ReleaseNotes.forRange(version, '', ''); await this.buildAndPublish(releaseNotes, publishBranch, distTag); } async testCherryPickWithPullRequest(version: semver.SemVer, branch: string) { - const releaseNotes = await ReleaseNotes.fromRange(version, '', ''); + const releaseNotes = await ReleaseNotes.forRange(version, '', ''); await this.cherryPickChangelogIntoNextBranch(releaseNotes, branch); } } diff --git a/ng-dev/release/publish/test/configure-next-as-major.spec.ts b/ng-dev/release/publish/test/configure-next-as-major.spec.ts index 80db233e6..5bcdee014 100644 --- a/ng-dev/release/publish/test/configure-next-as-major.spec.ts +++ b/ng-dev/release/publish/test/configure-next-as-major.spec.ts @@ -9,8 +9,7 @@ import {getBranchPushMatcher} from '../../../utils/testing'; import {ReleaseTrain} from '../../versioning/release-trains'; import {ConfigureNextAsMajorAction} from '../actions/configure-next-as-major'; - -import {parse, setupReleaseActionForTesting} from './test-utils'; +import {parse, setupReleaseActionForTesting} from './test-utils/test-utils'; describe('configure next as major action', () => { it('should be active if the next branch is for a minor', async () => { diff --git a/ng-dev/release/publish/test/cut-lts-patch.spec.ts b/ng-dev/release/publish/test/cut-lts-patch.spec.ts index c5309fce8..010bc657d 100644 --- a/ng-dev/release/publish/test/cut-lts-patch.spec.ts +++ b/ng-dev/release/publish/test/cut-lts-patch.spec.ts @@ -10,15 +10,23 @@ import {matchesVersion} from '../../../utils/testing/semver-matchers'; import {fetchLongTermSupportBranchesFromNpm} from '../../versioning/long-term-support'; import {ReleaseTrain} from '../../versioning/release-trains'; import {CutLongTermSupportPatchAction} from '../actions/cut-lts-patch'; - import { - expectStagingAndPublishWithCherryPick, + changelogPattern, fakeNpmPackageQueryRequest, - getTestingMocksForReleaseAction, parse, setupReleaseActionForTesting, +} from './test-utils/test-utils'; +import { + expectGithubApiRequestsForStaging, + expectStagingAndPublishWithCherryPick, +} from './test-utils/staging-test'; +import { + getMockGitClient, + getTestConfigurationsForAction, testTmpDir, -} from './test-utils'; +} from './test-utils/action-mocks'; +import {SandboxGitRepo} from './test-utils/sandbox-testing'; +import {readFileSync} from 'fs'; describe('cut an LTS patch action', () => { it('should be active', async () => { @@ -67,8 +75,51 @@ describe('cut an LTS patch action', () => { await expectStagingAndPublishWithCherryPick(action, '9.2.x', '9.2.5', 'v9-lts'); }); + it('should generate release notes capturing changes to previous latest LTS version', async () => { + const action = setupReleaseActionForTesting( + CutLongTermSupportPatchAction, + { + releaseCandidate: null, + next: new ReleaseTrain('master', parse('10.1.0-next.3')), + latest: new ReleaseTrain('10.0.x', parse('10.0.2')), + }, + true, + {useSandboxGitClient: true}, + ); + + spyOn(action.instance, '_promptForTargetLtsBranch').and.resolveTo({ + name: '9.2.x', + version: parse('9.2.4'), + npmDistTag: 'v9-lts', + }); + + SandboxGitRepo.withInitialCommit(action.githubConfig) + .branchOff('9.2.x') + .commit('feat(pkg1): already released #1') + .commit('feat(pkg1): already released #2') + .createTagForHead('9.2.4') + .commit('feat(pkg1): not yet released #1') + .commit('feat(pkg1): not yet released #2'); + + await expectGithubApiRequestsForStaging(action, '9.2.x', '9.2.5', true); + await action.instance.perform(); + + const changelog = readFileSync(`${testTmpDir}/CHANGELOG.md`, 'utf8'); + + expect(changelog).toMatch(changelogPattern` + # 9.2.5 <..> + ### pkg1 + | Commit | Description | + | -- | -- | + | <..> | feat(pkg1): not yet released #2 | + | <..> | feat(pkg1): not yet released #1 | + ## Special Thanks: + `); + }); + it('should include number of active LTS branches in action description', async () => { - const {releaseConfig, gitClient} = getTestingMocksForReleaseAction(); + const {releaseConfig, githubConfig} = getTestConfigurationsForAction(); + const gitClient = getMockGitClient(githubConfig, /* useSandboxGitClient */ false); const activeReleaseTrains = { releaseCandidate: null, next: new ReleaseTrain('master', parse('10.1.0-next.3')), @@ -96,7 +147,7 @@ describe('cut an LTS patch action', () => { }); it('should properly determine active and inactive LTS branches', async () => { - const {releaseConfig} = getTestingMocksForReleaseAction(); + const {releaseConfig} = getTestConfigurationsForAction(); fakeNpmPackageQueryRequest(releaseConfig.npmPackages[0], { 'dist-tags': { 'v9-lts': '9.2.3', diff --git a/ng-dev/release/publish/test/cut-new-patch.spec.ts b/ng-dev/release/publish/test/cut-new-patch.spec.ts index 10184220e..874c722f3 100644 --- a/ng-dev/release/publish/test/cut-new-patch.spec.ts +++ b/ng-dev/release/publish/test/cut-new-patch.spec.ts @@ -8,12 +8,14 @@ import {ReleaseTrain} from '../../versioning/release-trains'; import {CutNewPatchAction} from '../actions/cut-new-patch'; - +import {changelogPattern, parse, setupReleaseActionForTesting} from './test-utils/test-utils'; import { + expectGithubApiRequestsForStaging, expectStagingAndPublishWithCherryPick, - parse, - setupReleaseActionForTesting, -} from './test-utils'; +} from './test-utils/staging-test'; +import {SandboxGitRepo} from './test-utils/sandbox-testing'; +import {readFileSync} from 'fs'; +import {testTmpDir} from './test-utils/action-mocks'; describe('cut new patch action', () => { it('should be active', async () => { @@ -55,4 +57,40 @@ describe('cut new patch action', () => { await expectStagingAndPublishWithCherryPick(action, '10.0.x', '10.0.10', 'latest'); }); + + it('should generate release notes capturing changes to the previous latest patch version', async () => { + const action = setupReleaseActionForTesting( + CutNewPatchAction, + { + releaseCandidate: null, + next: new ReleaseTrain('master', parse('10.1.0-next.3')), + latest: new ReleaseTrain('10.0.x', parse('10.0.2')), + }, + true, + {useSandboxGitClient: true}, + ); + + SandboxGitRepo.withInitialCommit(action.githubConfig) + .branchOff('10.0.x') + .commit('feat(pkg1): already released #1') + .commit('feat(pkg1): already released #2') + .createTagForHead('10.0.2') + .commit('feat(pkg1): not yet released #1') + .commit('feat(pkg1): not yet released #2'); + + await expectGithubApiRequestsForStaging(action, '10.0.x', '10.0.3', true); + await action.instance.perform(); + + const changelog = readFileSync(`${testTmpDir}/CHANGELOG.md`, 'utf8'); + + expect(changelog).toMatch(changelogPattern` + # 10.0.3 <..> + ### pkg1 + | Commit | Description | + | -- | -- | + | <..> | feat(pkg1): not yet released #2 | + | <..> | feat(pkg1): not yet released #1 | + ## Special Thanks: + `); + }); }); diff --git a/ng-dev/release/publish/test/cut-next-prerelease.spec.ts b/ng-dev/release/publish/test/cut-next-prerelease.spec.ts index df9a991de..57df0a433 100644 --- a/ng-dev/release/publish/test/cut-next-prerelease.spec.ts +++ b/ng-dev/release/publish/test/cut-next-prerelease.spec.ts @@ -12,13 +12,14 @@ import {join} from 'path'; import {ReleaseTrain} from '../../versioning/release-trains'; import {CutNextPrereleaseAction} from '../actions/cut-next-prerelease'; import {packageJsonPath} from '../constants'; - +import {changelogPattern, parse, setupReleaseActionForTesting} from './test-utils/test-utils'; import { + expectGithubApiRequestsForStaging, expectStagingAndPublishWithCherryPick, expectStagingAndPublishWithoutCherryPick, - parse, - setupReleaseActionForTesting, -} from './test-utils'; +} from './test-utils/staging-test'; +import {testTmpDir} from './test-utils/action-mocks'; +import {SandboxGitRepo} from './test-utils/sandbox-testing'; describe('cut next pre-release action', () => { it('should always be active regardless of release-trains', async () => { @@ -42,22 +43,70 @@ describe('cut next pre-release action', () => { // publish versions to the NPM dist tag. This means that the version is later published, but // still needs all the staging work (e.g. changelog). We special-case this by not incrementing // the version if the version in the next branch has not been published yet. - it('should not bump version if current next version has not been published', async () => { - const action = setupReleaseActionForTesting( - CutNextPrereleaseAction, - { - releaseCandidate: null, - next: new ReleaseTrain('master', parse('10.2.0-next.0')), - latest: new ReleaseTrain('10.1.x', parse('10.1.0')), - }, - /* isNextPublishedToNpm */ false, - ); + describe('current next version has not been published', () => { + it('should not bump the version', async () => { + const action = setupReleaseActionForTesting( + CutNextPrereleaseAction, + { + releaseCandidate: null, + next: new ReleaseTrain('master', parse('10.2.0-next.0')), + latest: new ReleaseTrain('10.1.x', parse('10.1.0')), + }, + /* isNextPublishedToNpm */ false, + ); + + await expectStagingAndPublishWithoutCherryPick(action, 'master', '10.2.0-next.0', 'next'); + + const pkgJsonContents = readFileSync(join(action.testTmpDir, packageJsonPath), 'utf8'); + const pkgJson = JSON.parse(pkgJsonContents) as {version: string; [key: string]: any}; + expect(pkgJson.version).toBe('10.2.0-next.0', 'Expected version to not have changed.'); + }); + + it( + 'should generate release notes capturing changes to the latest patch while deduping ' + + 'changes that have also landed in the current patch', + async () => { + const action = setupReleaseActionForTesting( + CutNextPrereleaseAction, + { + releaseCandidate: null, + next: new ReleaseTrain('master', parse('10.2.0-next.0')), + latest: new ReleaseTrain('10.1.x', parse('10.1.0')), + }, + /* isNextPublishedToNpm */ false, + {useSandboxGitClient: true}, + ); + + SandboxGitRepo.withInitialCommit(action.githubConfig) + .branchOff('10.1.x') + .commit('feat(pkg1): patch already released #1') + .commit('feat(pkg1): patch already released #2') + .commit('feat(pkg1): released in patch, but cherry-picked', 1) + .createTagForHead('10.1.0') + .commit('feat(pkg1): not released yet, but cherry-picked', 2) + .switchToBranch('master') + .commit('feat(pkg1): only in next, not released yet #1') + .commit('feat(pkg1): only in next, not released yet #2') + .cherryPick(1) + .cherryPick(2); - await expectStagingAndPublishWithoutCherryPick(action, 'master', '10.2.0-next.0', 'next'); + await expectGithubApiRequestsForStaging(action, 'master', '10.2.0-next.0', false); + await action.instance.perform(); - const pkgJsonContents = readFileSync(join(action.testTmpDir, packageJsonPath), 'utf8'); - const pkgJson = JSON.parse(pkgJsonContents) as {version: string; [key: string]: any}; - expect(pkgJson.version).toBe('10.2.0-next.0', 'Expected version to not have changed.'); + const changelog = readFileSync(`${testTmpDir}/CHANGELOG.md`, 'utf8'); + + expect(changelog).toMatch(changelogPattern` + # 10.2.0-next.0 <..> + ### pkg1 + | Commit | Description | + | -- | -- | + | <..> | feat(pkg1): not released yet, but cherry-picked | + | <..> | feat(pkg1): only in next, not released yet #2 | + | <..> | feat(pkg1): only in next, not released yet #1 | + ## Special Thanks: + `); + }, + ); }); describe('with active feature-freeze', () => { @@ -70,6 +119,42 @@ describe('cut next pre-release action', () => { await expectStagingAndPublishWithCherryPick(action, '10.1.x', '10.1.0-next.5', 'next'); }); + + it('should generate release notes capturing changes to the previous pre-release', async () => { + const action = setupReleaseActionForTesting( + CutNextPrereleaseAction, + { + releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-next.4')), + next: new ReleaseTrain('master', parse('10.2.0-next.0')), + latest: new ReleaseTrain('10.0.x', parse('10.0.2')), + }, + true, + {useSandboxGitClient: true}, + ); + + SandboxGitRepo.withInitialCommit(action.githubConfig) + .branchOff('10.1.x') + .commit('feat(pkg1): feature-freeze already released #1') + .commit('feat(pkg1): feature-freeze already released #2') + .createTagForHead('10.1.0-next.4') + .commit('feat(pkg1): not released yet #1') + .commit('feat(pkg1): not released yet #2'); + + await expectGithubApiRequestsForStaging(action, '10.1.x', '10.1.0-next.5', true); + await action.instance.perform(); + + const changelog = readFileSync(`${testTmpDir}/CHANGELOG.md`, 'utf8'); + + expect(changelog).toMatch(changelogPattern` + # 10.1.0-next.5 <..> + ### pkg1 + | Commit | Description | + | -- | -- | + | <..> | feat(pkg1): not released yet #2 | + | <..> | feat(pkg1): not released yet #1 | + ## Special Thanks: + `); + }); }); describe('with active release-candidate', () => { @@ -82,5 +167,41 @@ describe('cut next pre-release action', () => { await expectStagingAndPublishWithCherryPick(action, '10.1.x', '10.1.0-rc.1', 'next'); }); + + it('should generate release notes capturing changes to the previous pre-release', async () => { + const action = setupReleaseActionForTesting( + CutNextPrereleaseAction, + { + releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-rc.0')), + next: new ReleaseTrain('master', parse('10.2.0-next.0')), + latest: new ReleaseTrain('10.0.x', parse('10.0.2')), + }, + true, + {useSandboxGitClient: true}, + ); + + SandboxGitRepo.withInitialCommit(action.githubConfig) + .branchOff('10.1.x') + .commit('feat(pkg1): release-candidate already released #1') + .commit('feat(pkg1): release-candidate already released #2') + .createTagForHead('10.1.0-rc.0') + .commit('feat(pkg1): not released yet #1') + .commit('feat(pkg1): not released yet #2'); + + await expectGithubApiRequestsForStaging(action, '10.1.x', '10.1.0-rc.1', true); + await action.instance.perform(); + + const changelog = readFileSync(`${testTmpDir}/CHANGELOG.md`, 'utf8'); + + expect(changelog).toMatch(changelogPattern` + # 10.1.0-rc.1 <..> + ### pkg1 + | Commit | Description | + | -- | -- | + | <..> | feat(pkg1): not released yet #2 | + | <..> | feat(pkg1): not released yet #1 | + ## Special Thanks: + `); + }); }); }); diff --git a/ng-dev/release/publish/test/cut-release-candidate-for-feature-freeze.spec.ts b/ng-dev/release/publish/test/cut-release-candidate-for-feature-freeze.spec.ts index f077c05bc..fcc29126f 100644 --- a/ng-dev/release/publish/test/cut-release-candidate-for-feature-freeze.spec.ts +++ b/ng-dev/release/publish/test/cut-release-candidate-for-feature-freeze.spec.ts @@ -8,12 +8,14 @@ import {ReleaseTrain} from '../../versioning/release-trains'; import {CutReleaseCandidateForFeatureFreezeAction} from '../actions/cut-release-candidate-for-feature-freeze'; - +import {changelogPattern, parse, setupReleaseActionForTesting} from './test-utils/test-utils'; import { + expectGithubApiRequestsForStaging, expectStagingAndPublishWithCherryPick, - parse, - setupReleaseActionForTesting, -} from './test-utils'; +} from './test-utils/staging-test'; +import {readFileSync} from 'fs'; +import {testTmpDir} from './test-utils/action-mocks'; +import {SandboxGitRepo} from './test-utils/sandbox-testing'; describe('cut release candidate for feature-freeze action', () => { it('should activate if a feature-freeze release-train is active', async () => { @@ -56,4 +58,40 @@ describe('cut release candidate for feature-freeze action', () => { await expectStagingAndPublishWithCherryPick(action, '10.1.x', '10.1.0-rc.0', 'next'); }); + + it('should generate release notes capturing changes to the previous pre-release', async () => { + const action = setupReleaseActionForTesting( + CutReleaseCandidateForFeatureFreezeAction, + { + releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-next.1')), + next: new ReleaseTrain('master', parse('10.2.0-next.0')), + latest: new ReleaseTrain('10.0.x', parse('10.0.3')), + }, + true, + {useSandboxGitClient: true}, + ); + + SandboxGitRepo.withInitialCommit(action.githubConfig) + .branchOff('10.1.x') + .commit('feat(pkg1): feature-freeze, already released #1') + .commit('feat(pkg1): feature-freeze, already released #2') + .createTagForHead('10.1.0-next.1') + .commit('feat(pkg1): not yet released #1') + .commit('fix(pkg1): not yet released #2'); + + await expectGithubApiRequestsForStaging(action, '10.1.x', '10.1.0-rc.0', true); + await action.instance.perform(); + + const changelog = readFileSync(`${testTmpDir}/CHANGELOG.md`, 'utf8'); + + expect(changelog).toMatch(changelogPattern` + # 10.1.0-rc.0 <..> + ### pkg1 + | Commit | Description | + | -- | -- | + | <..> | fix(pkg1): not yet released #2 | + | <..> | feat(pkg1): not yet released #1 | + ## Special Thanks: + `); + }); }); diff --git a/ng-dev/release/publish/test/cut-stable.spec.ts b/ng-dev/release/publish/test/cut-stable.spec.ts index 69b607d61..5ec43880d 100644 --- a/ng-dev/release/publish/test/cut-stable.spec.ts +++ b/ng-dev/release/publish/test/cut-stable.spec.ts @@ -11,11 +11,14 @@ import {ReleaseTrain} from '../../versioning/release-trains'; import {CutStableAction} from '../actions/cut-stable'; import * as externalCommands from '../external-commands'; +import {readFileSync} from 'fs'; +import {changelogPattern, parse, setupReleaseActionForTesting} from './test-utils/test-utils'; import { + expectGithubApiRequestsForStaging, expectStagingAndPublishWithCherryPick, - parse, - setupReleaseActionForTesting, -} from './test-utils'; +} from './test-utils/staging-test'; +import {testTmpDir} from './test-utils/action-mocks'; +import {SandboxGitRepo} from './test-utils/sandbox-testing'; describe('cut stable action', () => { it('should not activate if a feature-freeze release-train is active', async () => { @@ -96,4 +99,70 @@ describe('cut stable action', () => { matchesVersion('10.0.3'), ); }); + + it( + 'should generate release notes capturing all associated RC, next releases while ' + + 'deduping commits that have been cherry-picked from the existing patch', + async () => { + const action = setupReleaseActionForTesting( + CutStableAction, + { + releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-rc.0')), + next: new ReleaseTrain('master', parse('10.2.0-next.0')), + latest: new ReleaseTrain('10.0.x', parse('10.0.3')), + }, + true, + {useSandboxGitClient: true}, + ); + + SandboxGitRepo.withInitialCommit(action.githubConfig) + .commit('fix(pkg1): landed in all release trains #1') + .branchOff('10.0.x') + .commit('fix(pkg1): released in patch, cherry-picked #1', 1) + .commit('fix(pkg1): released in patch, cherry-picked #2', 2) + .createTagForHead('10.0.3') + .commit('fix(pkg1): landed in patch, not released but cherry-picked #1', 3) + .switchToBranch('master') + .cherryPick(1) + .cherryPick(2) + // All commits below are new to this current RC release-train, and are expected + // to be captured in the release notes. The cherry-picked commits from above have + // already been released as part of `10.0.3` and should be omitted. + .cherryPick(3) + .commit('fix(pkg1): released first next pre-release #1') + .commit('fix(pkg1): released first next pre-release #2') + .createTagForHead('10.1.0-next.0') + .commit('fix(pkg1): released feature-freeze pre-release #1') + .commit('fix(pkg1): released feature-freeze pre-release #2') + .branchOff('10.1.x') + .createTagForHead('10.1.0-next.1') + .commit('fix(pkg1): released release-candidate #1') + .commit('fix(pkg1): released release-candidate #2') + .createTagForHead('10.1.0-rc.0') + .commit('fix(pkg1): not yet released #1') + .commit('fix(pkg1): not yet released #2'); + + await expectGithubApiRequestsForStaging(action, '10.1.x', '10.1.0', true); + await action.instance.perform(); + + const changelog = readFileSync(`${testTmpDir}/CHANGELOG.md`, 'utf8'); + + expect(changelog).toMatch(changelogPattern` + # 10.1.0 <..> + ### pkg1 + | Commit | Description | + | -- | -- | + | <..> | fix(pkg1): not yet released #2 | + | <..> | fix(pkg1): not yet released #1 | + | <..> | fix(pkg1): released release-candidate #2 | + | <..> | fix(pkg1): released release-candidate #1 | + | <..> | fix(pkg1): released feature-freeze pre-release #2 | + | <..> | fix(pkg1): released feature-freeze pre-release #1 | + | <..> | fix(pkg1): released first next pre-release #2 | + | <..> | fix(pkg1): released first next pre-release #1 | + | <..> | fix(pkg1): landed in patch, not released but cherry-picked #1 | + ## Special Thanks: + `); + }, + ); }); diff --git a/ng-dev/release/publish/test/move-next-into-feature-freeze.spec.ts b/ng-dev/release/publish/test/move-next-into-feature-freeze.spec.ts index 16d9b2ac5..03070b546 100644 --- a/ng-dev/release/publish/test/move-next-into-feature-freeze.spec.ts +++ b/ng-dev/release/publish/test/move-next-into-feature-freeze.spec.ts @@ -9,8 +9,12 @@ import {ReleaseTrain} from '../../versioning/release-trains'; import {MoveNextIntoFeatureFreezeAction} from '../actions/move-next-into-feature-freeze'; -import {expectBranchOffActionToRun} from './branch-off-next-branch-testing'; -import {parse} from './test-utils'; +import { + expectBranchOffActionToRun, + prepareBranchOffActionForChangelog, +} from './branch-off-next-branch-testing'; +import {changelogPattern, parse} from './test-utils/test-utils'; +import {SandboxGitRepo} from './test-utils/sandbox-testing'; describe('move next into feature-freeze action', () => { it('should not activate if a feature-freeze release-train is active', async () => { @@ -59,28 +63,108 @@ describe('move next into feature-freeze action', () => { MoveNextIntoFeatureFreezeAction, { releaseCandidate: null, - next: new ReleaseTrain('master', parse('10.2.0-next.0')), + next: new ReleaseTrain('master', parse('10.1.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), }, /* isNextPublishedToNpm */ true, - '10.3.0-next.0', - '10.2.0-next.1', - '10.2.x', + '10.2.0-next.0', + '10.1.0-next.1', + '10.1.x', ); }); - it('should not increment the version if "next" version is not yet published', async () => { - await expectBranchOffActionToRun( + // This is test for a special case in the release tooling. Whenever we branch off for + // feature-freeze, we immediately bump the version in the `next` branch but do not publish + // it. We special-case this by not incrementing the version if the version in the next + // branch has not been published yet. + describe('current next version has not been published', () => { + it('should not increment the version', async () => { + await expectBranchOffActionToRun( + MoveNextIntoFeatureFreezeAction, + { + releaseCandidate: null, + next: new ReleaseTrain('master', parse('10.1.0-next.0')), + latest: new ReleaseTrain('10.0.x', parse('10.0.3')), + }, + /* isNextPublishedToNpm */ false, + '10.2.0-next.0', + '10.1.0-next.0', + '10.1.x', + ); + }); + + it( + 'should generate release notes capturing changes to the latest patch while deduping ' + + 'changes that have also landed in the current patch', + async () => { + const {action, buildChangelog} = prepareBranchOffActionForChangelog( + MoveNextIntoFeatureFreezeAction, + { + releaseCandidate: null, + next: new ReleaseTrain('master', parse('10.1.0-next.0')), + latest: new ReleaseTrain('10.0.x', parse('10.0.3')), + }, + /* isNextPublishedToNpm */ false, + '10.2.0-next.0', + '10.1.0-next.0', + '10.1.x', + ); + + SandboxGitRepo.withInitialCommit(action.githubConfig) + .branchOff('10.0.x') + .commit('feat(pkg1): patch already released #1') + .commit('feat(pkg1): patch already released #2') + .commit('feat(pkg1): released in patch, but cherry-picked', 1) + .createTagForHead('10.0.3') + .commit('feat(pkg1): not released yet, but cherry-picked', 2) + .switchToBranch('master') + .commit('feat(pkg1): only in next, not released yet #1') + .commit('feat(pkg1): only in next, not released yet #2') + .cherryPick(1) + .cherryPick(2); + + expect(await buildChangelog()).toMatch(changelogPattern` + # 10.1.0-next.0 <..> + ### pkg1 + | Commit | Description | + | -- | -- | + | <..> | feat(pkg1): not released yet, but cherry-picked | + | <..> | feat(pkg1): only in next, not released yet #2 | + | <..> | feat(pkg1): only in next, not released yet #1 | + `); + }, + ); + }); + + it('should generate release notes capturing changes to the previous next pre-release', async () => { + const {action, buildChangelog} = prepareBranchOffActionForChangelog( MoveNextIntoFeatureFreezeAction, { releaseCandidate: null, - next: new ReleaseTrain('master', parse('10.2.0-next.0')), + next: new ReleaseTrain('master', parse('10.1.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), }, - /* isNextPublishedToNpm */ false, - '10.3.0-next.0', + /* isNextPublishedToNpm */ true, '10.2.0-next.0', - '10.2.x', + '10.1.0-next.1', + '10.1.x', ); + + SandboxGitRepo.withInitialCommit(action.githubConfig) + .commit('feat(pkg1): already released #1') + .commit('feat(pkg1): already released #2') + .createTagForHead('10.1.0-next.0') + .commit('feat(pkg1): not yet released #1') + .commit('fix(pkg1): not yet released #2'); + + expect(await buildChangelog()).toMatch(changelogPattern` + # 10.1.0-next.1 <..> + ### pkg1 + | Commit | Description | + | -- | -- | + | <..> | fix(pkg1): not yet released #2 | + | <..> | feat(pkg1): not yet released #1 | + ## Special Thanks: + `); }); }); diff --git a/ng-dev/release/publish/test/move-next-into-release-candidate.spec.ts b/ng-dev/release/publish/test/move-next-into-release-candidate.spec.ts index 01543dad5..69716c3e8 100644 --- a/ng-dev/release/publish/test/move-next-into-release-candidate.spec.ts +++ b/ng-dev/release/publish/test/move-next-into-release-candidate.spec.ts @@ -9,8 +9,12 @@ import {ReleaseTrain} from '../../versioning/release-trains'; import {MoveNextIntoReleaseCandidateAction} from '../actions/move-next-into-release-candidate'; -import {expectBranchOffActionToRun} from './branch-off-next-branch-testing'; -import {parse} from './test-utils'; +import { + expectBranchOffActionToRun, + prepareBranchOffActionForChangelog, +} from './branch-off-next-branch-testing'; +import {changelogPattern, parse} from './test-utils/test-utils'; +import {SandboxGitRepo} from './test-utils/sandbox-testing'; describe('move next into release-candidate action', () => { it('should not activate if a feature-freeze release-train is active', async () => { @@ -54,18 +58,111 @@ describe('move next into release-candidate action', () => { ).toBe(true); }); + // This is test for a special case in the release tooling. Whenever we branch off for + // feature-freeze, we immediately bump the version in `next` but do not publish it. + describe('current next version has not been published', () => { + it('should update the version regardless', async () => { + await expectBranchOffActionToRun( + MoveNextIntoReleaseCandidateAction, + { + releaseCandidate: null, + next: new ReleaseTrain('master', parse('10.1.0-next.0')), + latest: new ReleaseTrain('10.0.x', parse('10.0.3')), + }, + /* isNextPublishedToNpm */ false, + '10.2.0-next.0', + '10.1.0-rc.0', + '10.1.x', + ); + }); + + it( + 'should generate release notes capturing changes to the latest patch while deduping ' + + 'changes that have also landed in the current patch', + async () => { + const {action, buildChangelog} = prepareBranchOffActionForChangelog( + MoveNextIntoReleaseCandidateAction, + { + releaseCandidate: null, + next: new ReleaseTrain('master', parse('10.1.0-next.0')), + latest: new ReleaseTrain('10.0.x', parse('10.0.3')), + }, + /* isNextPublishedToNpm */ false, + '10.2.0-next.0', + '10.1.0-rc.0', + '10.1.x', + ); + + SandboxGitRepo.withInitialCommit(action.githubConfig) + .branchOff('10.0.x') + .commit('feat(pkg1): patch already released #1') + .commit('feat(pkg1): patch already released #2') + .commit('feat(pkg1): released in patch, but cherry-picked', 1) + .createTagForHead('10.0.3') + .commit('feat(pkg1): not released yet, but cherry-picked', 2) + .switchToBranch('master') + .commit('feat(pkg1): only in next, not released yet #1') + .commit('feat(pkg1): only in next, not released yet #2') + .cherryPick(1) + .cherryPick(2); + + expect(await buildChangelog()).toMatch(changelogPattern` + # 10.1.0-rc.0 <..> + ### pkg1 + | Commit | Description | + | -- | -- | + | <..> | feat(pkg1): not released yet, but cherry-picked | + | <..> | feat(pkg1): only in next, not released yet #2 | + | <..> | feat(pkg1): only in next, not released yet #1 | + `); + }, + ); + }); + + it('should generate release notes capturing changes to the previous next pre-release', async () => { + const {action, buildChangelog} = prepareBranchOffActionForChangelog( + MoveNextIntoReleaseCandidateAction, + { + releaseCandidate: null, + next: new ReleaseTrain('master', parse('10.1.0-next.0')), + latest: new ReleaseTrain('10.0.x', parse('10.0.3')), + }, + /* isNextPublishedToNpm */ true, + '10.2.0-next.0', + '10.1.0-rc.0', + '10.1.x', + ); + + SandboxGitRepo.withInitialCommit(action.githubConfig) + .commit('feat(pkg1): already released #1') + .commit('feat(pkg1): already released #2') + .createTagForHead('10.1.0-next.0') + .commit('feat(pkg1): not yet released #1') + .commit('fix(pkg1): not yet released #2'); + + expect(await buildChangelog()).toMatch(changelogPattern` + # 10.1.0-rc.0 <..> + ### pkg1 + | Commit | Description | + | -- | -- | + | <..> | fix(pkg1): not yet released #2 | + | <..> | feat(pkg1): not yet released #1 | + ## Special Thanks: + `); + }); + it('should create pull requests and new version-branch', async () => { await expectBranchOffActionToRun( MoveNextIntoReleaseCandidateAction, { releaseCandidate: null, - next: new ReleaseTrain('master', parse('10.2.0-next.0')), + next: new ReleaseTrain('master', parse('10.1.0-next.0')), latest: new ReleaseTrain('10.0.x', parse('10.0.3')), }, /* isNextPublishedToNpm */ true, - '10.3.0-next.0', - '10.2.0-rc.0', - '10.2.x', + '10.2.0-next.0', + '10.1.0-rc.0', + '10.1.x', ); }); }); diff --git a/ng-dev/release/publish/test/release-notes/release-notes-utils.ts b/ng-dev/release/publish/test/release-notes/release-notes-utils.ts deleted file mode 100644 index 1f7a4ad32..000000000 --- a/ng-dev/release/publish/test/release-notes/release-notes-utils.ts +++ /dev/null @@ -1,43 +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 {DevInfraReleaseConfig, ReleaseConfig} from '../../../config'; -import {ReleaseNotes} from '../../../notes/release-notes'; - -/** - * Mock version of the ReleaseNotes for testing, preventing actual calls to git for commits and - * returning versioned entry strings. - */ -class MockReleaseNotes extends ReleaseNotes { - static override async fromRange(version: semver.SemVer, startingRef: string, endingRef: string) { - return new MockReleaseNotes(version, startingRef, endingRef); - } - - override async getChangelogEntry() { - return `Changelog Entry for ${this.version}`; - } - - override async getGithubReleaseEntry() { - return `Github Release Entry for ${this.version}`; - } - - // Overrides of utility functions which call out to other tools and are unused in this mock. - protected override async getCommitsInRange(from: string, to?: string) { - return []; - } - protected override getReleaseConfig(config?: Partial) { - return {} as ReleaseConfig; - } -} - -/** Replace the ReleaseNotes static builder function with the MockReleaseNotes builder function. */ -export function installMockReleaseNotes() { - spyOn(ReleaseNotes, 'fromRange').and.callFake(MockReleaseNotes.fromRange); -} diff --git a/ng-dev/release/publish/test/tag-recent-major-as-latest.spec.ts b/ng-dev/release/publish/test/tag-recent-major-as-latest.spec.ts index 842918c7e..0e2b879f8 100644 --- a/ng-dev/release/publish/test/tag-recent-major-as-latest.spec.ts +++ b/ng-dev/release/publish/test/tag-recent-major-as-latest.spec.ts @@ -10,17 +10,16 @@ import {matchesVersion} from '../../../utils/testing'; import {ReleaseTrain} from '../../versioning/release-trains'; import {TagRecentMajorAsLatest} from '../actions/tag-recent-major-as-latest'; import * as externalCommands from '../external-commands'; - +import {getTestConfigurationsForAction} from './test-utils/action-mocks'; import { fakeNpmPackageQueryRequest, - getTestingMocksForReleaseAction, parse, setupReleaseActionForTesting, -} from './test-utils'; +} from './test-utils/test-utils'; describe('tag recent major as latest action', () => { it('should not be active if a patch has been published after major release', async () => { - const {releaseConfig} = getTestingMocksForReleaseAction(); + const {releaseConfig} = getTestConfigurationsForAction(); expect( await TagRecentMajorAsLatest.isActive( { @@ -37,7 +36,7 @@ describe('tag recent major as latest action', () => { 'should not be active if a major has been released recently but "@latest" on NPM points to ' + 'a more recent major', async () => { - const {releaseConfig} = getTestingMocksForReleaseAction(); + const {releaseConfig} = getTestConfigurationsForAction(); // NPM `@latest` will point to a patch release of a more recent major. This is unlikely // to happen (only with manual changes outside of the release tool), but should @@ -63,7 +62,7 @@ describe('tag recent major as latest action', () => { 'should not be active if a major has been released recently but "@latest" on NPM points to ' + 'an older major', async () => { - const {releaseConfig} = getTestingMocksForReleaseAction(); + const {releaseConfig} = getTestConfigurationsForAction(); // NPM `@latest` will point to a patch release of an older major. This is unlikely to happen // (only with manual changes outside of the release tool), but should prevent accidental @@ -90,7 +89,7 @@ describe('tag recent major as latest action', () => { 'should be active if a major has been released recently but is not published as ' + '"@latest" to NPM', async () => { - const {releaseConfig} = getTestingMocksForReleaseAction(); + const {releaseConfig} = getTestConfigurationsForAction(); // NPM `@latest` will point to a patch release of the previous major. fakeNpmPackageQueryRequest(releaseConfig.npmPackages[0], { diff --git a/ng-dev/release/publish/test/test-utils.ts b/ng-dev/release/publish/test/test-utils.ts deleted file mode 100644 index 81a81371c..000000000 --- a/ng-dev/release/publish/test/test-utils.ts +++ /dev/null @@ -1,287 +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 {writeFileSync} from 'fs'; -import * as nock from 'nock'; -import {join} from 'path'; -import * as semver from 'semver'; - -import {GithubConfig} from '../../../utils/config'; -import * as console from '../../../utils/console'; -import { - getBranchPushMatcher, - installVirtualGitClientSpies, - VirtualGitClient, -} from '../../../utils/testing'; -import {ReleaseConfig} from '../../config/index'; -import {ActiveReleaseTrains} from '../../versioning/active-release-trains'; -import * as npm from '../../versioning/npm-publish'; -import {_npmPackageInfoCache, NpmDistTag, NpmPackageInfo} from '../../versioning/npm-registry'; -import {ReleaseAction, ReleaseActionConstructor} from '../actions'; -import * as constants from '../constants'; -import * as externalCommands from '../external-commands'; - -import {GithubTestingRepo} from './github-api-testing'; -import {installMockReleaseNotes} from './release-notes/release-notes-utils'; - -/** - * Temporary directory which will be used as project directory in tests. Note that - * this environment variable is automatically set by Bazel for tests. - */ -export const testTmpDir: string = process.env['TEST_TMPDIR']!; - -/** Interface describing a test release action. */ -export interface TestReleaseAction { - instance: T; - gitClient: VirtualGitClient; - repo: GithubTestingRepo; - fork: GithubTestingRepo; - testTmpDir: string; - githubConfig: GithubConfig; - releaseConfig: ReleaseConfig; -} - -/** Gets necessary test mocks for running a release action. */ -export function getTestingMocksForReleaseAction() { - const githubConfig: GithubConfig = { - owner: 'angular', - name: 'dev-infra-test', - mainBranchName: 'master', - }; - const gitClient = VirtualGitClient.createInstance({github: githubConfig}); - const releaseConfig: ReleaseConfig = { - npmPackages: ['@angular/pkg1', '@angular/pkg2'], - releaseNotes: {}, - buildPackages: () => { - throw Error('Not implemented'); - }, - }; - return {githubConfig, gitClient, releaseConfig}; -} - -/** - * Sets up the given release action for testing. - * @param actionCtor Type of release action to be tested. - * @param active Fake active release trains for the action, - * @param isNextPublishedToNpm Whether the next version is published to NPM. True by default. - */ -export function setupReleaseActionForTesting( - actionCtor: ReleaseActionConstructor, - active: ActiveReleaseTrains, - isNextPublishedToNpm = true, -): TestReleaseAction { - // Reset existing HTTP interceptors. - nock.cleanAll(); - - const {gitClient, githubConfig, releaseConfig} = getTestingMocksForReleaseAction(); - const repo = new GithubTestingRepo(githubConfig.owner, githubConfig.name); - const fork = new GithubTestingRepo('some-user', 'fork'); - - installVirtualGitClientSpies(gitClient); - installMockReleaseNotes(); - - // The version for the release-train in the next phase does not necessarily need to be - // published to NPM. We mock the NPM package request and fake the state of the next - // version based on the `isNextPublishedToNpm` testing parameter. More details on the - // special case for the next release train can be found in the next pre-release action. - fakeNpmPackageQueryRequest(releaseConfig.npmPackages[0], { - versions: {[active.next.version.format()]: isNextPublishedToNpm ? {} : undefined}, - }); - - const action = new actionCtor(active, gitClient, releaseConfig, testTmpDir); - - // Fake confirm any prompts. We do not want to make any changelog edits and - // just proceed with the release action. - spyOn(console, 'promptConfirm').and.resolveTo(true); - - // Fake all external commands for the release tool. - spyOn(npm, 'runNpmPublish').and.resolveTo(); - spyOn(externalCommands, 'invokeSetNpmDistCommand').and.resolveTo(); - spyOn(externalCommands, 'invokeYarnInstallCommand').and.resolveTo(); - spyOn(externalCommands, 'invokeReleaseBuildCommand').and.resolveTo([ - {name: '@angular/pkg1', outputPath: `${testTmpDir}/dist/pkg1`}, - {name: '@angular/pkg2', outputPath: `${testTmpDir}/dist/pkg2`}, - ]); - - // Fake checking the package versions since we don't actually create packages to check against in - // the publish tests. - spyOn(ReleaseAction.prototype, '_verifyPackageVersions' as any).and.resolveTo(); - - // Create an empty changelog and a `package.json` file so that file system - // interactions with the project directory do not cause exceptions. - writeFileSync(join(testTmpDir, 'CHANGELOG.md'), 'Existing changelog'); - writeFileSync(join(testTmpDir, 'package.json'), JSON.stringify({version: '0.0.0'})); - - // Override the default pull request wait interval to a number of milliseconds that can be - // awaited in Jasmine tests. The default interval of 10sec is too large and causes a timeout. - Object.defineProperty(constants, 'waitForPullRequestInterval', {value: 50}); - - return {instance: action, repo, fork, testTmpDir, githubConfig, releaseConfig, gitClient}; -} - -/** Parses the specified version into Semver. */ -export function parse(version: string): semver.SemVer { - return semver.parse(version)!; -} - -export async function expectStagingAndPublishWithoutCherryPick( - action: TestReleaseAction, - expectedBranch: string, - expectedVersion: string, - expectedNpmDistTag: NpmDistTag, -) { - const {repo, fork, gitClient} = action; - const expectedStagingForkBranch = `release-stage-${expectedVersion}`; - const expectedTagName = expectedVersion; - - // We first mock the commit status check for the next branch, then expect two pull - // requests from a fork that are targeting next and the new feature-freeze branch. - repo - .expectBranchRequest(expectedBranch, 'MASTER_COMMIT_SHA') - .expectCommitStatusCheck('MASTER_COMMIT_SHA', 'success') - .expectFindForkRequest(fork) - .expectPullRequestToBeCreated(expectedBranch, fork, expectedStagingForkBranch, 200) - .expectPullRequestWait(200) - .expectBranchRequest(expectedBranch, 'STAGING_COMMIT_SHA') - .expectCommitRequest( - 'STAGING_COMMIT_SHA', - `release: cut the v${expectedVersion} release\n\nPR Close #200.`, - ) - .expectTagToBeCreated(expectedTagName, 'STAGING_COMMIT_SHA') - .expectReleaseToBeCreated(`v${expectedVersion}`, expectedTagName); - - // In the fork, we make the staging branch appear as non-existent, - // so that the PR can be created properly without collisions. - fork.expectBranchRequest(expectedStagingForkBranch, null); - - await action.instance.perform(); - - expect(gitClient.pushed.length).toBe(1); - expect(gitClient.pushed[0]).toEqual( - getBranchPushMatcher({ - baseBranch: expectedBranch, - baseRepo: repo, - targetBranch: expectedStagingForkBranch, - targetRepo: fork, - expectedCommits: [ - { - message: `release: cut the v${expectedVersion} release`, - files: ['package.json', 'CHANGELOG.md'], - }, - ], - }), - 'Expected release staging branch to be created in fork.', - ); - - expect(externalCommands.invokeReleaseBuildCommand).toHaveBeenCalledTimes(1); - expect(npm.runNpmPublish).toHaveBeenCalledTimes(2); - expect(npm.runNpmPublish).toHaveBeenCalledWith( - `${testTmpDir}/dist/pkg1`, - expectedNpmDistTag, - undefined, - ); - expect(npm.runNpmPublish).toHaveBeenCalledWith( - `${testTmpDir}/dist/pkg2`, - expectedNpmDistTag, - undefined, - ); -} - -export async function expectStagingAndPublishWithCherryPick( - action: TestReleaseAction, - expectedBranch: string, - expectedVersion: string, - expectedNpmDistTag: NpmDistTag, -) { - const {repo, fork, gitClient, releaseConfig} = action; - const expectedStagingForkBranch = `release-stage-${expectedVersion}`; - const expectedCherryPickForkBranch = `changelog-cherry-pick-${expectedVersion}`; - const expectedTagName = expectedVersion; - - // We first mock the commit status check for the next branch, then expect two pull - // requests from a fork that are targeting next and the new feature-freeze branch. - repo - .expectBranchRequest(expectedBranch, 'MASTER_COMMIT_SHA') - .expectCommitStatusCheck('MASTER_COMMIT_SHA', 'success') - .expectFindForkRequest(fork) - .expectPullRequestToBeCreated(expectedBranch, fork, expectedStagingForkBranch, 200) - .expectPullRequestWait(200) - .expectBranchRequest(expectedBranch, 'STAGING_COMMIT_SHA') - .expectCommitRequest( - 'STAGING_COMMIT_SHA', - `release: cut the v${expectedVersion} release\n\nPR Close #200.`, - ) - .expectTagToBeCreated(expectedTagName, 'STAGING_COMMIT_SHA') - .expectReleaseToBeCreated(`v${expectedVersion}`, expectedTagName) - .expectPullRequestToBeCreated('master', fork, expectedCherryPickForkBranch, 300) - .expectPullRequestWait(300); - - // In the fork, we make the staging and cherry-pick branches appear as - // non-existent, so that the PRs can be created properly without collisions. - fork - .expectBranchRequest(expectedStagingForkBranch, null) - .expectBranchRequest(expectedCherryPickForkBranch, null); - - await action.instance.perform(); - - expect(gitClient.pushed.length).toBe(2); - expect(gitClient.pushed[0]).toEqual( - getBranchPushMatcher({ - baseBranch: expectedBranch, - baseRepo: repo, - targetBranch: expectedStagingForkBranch, - targetRepo: fork, - expectedCommits: [ - { - message: `release: cut the v${expectedVersion} release`, - files: ['package.json', 'CHANGELOG.md'], - }, - ], - }), - 'Expected release staging branch to be created in fork.', - ); - - expect(gitClient.pushed[1]).toEqual( - getBranchPushMatcher({ - baseBranch: 'master', - baseRepo: repo, - targetBranch: expectedCherryPickForkBranch, - targetRepo: fork, - expectedCommits: [ - { - message: `docs: release notes for the v${expectedVersion} release`, - files: ['CHANGELOG.md'], - }, - ], - }), - 'Expected cherry-pick branch to be created in fork.', - ); - - expect(externalCommands.invokeReleaseBuildCommand).toHaveBeenCalledTimes(1); - expect(npm.runNpmPublish).toHaveBeenCalledTimes(2); - expect(npm.runNpmPublish).toHaveBeenCalledWith( - `${testTmpDir}/dist/pkg1`, - expectedNpmDistTag, - undefined, - ); - expect(npm.runNpmPublish).toHaveBeenCalledWith( - `${testTmpDir}/dist/pkg2`, - expectedNpmDistTag, - undefined, - ); -} - -/** Fakes a NPM package query API request for the given package. */ -export function fakeNpmPackageQueryRequest(pkgName: string, data: Partial) { - _npmPackageInfoCache[pkgName] = Promise.resolve({ - 'dist-tags': {}, - versions: {}, - time: {}, - ...data, - }); -} diff --git a/ng-dev/release/publish/test/test-utils/action-mocks.ts b/ng-dev/release/publish/test/test-utils/action-mocks.ts new file mode 100644 index 000000000..6dda52cfd --- /dev/null +++ b/ng-dev/release/publish/test/test-utils/action-mocks.ts @@ -0,0 +1,121 @@ +import {mkdirSync, rmdirSync, writeFileSync} from 'fs'; +import {join} from 'path'; + +/** + * @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 npm from '../../../versioning/npm-publish'; +import * as constants from '../../constants'; +import * as externalCommands from '../../external-commands'; +import * as console from '../../../../utils/console'; + +import {ReleaseAction} from '../../actions'; +import {GithubConfig} from '../../../../utils/config'; +import {ReleaseConfig} from '../../../config'; +import {installVirtualGitClientSpies, VirtualGitClient} from '../../../../utils/testing'; +import {installSandboxGitClient, SandboxGitClient} from './sandbox-git-client'; +import {ReleaseNotes} from '../../../notes/release-notes'; + +/** + * Temporary directory which will be used as project directory in tests. Note that + * this environment variable is automatically set by Bazel for tests. + */ +export const testTmpDir: string = process.env['TEST_TMPDIR']!; + +/** List of NPM packages which are configured for release action tests. */ +export const testReleasePackages = ['@angular/pkg1', '@angular/pkg2']; + +/** Gets test configurations for running testing a publish action. */ +export function getTestConfigurationsForAction() { + const githubConfig: GithubConfig = { + owner: 'angular', + name: 'dev-infra-test', + mainBranchName: 'master', + }; + const releaseConfig: ReleaseConfig = { + npmPackages: testReleasePackages, + releaseNotes: {}, + buildPackages: () => { + throw Error('Not implemented'); + }, + }; + return {githubConfig, releaseConfig}; +} + +/** Sets up all test mocks needed to run a release action. */ +export function setupMocksForReleaseAction( + githubConfig: GithubConfig, + releaseConfig: ReleaseConfig, + useSandboxGitClient: T, +) { + // Clear the temporary directory. We do not want the repo state + // to persist between tests if the sandbox git client is used. + rmdirSync(testTmpDir, {recursive: true}); + mkdirSync(testTmpDir); + + // Fake confirm any prompts. We do not want to make any changelog edits and + // just proceed with the release action. + spyOn(console, 'promptConfirm').and.resolveTo(true); + + // Fake all external commands for the release tool. + spyOn(npm, 'runNpmPublish').and.resolveTo(); + spyOn(externalCommands, 'invokeSetNpmDistCommand').and.resolveTo(); + spyOn(externalCommands, 'invokeYarnInstallCommand').and.resolveTo(); + spyOn(externalCommands, 'invokeReleaseBuildCommand').and.resolveTo( + testReleasePackages.map((name) => ({name, outputPath: `${testTmpDir}/dist/${name}`})), + ); + + spyOn(ReleaseNotes.prototype as any, 'getReleaseConfig').and.returnValue(releaseConfig); + + // Fake checking the package versions since we don't actually create NPM + // package output that can be tested. + spyOn(ReleaseAction.prototype, '_verifyPackageVersions' as any).and.resolveTo(); + + // Create an empty changelog and a `package.json` file so that file system + // interactions with the project directory do not cause exceptions. + writeFileSync(join(testTmpDir, 'CHANGELOG.md'), 'Existing changelog'); + writeFileSync(join(testTmpDir, 'package.json'), JSON.stringify({version: '0.0.0'})); + + // Override the default pull request wait interval to a number of milliseconds that can be + // awaited in Jasmine tests. The default interval of 10sec is too large and causes a timeout. + Object.defineProperty(constants, 'waitForPullRequestInterval', {value: 50}); + + // Get a mocked `GitClient` for testing release actions. + const gitClient = getMockGitClient(githubConfig, useSandboxGitClient); + + if (gitClient instanceof VirtualGitClient) { + installVirtualGitClientSpies(gitClient); + } else { + installSandboxGitClient(gitClient); + + // If we run with a sandbox git client, we assume the upstream branches exist locally. + // This is necessary for testing as we cannot fake an upstream remote. + spyOn(ReleaseAction.prototype as any, 'checkoutUpstreamBranch').and.callFake((n: string) => + gitClient.run(['checkout', n]), + ); + } + + return {gitClient}; +} + +/** Gets a mock instance for the `GitClient` instance. */ +export function getMockGitClient( + github: GithubConfig, + useSandboxGitClient: T, +): T extends true ? SandboxGitClient : VirtualGitClient { + if (useSandboxGitClient) { + // TypeScript does not infer the return type for the implementation, so we cast + // to any. The function signature will have the proper conditional return type. + // The Git binary path will be passed to this test process as command line argument. + // See `ng-dev/release/publish/test/BUILD.bazel` and the `GIT_BIN_PATH` variable + // that is exposed from the Git bazel toolchain. + return SandboxGitClient.createInstance(process.argv[2], {github}, testTmpDir) as any; + } else { + return VirtualGitClient.createInstance({github}); + } +} diff --git a/ng-dev/release/publish/test/github-api-testing.ts b/ng-dev/release/publish/test/test-utils/github-api-testing.ts similarity index 100% rename from ng-dev/release/publish/test/github-api-testing.ts rename to ng-dev/release/publish/test/test-utils/github-api-testing.ts diff --git a/ng-dev/release/publish/test/test-utils/sandbox-git-client.ts b/ng-dev/release/publish/test/test-utils/sandbox-git-client.ts new file mode 100644 index 000000000..d534182d4 --- /dev/null +++ b/ng-dev/release/publish/test/test-utils/sandbox-git-client.ts @@ -0,0 +1,65 @@ +/** + * @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 {SpawnSyncOptions, SpawnSyncReturns} from 'child_process'; +import {AuthenticatedGitClient} from '../../../../utils/git/authenticated-git-client'; +import {NgDevConfig} from '../../../../utils/config'; +import {GitClient} from '../../../../utils/git/git-client'; + +/** Fake spawn sync returns value that is successful without any process output. */ +const noopSpawnSyncReturns = {status: 0, stderr: '', output: [], pid: -1, signal: null, stdout: ''}; + +/** + * Client that relies on the real Git binaries but operates in a sandbox-manner + * where no network access is granted and commands are only executed in a + * specified directory. + */ +export class SandboxGitClient extends AuthenticatedGitClient { + static createInstance( + gitBinPath: string, + config: NgDevConfig, + baseDir: string, + ): SandboxGitClient { + return new SandboxGitClient(gitBinPath, 'abc123', baseDir, config); + } + + protected constructor( + // Overrides the path to the Git binary. + override gitBinPath: string, + githubToken: string, + baseDir?: string, + config?: NgDevConfig, + ) { + super(githubToken, baseDir, config); + } + + /** Override for the actual Git client command execution. */ + override runGraceful(args: string[], options: SpawnSyncOptions = {}): SpawnSyncReturns { + const command = args[0]; + + // If any command refers to `FETCH_HEAD` in some way, we always + // return the noop spawn sync value. We do not deal with remotes + // in the sandbox client so this would always fail. + if (args.some((v) => v.includes('FETCH_HEAD'))) { + return noopSpawnSyncReturns; + } + + // For the following commands, we do not run Git as those deal with + // remotes and we do not allow this for the sandboxed environment. + if (command === 'push' || command === 'fetch') { + return noopSpawnSyncReturns; + } + + return super.runGraceful(args, options); + } +} + +export function installSandboxGitClient(mockInstance: SandboxGitClient) { + spyOn(GitClient, 'get').and.returnValue(mockInstance); + spyOn(AuthenticatedGitClient, 'get').and.returnValue(mockInstance); +} diff --git a/ng-dev/release/publish/test/test-utils/sandbox-testing.ts b/ng-dev/release/publish/test/test-utils/sandbox-testing.ts new file mode 100644 index 000000000..b3b8896d3 --- /dev/null +++ b/ng-dev/release/publish/test/test-utils/sandbox-testing.ts @@ -0,0 +1,91 @@ +/** + * @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 {getNextBranchName} from '../../../versioning'; +import {GithubConfig} from '../../../../utils/config'; +import {spawnSync} from 'child_process'; +import {testTmpDir} from './action-mocks'; + +/** Runs a Git command in the temporary repo directory. */ +export function runGitInTmpDir(args: string[]): string { + const result = spawnSync(process.argv[2], args, {cwd: testTmpDir, encoding: 'utf8'}); + if (result.status !== 0) { + throw Error(`Error for Git command: ${result.stdout} ${result.stderr}`); + } + return result.stdout.trim(); +} + +/** Helper class that can be used to initialize and control the sandbox test repo. */ +export class SandboxGitRepo { + private _nextBranchName = getNextBranchName(this._github); + private _commitShaById = new Map(); + + static withInitialCommit(github: GithubConfig) { + return new SandboxGitRepo(github).commit('feat(pkg1): initial commit'); + } + + protected constructor(private _github: GithubConfig) { + runGitInTmpDir(['init']); + runGitInTmpDir(['config', 'user.email', 'dev-infra-test@angular.io']); + runGitInTmpDir(['config', 'user.name', 'DevInfraTestActor']); + + // Note: We cannot use `--initial-branch=` as this Git option is rather + // new and we do not have a strict requirement on a specific Git version. + this.branchOff(this._nextBranchName); + this.commit('feat(pkg1): initial commit'); + } + + /** + * Creates a commit with the given message. Optionally, an id can be specified to + * associate the created commit with a shortcut in order to reference it conveniently + * when writing tests (e.g. when cherry-picking later). + */ + commit(message: string, id?: number): this { + // Capture existing files in the temporary directory. e.g. if a changelog + // file has been written before we want to preserve that in the fake repo. + runGitInTmpDir(['add', '-A']); + runGitInTmpDir(['commit', '--allow-empty', '-m', message]); + + if (id !== undefined) { + const commitSha = runGitInTmpDir(['rev-parse', 'HEAD']); + this._commitShaById.set(id, commitSha); + } + + return this; + } + + /** Branches off the current repository `HEAD`. */ + branchOff(newBranchName: string): this { + runGitInTmpDir(['checkout', '-B', newBranchName]); + return this; + } + + /** Switches to an existing branch. */ + switchToBranch(branchName: string): this { + runGitInTmpDir(['checkout', branchName]); + return this; + } + + /** Creates a new tag for the current repo `HEAD`. */ + createTagForHead(tagName: string): this { + runGitInTmpDir(['tag', tagName, 'HEAD']); + return this; + } + + /** Cherry-picks a commit into the current branch. */ + cherryPick(commitId: number) { + const commitSha = this._commitShaById.get(commitId); + + if (commitSha === undefined) { + throw Error('Unable to cherry-pick. Unknown commit id.'); + } + + runGitInTmpDir(['cherry-pick', '--allow-empty', commitSha]); + return this; + } +} diff --git a/ng-dev/release/publish/test/test-utils/staging-test.ts b/ng-dev/release/publish/test/test-utils/staging-test.ts new file mode 100644 index 000000000..0d4c9cc08 --- /dev/null +++ b/ng-dev/release/publish/test/test-utils/staging-test.ts @@ -0,0 +1,165 @@ +/** + * @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 {NpmDistTag} from '../../../versioning'; +import {getBranchPushMatcher} from '../../../../utils/testing'; + +import * as npm from '../../../versioning/npm-publish'; +import * as externalCommands from '../../external-commands'; +import {testReleasePackages, testTmpDir} from './action-mocks'; +import {TestReleaseAction} from './test-action'; + +/** + * Expects and fakes the necessary Github API requests for staging + * a given version. + */ +export async function expectGithubApiRequestsForStaging( + action: Omit, + expectedBranch: string, + expectedVersion: string, + withCherryPicking: boolean, + cherryPickBranchName: string | null = null, +) { + const {repo, fork} = action; + const expectedStagingForkBranch = `release-stage-${expectedVersion}`; + const expectedTagName = expectedVersion; + + // We first mock the commit status check for the next branch, then expect two pull + // requests from a fork that are targeting next and the new feature-freeze branch. + repo + .expectBranchRequest(expectedBranch, 'MASTER_COMMIT_SHA') + .expectCommitStatusCheck('MASTER_COMMIT_SHA', 'success') + .expectFindForkRequest(fork) + .expectPullRequestToBeCreated(expectedBranch, fork, expectedStagingForkBranch, 200) + .expectPullRequestWait(200) + .expectBranchRequest(expectedBranch, 'STAGING_COMMIT_SHA') + .expectCommitRequest( + 'STAGING_COMMIT_SHA', + `release: cut the v${expectedVersion} release\n\nPR Close #200.`, + ) + .expectTagToBeCreated(expectedTagName, 'STAGING_COMMIT_SHA') + .expectReleaseToBeCreated(`v${expectedVersion}`, expectedTagName); + + // In the fork, we make the staging branch appear as non-existent, + // so that the PR can be created properly without collisions. + fork.expectBranchRequest(expectedStagingForkBranch, null); + + if (withCherryPicking) { + const expectedCherryPickForkBranch = + cherryPickBranchName ?? `changelog-cherry-pick-${expectedVersion}`; + + repo + .expectPullRequestToBeCreated('master', fork, expectedCherryPickForkBranch, 300) + .expectPullRequestWait(300); + + // In the fork, make the cherry-pick branch appear as non-existent, so that the + // cherry-pick PR can be created properly without collisions. + fork + .expectBranchRequest(expectedStagingForkBranch, null) + .expectBranchRequest(expectedCherryPickForkBranch, null); + } +} + +export async function expectStagingAndPublishWithoutCherryPick( + action: TestReleaseAction, + expectedBranch: string, + expectedVersion: string, + expectedNpmDistTag: NpmDistTag, +) { + const {repo, fork, gitClient} = action; + const expectedStagingForkBranch = `release-stage-${expectedVersion}`; + + await expectGithubApiRequestsForStaging(action, expectedBranch, expectedVersion, false); + await action.instance.perform(); + + expect(gitClient.pushed.length).toBe(1); + expect(gitClient.pushed[0]).toEqual( + getBranchPushMatcher({ + baseBranch: expectedBranch, + baseRepo: repo, + targetBranch: expectedStagingForkBranch, + targetRepo: fork, + expectedCommits: [ + { + message: `release: cut the v${expectedVersion} release`, + files: ['package.json', 'CHANGELOG.md'], + }, + ], + }), + 'Expected release staging branch to be created in fork.', + ); + + expect(externalCommands.invokeReleaseBuildCommand).toHaveBeenCalledTimes(1); + expect(npm.runNpmPublish).toHaveBeenCalledTimes(testReleasePackages.length); + + for (const pkgName of testReleasePackages) { + expect(npm.runNpmPublish).toHaveBeenCalledWith( + `${testTmpDir}/dist/${pkgName}`, + expectedNpmDistTag, + undefined, + ); + } +} + +export async function expectStagingAndPublishWithCherryPick( + action: TestReleaseAction, + expectedBranch: string, + expectedVersion: string, + expectedNpmDistTag: NpmDistTag, +) { + const {repo, fork, gitClient} = action; + const expectedStagingForkBranch = `release-stage-${expectedVersion}`; + const expectedCherryPickForkBranch = `changelog-cherry-pick-${expectedVersion}`; + + await expectGithubApiRequestsForStaging(action, expectedBranch, expectedVersion, true); + await action.instance.perform(); + + expect(gitClient.pushed.length).toBe(2); + expect(gitClient.pushed[0]).toEqual( + getBranchPushMatcher({ + baseBranch: expectedBranch, + baseRepo: repo, + targetBranch: expectedStagingForkBranch, + targetRepo: fork, + expectedCommits: [ + { + message: `release: cut the v${expectedVersion} release`, + files: ['package.json', 'CHANGELOG.md'], + }, + ], + }), + 'Expected release staging branch to be created in fork.', + ); + + expect(gitClient.pushed[1]).toEqual( + getBranchPushMatcher({ + baseBranch: 'master', + baseRepo: repo, + targetBranch: expectedCherryPickForkBranch, + targetRepo: fork, + expectedCommits: [ + { + message: `docs: release notes for the v${expectedVersion} release`, + files: ['CHANGELOG.md'], + }, + ], + }), + 'Expected cherry-pick branch to be created in fork.', + ); + + expect(externalCommands.invokeReleaseBuildCommand).toHaveBeenCalledTimes(1); + expect(npm.runNpmPublish).toHaveBeenCalledTimes(testReleasePackages.length); + + for (const pkgName of testReleasePackages) { + expect(npm.runNpmPublish).toHaveBeenCalledWith( + `${testTmpDir}/dist/${pkgName}`, + expectedNpmDistTag, + undefined, + ); + } +} diff --git a/ng-dev/release/publish/test/test-utils/test-action.ts b/ng-dev/release/publish/test/test-utils/test-action.ts new file mode 100644 index 000000000..ca56fd716 --- /dev/null +++ b/ng-dev/release/publish/test/test-utils/test-action.ts @@ -0,0 +1,47 @@ +/** + * @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 {ReleaseAction} from '../../actions'; +import {GithubTestingRepo} from './github-api-testing'; +import {GithubConfig} from '../../../../utils/config'; +import {ReleaseConfig} from '../../../config'; +import {SandboxGitClient} from './sandbox-git-client'; +import {VirtualGitClient} from '../../../../utils/testing'; +import {ActiveReleaseTrains} from '../../../versioning'; + +export interface TestOptions { + /** + * Whether the test should operate using a sandbox Git client. + */ + useSandboxGitClient: boolean; +} + +/** Type describing the default options. Used for narrowing in generics. */ +export type defaultTestOptionsType = TestOptions & { + useSandboxGitClient: false; +}; + +/** Default options for tests. Need to match with the default options type. */ +export const defaultTestOptions: defaultTestOptionsType = { + useSandboxGitClient: false, +}; + +/** Interface describing a test release action. */ +export interface TestReleaseAction< + T extends ReleaseAction = ReleaseAction, + O extends TestOptions = defaultTestOptionsType, +> { + active: ActiveReleaseTrains; + instance: T; + repo: GithubTestingRepo; + fork: GithubTestingRepo; + testTmpDir: string; + githubConfig: GithubConfig; + releaseConfig: ReleaseConfig; + gitClient: O['useSandboxGitClient'] extends true ? SandboxGitClient : VirtualGitClient; +} diff --git a/ng-dev/release/publish/test/test-utils/test-utils.ts b/ng-dev/release/publish/test/test-utils/test-utils.ts new file mode 100644 index 000000000..25379cbb1 --- /dev/null +++ b/ng-dev/release/publish/test/test-utils/test-utils.ts @@ -0,0 +1,98 @@ +/** + * @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 semver from 'semver'; + +import { + getTestConfigurationsForAction, + setupMocksForReleaseAction, + testTmpDir, +} from './action-mocks'; +import {_npmPackageInfoCache, ActiveReleaseTrains, NpmPackageInfo} from '../../../versioning'; +import {ReleaseAction, ReleaseActionConstructor} from '../../actions'; +import {GithubTestingRepo} from './github-api-testing'; +import {defaultTestOptions, TestOptions, TestReleaseAction} from './test-action'; +import {dedent} from '../../../../utils/testing/dedent'; + +/** + * Sets up the given release action for testing. + * @param actionCtor Type of release action to be tested. + * @param active Fake active release trains for the action, + * @param isNextPublishedToNpm Whether the next version is published to NPM. True by default. + * @param testOptions Additional options that can be used to control the test setup. + */ +export function setupReleaseActionForTesting( + actionCtor: ReleaseActionConstructor, + active: ActiveReleaseTrains, + isNextPublishedToNpm = true, + testOptions: O = defaultTestOptions as O, +): TestReleaseAction { + // Reset existing HTTP interceptors. + nock.cleanAll(); + + const {githubConfig, releaseConfig} = getTestConfigurationsForAction(); + const repo = new GithubTestingRepo(githubConfig.owner, githubConfig.name); + const fork = new GithubTestingRepo('some-user', 'fork'); + + // The version for the release-train in the next phase does not necessarily need to be + // published to NPM. We mock the NPM package request and fake the state of the next + // version based on the `isNextPublishedToNpm` testing parameter. More details on the + // special case for the next release train can be found in the next pre-release action. + fakeNpmPackageQueryRequest(releaseConfig.npmPackages[0], { + versions: {[active.next.version.format()]: isNextPublishedToNpm ? {} : undefined}, + }); + + // Setup mocks for release action. + const {gitClient} = setupMocksForReleaseAction( + githubConfig, + releaseConfig, + testOptions.useSandboxGitClient, + ); + + const action = new actionCtor(active, gitClient, releaseConfig, testTmpDir); + + return {instance: action, active, repo, fork, testTmpDir, githubConfig, releaseConfig, gitClient}; +} + +/** Parses the specified version into Semver. */ +export function parse(version: string): semver.SemVer { + return semver.parse(version)!; +} + +/** Fakes a NPM package query API request for the given package. */ +export function fakeNpmPackageQueryRequest(pkgName: string, data: Partial) { + _npmPackageInfoCache[pkgName] = Promise.resolve({ + 'dist-tags': {}, + versions: {}, + time: {}, + ...data, + }); +} + +/** + * Template string function that converts a changelog pattern to a regular + * expression that can be used for test assertions. + * + * The following transformations are applied to allow for more readable + * test assertions: + * + * 1. The computed string will be updated to omit the smallest common indentation. + * 2. The `<..>` is a placeholder that will allow for arbitrary content. + */ +export function changelogPattern(strings: TemplateStringsArray, ...values: any[]): RegExp { + return new RegExp( + sanitizeForRegularExpression(dedent(strings, ...values).trim()).replace(/<\\.\\.>/g, '.*?'), + 'g', + ); +} + +/** Sanitizes a given string so that it can be used as literal in a RegExp. */ +function sanitizeForRegularExpression(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/ng-dev/release/versioning/next-prerelease-version.ts b/ng-dev/release/versioning/next-prerelease-version.ts index 8048b4550..60341a172 100644 --- a/ng-dev/release/versioning/next-prerelease-version.ts +++ b/ng-dev/release/versioning/next-prerelease-version.ts @@ -7,6 +7,7 @@ */ import * as semver from 'semver'; +import {SemVer} from 'semver'; import {semverInc} from '../../utils/semver'; import {ReleaseConfig} from '../config/index'; @@ -14,6 +15,26 @@ import {ReleaseConfig} from '../config/index'; import {ActiveReleaseTrains} from './active-release-trains'; import {isVersionPublishedToNpm} from './npm-registry'; +/** + * Gets a version that can be used to build release notes for the next + * release train. + */ +export async function getReleaseNotesCompareVersionForNext( + active: ActiveReleaseTrains, + config: ReleaseConfig, +): Promise { + const {version: nextVersion} = active.next; + // Special-case where the version in the `next` release-train is not published yet. This + // happens when we recently branched off for feature-freeze. We already bump the version to + // the next minor or major, but do not publish immediately. Cutting a release immediately would + // be not helpful as there are no other changes than in the feature-freeze branch. + const isNextPublishedToNpm = await isVersionPublishedToNpm(nextVersion, config); + // If we happen to detect the case from above, we use the most recent patch version as base for + // building release notes. This is better than finding the "next" version when we branched-off + // as it also prevents us from duplicating many commits that have already landed in the FF/RC. + return isNextPublishedToNpm ? nextVersion : active.latest.version; +} + /** Computes the new pre-release version for the next release-train. */ export async function computeNewPrereleaseVersionForNext( active: ActiveReleaseTrains, diff --git a/ng-dev/release/versioning/version-tags.ts b/ng-dev/release/versioning/version-tags.ts new file mode 100644 index 000000000..73e6e873c --- /dev/null +++ b/ng-dev/release/versioning/version-tags.ts @@ -0,0 +1,14 @@ +/** + * @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 {SemVer} from 'semver'; + +/** Gets the release tag name for the specified version. */ +export function getReleaseTagForVersion(version: SemVer): string { + return version.format(); +} diff --git a/ng-dev/utils/git/git-client.ts b/ng-dev/utils/git/git-client.ts index 21fc6e652..2b85bfbfa 100644 --- a/ng-dev/utils/git/git-client.ts +++ b/ng-dev/utils/git/git-client.ts @@ -50,6 +50,12 @@ export class GitClient { /** Instance of the Github client. */ readonly github = new GithubClient(); + /** + * Path to the Git executable. By default, `git` is assumed to exist + * in the shell environment (using `$PATH`). + */ + readonly gitBinPath: string = 'git'; + constructor( /** The full path to the root of the repository base. */ readonly baseDir = determineRepoBaseDirFromCwd(), @@ -92,7 +98,7 @@ export class GitClient { // others if the tool failed, and we do not want to leak tokens. printFn('Executing: git', this.sanitizeConsoleOutput(args.join(' '))); - const result = spawnSync('git', args, { + const result = spawnSync(this.gitBinPath, args, { cwd: this.baseDir, stdio: 'pipe', ...options, @@ -108,6 +114,13 @@ export class GitClient { process.stderr.write(this.sanitizeConsoleOutput(result.stderr)); } + if (result.error !== undefined) { + // Git sometimes prints the command if it failed. This means that it could + // potentially leak the Github token used for accessing the remote. To avoid + // printing a token, we sanitize the string before printing the stderr output. + process.stderr.write(this.sanitizeConsoleOutput(result.error.message)); + } + return result; } @@ -157,34 +170,6 @@ export class GitClient { return this.runGraceful(['checkout', branchOrRevision], {stdio: 'ignore'}).status === 0; } - /** Gets the latest git tag on the current branch that matches SemVer. */ - getLatestSemverTag(): SemVer { - const semVerOptions: SemVerOptions = {loose: true}; - const tags = this.runGraceful(['tag', '--sort=-committerdate', '--merged']).stdout.split('\n'); - const latestTag = tags.find((tag: string) => parse(tag, semVerOptions)); - - if (latestTag === undefined) { - throw new Error( - `Unable to find a SemVer matching tag on "${this.getCurrentBranchOrRevision()}"`, - ); - } - return new SemVer(latestTag, semVerOptions); - } - - /** Retrieves the git tag matching the provided SemVer, if it exists. */ - getMatchingTagForSemver(semver: SemVer): string { - const semVerOptions: SemVerOptions = {loose: true}; - const tags = this.runGraceful(['tag', '--sort=-committerdate', '--merged']).stdout.split('\n'); - const matchingTag = tags.find( - (tag: string) => parse(tag, semVerOptions)?.compare(semver) === 0, - ); - - if (matchingTag === undefined) { - throw new Error(`Unable to find a tag for the version: "${semver.format()}"`); - } - return matchingTag; - } - /** Retrieve a list of all files in the repository changed since the provided shaOrRef. */ allChangesFilesSince(shaOrRef = 'HEAD'): string[] { return Array.from( diff --git a/ng-dev/utils/testing/dedent.ts b/ng-dev/utils/testing/dedent.ts new file mode 100644 index 000000000..65663bd7e --- /dev/null +++ b/ng-dev/utils/testing/dedent.ts @@ -0,0 +1,28 @@ +/** + * @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 + */ + +/** + * Template string function that can be used to dedent a given string + * literal. The smallest common indentation will be omitted. + */ +export function dedent(strings: TemplateStringsArray, ...values: any[]) { + let joinedString = ''; + for (let i = 0; i < values.length; i++) { + joinedString += `${strings[i]}${values[i]}`; + } + joinedString += strings[strings.length - 1]; + + const matches = joinedString.match(/^[ \t]*(?=\S)/gm); + if (matches === null) { + return joinedString; + } + + const minLineIndent = Math.min(...matches.map((el) => el.length)); + const omitMinIndentRegex = new RegExp(`^[ \\t]{${minLineIndent}}`, 'gm'); + return minLineIndent > 0 ? joinedString.replace(omitMinIndentRegex, '') : joinedString; +} diff --git a/ng-dev/utils/testing/virtual-git-client.ts b/ng-dev/utils/testing/virtual-git-client.ts index 147e5e3f3..adcbd5a69 100644 --- a/ng-dev/utils/testing/virtual-git-client.ts +++ b/ng-dev/utils/testing/virtual-git-client.ts @@ -8,7 +8,6 @@ import {SpawnSyncOptions, SpawnSyncReturns} from 'child_process'; import * as parseArgs from 'minimist'; -import {SemVer} from 'semver'; import {NgDevConfig} from '../config'; import {AuthenticatedGitClient} from '../git/authenticated-git-client'; @@ -18,7 +17,7 @@ import {GitClient} from '../git/git-client'; * Temporary directory which will be used as project directory in tests. Note that * this environment variable is automatically set by Bazel for tests. */ -export const testTmpDir: string = process.env['TEST_TMPDIR']!; +const testTmpDir: string = process.env['TEST_TMPDIR']!; /** A mock instance of a configuration for the ng-dev toolset for default testing. */ export const mockNgDevConfig: NgDevConfig = { @@ -75,22 +74,6 @@ export class VirtualGitClient extends AuthenticatedGitClient { /** List of pushed heads to a given remote ref. */ pushed: {remote: RemoteRef; head: GitHead}[] = []; - /** - * Override the actual GitClient getLatestSemverTag, as an actual tag cannot be retrieved in - * testing. - */ - override getLatestSemverTag() { - return new SemVer('0.0.0'); - } - - /** - * Override the actual GitClient getLatestSemverTag, as an actual tags cannot be checked during - * testing, return back the SemVer version as the tag. - */ - override getMatchingTagForSemver(semver: SemVer) { - return semver.format(); - } - /** Override for the actual Git client command execution. */ override runGraceful(args: string[], options: SpawnSyncOptions = {}): SpawnSyncReturns { const [command, ...rawArgs] = args; diff --git a/tools/git-toolchain/BUILD.bazel b/tools/git-toolchain/BUILD.bazel new file mode 100644 index 000000000..3601daf44 --- /dev/null +++ b/tools/git-toolchain/BUILD.bazel @@ -0,0 +1,65 @@ +load(":toolchain.bzl", "git_toolchain") +load(":alias.bzl", "git_toolchain_alias") + +package(default_visibility = ["//visibility:public"]) + +toolchain_type(name = "toolchain_type") + +git_toolchain_alias(name = "current_git_toolchain") + +git_toolchain( + name = "git_linux", + binary_path = "/usr/bin/git", +) + +git_toolchain( + name = "git_macos", + binary_path = "/usr/bin/git", +) + +git_toolchain( + name = "git_windows", + binary_path = "C:\\Program Files\\Git\\bin\\git.exe", +) + +toolchain( + name = "git_linux_toolchain", + exec_compatible_with = [ + "@platforms//os:linux", + "@platforms//cpu:x86_64", + ], + target_compatible_with = [ + "@platforms//os:linux", + "@platforms//cpu:x86_64", + ], + toolchain = ":git_linux", + toolchain_type = ":toolchain_type", +) + +toolchain( + name = "git_macos_toolchain", + exec_compatible_with = [ + "@platforms//os:macos", + "@platforms//cpu:x86_64", + ], + target_compatible_with = [ + "@platforms//os:macos", + "@platforms//cpu:x86_64", + ], + toolchain = ":git_macos", + toolchain_type = ":toolchain_type", +) + +toolchain( + name = "git_windows_toolchain", + exec_compatible_with = [ + "@platforms//os:windows", + "@platforms//cpu:x86_64", + ], + target_compatible_with = [ + "@platforms//os:windows", + "@platforms//cpu:x86_64", + ], + toolchain = ":git_windows", + toolchain_type = ":toolchain_type", +) diff --git a/tools/git-toolchain/alias.bzl b/tools/git-toolchain/alias.bzl new file mode 100644 index 000000000..fd83c4c59 --- /dev/null +++ b/tools/git-toolchain/alias.bzl @@ -0,0 +1,17 @@ +def _git_toolchain_alias_impl(ctx): + toolchain = ctx.toolchains["//tools/git-toolchain:toolchain_type"] + + return [ + platform_common.TemplateVariableInfo({ + "GIT_BIN_PATH": toolchain.binary_path, + }), + ] + +git_toolchain_alias = rule( + implementation = _git_toolchain_alias_impl, + toolchains = ["//tools/git-toolchain:toolchain_type"], + doc = """ + Exposes an alias for retrieving the resolved Git toolchain. Exposing a template variable for + accessing the Git binary path using Bazel `Make variables`. + """, +) diff --git a/tools/git-toolchain/toolchain.bzl b/tools/git-toolchain/toolchain.bzl new file mode 100644 index 000000000..0594b76ee --- /dev/null +++ b/tools/git-toolchain/toolchain.bzl @@ -0,0 +1,14 @@ +def _git_toolchain_impl(ctx): + return [ + platform_common.ToolchainInfo( + binary_path = ctx.attr.binary_path, + ), + ] + +git_toolchain = rule( + implementation = _git_toolchain_impl, + attrs = { + "binary_path": attr.string(doc = "System path to the Git binary"), + }, + doc = "Toolchain for configuring Git for a specific platform.", +) diff --git a/tsconfig.json b/tsconfig.json index a62d65eec..f26a9b6c6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,8 @@ "target": "es2020", "lib": ["es2020"], "strict": true, + "noImplicitOverride": true, + "noImplicitReturns": true, "moduleResolution": "node" } }