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 000000000..578839541 Binary files /dev/null and b/ng-dev/release/notes/commits/get-commits-in-range.png differ diff --git a/ng-dev/release/notes/commits/get-commits-in-range.ts b/ng-dev/release/notes/commits/get-commits-in-range.ts new file mode 100644 index 000000000..07d540de7 --- /dev/null +++ b/ng-dev/release/notes/commits/get-commits-in-range.ts @@ -0,0 +1,94 @@ +/** + * @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 {GitClient} from '../../../utils/git/git-client'; +import { + CommitFromGitLog, + gitLogFormatForParsing, + parseCommitFromGitLog, +} from '../../../commit-message/parse'; +import {computeUniqueIdFromCommitMessage} from './unique-commit-id'; + +/** + * Gets all commits the head branch contains, but the base branch does not include. + * This follows the same semantics as Git's double-dot revision range. + * + * i.e. `..` 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" } }