Skip to content

Commit f10879a

Browse files
authored
fix(ng-dev): publishing fails if pre-release is cut for new minor/major (angular#126)
Currently publishing fails if a pre-release is cut for an upcoming major/minor. This is because the `next` release-train has some special logic where its version is already bumped, but the version has not been released. Our release notes logic currently when building a pre-release for next, looks for a tag named similar to the version in the `package.json`. This will always fail. To fix this, we consult the release trains for building up the Git range that is used for the release notes. We still rely on tags, but we determine the tags from the active release trains (which is more robust than relying on the package json value). For the special `next` release-train situation, we build up the release notes from the most recent patch to `HEAD` of next. While doing this, we also dedupe any commits that have already landed in patch, RC, or FF releases.
1 parent 69aec1a commit f10879a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+1649
-525
lines changed

WORKSPACE

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,9 @@ browser_repositories()
4444
load("@npm//@bazel/esbuild:esbuild_repositories.bzl", "esbuild_repositories")
4545

4646
esbuild_repositories()
47+
48+
register_toolchains(
49+
"//tools/git-toolchain:git_linux_toolchain",
50+
"//tools/git-toolchain:git_macos_toolchain",
51+
"//tools/git-toolchain:git_windows_toolchain",
52+
)

ng-dev/commit-message/parse.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,9 @@ function parseInternal(fullText: string | Buffer): CommitFromGitLog | Commit {
142142
// Extract the commit message notes by marked types into their respective lists.
143143
commit.notes.forEach((note: ParsedCommit.Note) => {
144144
if (note.title === NoteSections.BREAKING_CHANGE) {
145-
return breakingChanges.push(note);
146-
}
147-
if (note.title === NoteSections.DEPRECATED) {
148-
return deprecations.push(note);
145+
breakingChanges.push(note);
146+
} else if (note.title === NoteSections.DEPRECATED) {
147+
deprecations.push(note);
149148
}
150149
});
151150

ng-dev/release/notes/cli.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,12 @@ import {SemVer} from 'semver';
1212
import {Arguments, Argv, CommandModule} from 'yargs';
1313

1414
import {info} from '../../utils/console';
15-
import {GitClient} from '../../utils/git/git-client';
1615

1716
import {ReleaseNotes} from './release-notes';
1817

1918
/** Command line options for building a release. */
2019
export interface ReleaseNotesOptions {
21-
from?: string;
20+
from: string;
2221
to: string;
2322
outFile?: string;
2423
releaseVersion: SemVer;
@@ -36,7 +35,7 @@ function builder(argv: Argv): Argv<ReleaseNotesOptions> {
3635
.option('from', {
3736
type: 'string',
3837
description: 'The git tag or ref to start the changelog entry from',
39-
defaultDescription: 'The latest semver tag',
38+
demandOption: true,
4039
})
4140
.option('to', {
4241
type: 'string',
@@ -58,11 +57,8 @@ function builder(argv: Argv): Argv<ReleaseNotesOptions> {
5857

5958
/** Yargs command handler for generating release notes. */
6059
async function handler({releaseVersion, from, to, outFile, type}: Arguments<ReleaseNotesOptions>) {
61-
// Since `yargs` evaluates defaults even if a value as been provided, if no value is provided to
62-
// the handler, the latest semver tag on the branch is used.
63-
from = from || GitClient.get().getLatestSemverTag().format();
6460
/** The ReleaseNotes instance to generate release notes. */
65-
const releaseNotes = await ReleaseNotes.fromRange(releaseVersion, from, to);
61+
const releaseNotes = await ReleaseNotes.forRange(releaseVersion, from, to);
6662

6763
/** The requested release notes entry. */
6864
const releaseNotesEntry = await (type === 'changelog'
34.3 KB
Loading
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {GitClient} from '../../../utils/git/git-client';
10+
import {
11+
CommitFromGitLog,
12+
gitLogFormatForParsing,
13+
parseCommitFromGitLog,
14+
} from '../../../commit-message/parse';
15+
import {computeUniqueIdFromCommitMessage} from './unique-commit-id';
16+
17+
/**
18+
* Gets all commits the head branch contains, but the base branch does not include.
19+
* This follows the same semantics as Git's double-dot revision range.
20+
*
21+
* i.e. `<baseRef>..<headRef>` revision range as per Git.
22+
* https://git-scm.com/book/en/v2/Git-Tools-Revision-Selection.
23+
*
24+
* Branches in the Angular organization are diverging quickly due to multiple factors
25+
* concerning the versioning and merging. i.e. Commits are cherry-picked into branches,
26+
* resulting in different SHAs for each branch. Additionally, branches diverge quickly
27+
* because changes can be made only for specific branches (e.g. a master-only change).
28+
*
29+
* In order to allow for comparisons that follow similar semantics as Git's double-dot
30+
* revision range syntax, the logic re-implementing the semantics need to account for
31+
* the mentioned semi-diverged branches. We achieve this by excluding commits in the
32+
* head branch which have a similarly-named commit in the base branch. We cannot rely on
33+
* SHAs for determining common commits between the two branches (as explained above).
34+
*
35+
* More details can be found in the `get-commits-in-range.png` file which illustrates a
36+
* scenario where commits from the patch branch need to be excluded from the main branch.
37+
*/
38+
export function getCommitsForRangeWithDeduping(
39+
client: GitClient,
40+
baseRef: string,
41+
headRef: string,
42+
): CommitFromGitLog[] {
43+
const commits: CommitFromGitLog[] = [];
44+
const commitsForHead = fetchCommitsForRevisionRange(client, `${baseRef}..${headRef}`);
45+
const commitsForBase = fetchCommitsForRevisionRange(client, `${headRef}..${baseRef}`);
46+
47+
// Map that keeps track of commits within the base branch. Commits are
48+
// stored with an unique id based on the commit message. If a similarly-named
49+
// commit appears multiple times, the value number will reflect that.
50+
const knownCommitsOnlyInBase = new Map<string, number>();
51+
52+
for (const commit of commitsForBase) {
53+
const id = computeUniqueIdFromCommitMessage(commit);
54+
const numSimilarCommits = knownCommitsOnlyInBase.get(id) ?? 0;
55+
knownCommitsOnlyInBase.set(id, numSimilarCommits + 1);
56+
}
57+
58+
for (const commit of commitsForHead) {
59+
const id = computeUniqueIdFromCommitMessage(commit);
60+
const numSimilarCommits = knownCommitsOnlyInBase.get(id) ?? 0;
61+
62+
// If there is a similar commit in the base branch, the current commit in the head branch
63+
// needs to be skipped. We keep track of the number of similar commits so that we do not
64+
// accidentally "dedupe" a commit. e.g. consider a case where commit `X` lands in the
65+
// patch branch and next branch. Then a similar similarly named commits lands only in the
66+
// next branch. We would not want to omit that one as it is not part of the patch branch.
67+
if (numSimilarCommits > 0) {
68+
knownCommitsOnlyInBase.set(id, numSimilarCommits - 1);
69+
continue;
70+
}
71+
72+
commits.push(commit);
73+
}
74+
75+
return commits;
76+
}
77+
78+
/** Fetches commits for the given revision range using `git log`. */
79+
export function fetchCommitsForRevisionRange(
80+
client: GitClient,
81+
revisionRange: string,
82+
): CommitFromGitLog[] {
83+
const splitDelimiter = '-------------ɵɵ------------';
84+
const output = client.run([
85+
'log',
86+
`--format=${gitLogFormatForParsing}${splitDelimiter}`,
87+
revisionRange,
88+
]);
89+
90+
return output.stdout
91+
.split(splitDelimiter)
92+
.filter((entry) => !!entry.trim())
93+
.map((entry) => parseCommitFromGitLog(Buffer.from(entry, 'utf-8')));
94+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {Commit} from '../../../commit-message/parse';
10+
11+
/**
12+
* Fields from a `Commit` to incorporate when building up an unique
13+
* id for a commit message.
14+
*
15+
* Note: The header incorporates the commit type, scope and message
16+
* but lacks information for fixup, revert or squash commits..
17+
*/
18+
const fieldsToIncorporateForId: (keyof Commit)[] = ['header', 'isFixup', 'isRevert', 'isSquash'];
19+
20+
/**
21+
* Computes an unique id for the given commit using its commit message.
22+
* This can be helpful for comparisons, if commits differ in SHAs due
23+
* to cherry-picking.
24+
*/
25+
export function computeUniqueIdFromCommitMessage(commit: Commit): string {
26+
// Join all resolved fields with a special character to build up an id.
27+
return fieldsToIncorporateForId.map((f) => commit[f]).join('ɵɵ');
28+
}

ng-dev/release/notes/release-notes.ts

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,21 @@ import {render} from 'ejs';
99
import * as semver from 'semver';
1010
import {CommitFromGitLog} from '../../commit-message/parse';
1111

12-
import {getCommitsInRange} from '../../commit-message/utils';
1312
import {promptInput} from '../../utils/console';
1413
import {GitClient} from '../../utils/git/git-client';
1514
import {DevInfraReleaseConfig, getReleaseConfig, ReleaseNotesConfig} from '../config/index';
1615
import {RenderContext} from './context';
1716

1817
import changelogTemplate from './templates/changelog';
1918
import githubReleaseTemplate from './templates/github-release';
19+
import {getCommitsForRangeWithDeduping} from './commits/get-commits-in-range';
2020

2121
/** Release note generation. */
2222
export class ReleaseNotes {
23-
static async fromRange(version: semver.SemVer, startingRef: string, endingRef: string) {
24-
return new ReleaseNotes(version, startingRef, endingRef);
23+
static async forRange(version: semver.SemVer, baseRef: string, headRef: string) {
24+
const client = GitClient.get();
25+
const commits = getCommitsForRangeWithDeduping(client, baseRef, headRef);
26+
return new ReleaseNotes(version, commits);
2527
}
2628

2729
/** An instance of GitClient. */
@@ -30,19 +32,10 @@ export class ReleaseNotes {
3032
private renderContext: RenderContext | undefined;
3133
/** The title to use for the release. */
3234
private title: string | false | undefined;
33-
/** A promise resolving to a list of Commits since the latest semver tag on the branch. */
34-
private commits: Promise<CommitFromGitLog[]> = this.getCommitsInRange(
35-
this.startingRef,
36-
this.endingRef,
37-
);
3835
/** The configuration for release notes. */
3936
private config: ReleaseNotesConfig = this.getReleaseConfig().releaseNotes;
4037

41-
protected constructor(
42-
public version: semver.SemVer,
43-
private startingRef: string,
44-
private endingRef: string,
45-
) {}
38+
protected constructor(public version: semver.SemVer, private commits: CommitFromGitLog[]) {}
4639

4740
/** Retrieve the release note generated for a Github Release. */
4841
async getGithubReleaseEntry(): Promise<string> {
@@ -75,7 +68,7 @@ export class ReleaseNotes {
7568
private async generateRenderContext(): Promise<RenderContext> {
7669
if (!this.renderContext) {
7770
this.renderContext = new RenderContext({
78-
commits: await this.commits,
71+
commits: this.commits,
7972
github: this.git.remoteConfig,
8073
version: this.version.format(),
8174
groupOrder: this.config.groupOrder,
@@ -86,12 +79,8 @@ export class ReleaseNotes {
8679
return this.renderContext;
8780
}
8881

89-
// These methods are used for access to the utility functions while allowing them to be
90-
// overwritten in subclasses during testing.
91-
protected async getCommitsInRange(from: string, to?: string) {
92-
return getCommitsInRange(from, to);
93-
}
94-
82+
// These methods are used for access to the utility functions while allowing them
83+
// to be overwritten in subclasses during testing.
9584
protected getReleaseConfig(config?: Partial<DevInfraReleaseConfig>) {
9685
return getReleaseConfig(config);
9786
}

ng-dev/release/publish/actions.ts

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {changelogPath, packageJsonPath, waitForPullRequestInterval} from './cons
2727
import {invokeReleaseBuildCommand, invokeYarnInstallCommand} from './external-commands';
2828
import {findOwnedForksOfRepoQuery} from './graphql-queries';
2929
import {getPullRequestState} from './pull-request-state';
30+
import {getReleaseTagForVersion} from '../versioning/version-tags';
3031

3132
/** Interface describing a Github repository. */
3233
export interface GithubRepo {
@@ -377,33 +378,44 @@ export abstract class ReleaseAction {
377378
/**
378379
* Creates a commit for the specified files with the given message.
379380
* @param message Message for the created commit
380-
* @param files List of project-relative file paths to be commited.
381+
* @param files List of project-relative file paths to be committed.
381382
*/
382383
protected async createCommit(message: string, files: string[]) {
384+
// Note: `git add` would not be needed if the files are already known to
385+
// Git, but the specified files could also be newly created, and unknown.
386+
this.git.run(['add', ...files]);
383387
this.git.run(['commit', '-q', '--no-verify', '-m', message, ...files]);
384388
}
385389

386390
/**
387-
* Stages the specified new version for the current branch and creates a
388-
* pull request that targets the given base branch.
391+
* Stages the specified new version for the current branch and creates a pull request
392+
* that targets the given base branch. Assumes the staging branch is already checked-out.
393+
*
394+
* @param newVersion New version to be staged.
395+
* @param compareVersionForReleaseNotes Version used for comparing with the current
396+
* `HEAD` in order build the release notes.
397+
* @param pullRequestTargetBranch Branch the pull request should target.
389398
* @returns an object describing the created pull request.
390399
*/
391400
protected async stageVersionForBranchAndCreatePullRequest(
392401
newVersion: semver.SemVer,
393-
pullRequestBaseBranch: string,
402+
compareVersionForReleaseNotes: semver.SemVer,
403+
pullRequestTargetBranch: string,
394404
): Promise<{releaseNotes: ReleaseNotes; pullRequest: PullRequest}> {
395-
/**
396-
* The current version of the project for the branch from the root package.json. This must be
397-
* retrieved prior to updating the project version.
398-
*/
399-
const currentVersion = this.git.getMatchingTagForSemver(await this.getProjectVersion());
400-
const releaseNotes = await ReleaseNotes.fromRange(newVersion, currentVersion, 'HEAD');
405+
const releaseNotesCompareTag = getReleaseTagForVersion(compareVersionForReleaseNotes);
406+
407+
// Fetch the compare tag so that commits for the release notes can be determined.
408+
this.git.run(['fetch', this.git.getRepoGitUrl(), `refs/tags/${releaseNotesCompareTag}`]);
409+
410+
// Build release notes for commits from `<releaseNotesCompareTag>..HEAD`.
411+
const releaseNotes = await ReleaseNotes.forRange(newVersion, releaseNotesCompareTag, 'HEAD');
412+
401413
await this.updateProjectVersion(newVersion);
402414
await this.prependReleaseNotesToChangelog(releaseNotes);
403415
await this.waitForEditsAndCreateReleaseCommit(newVersion);
404416

405417
const pullRequest = await this.pushChangesToForkAndCreatePullRequest(
406-
pullRequestBaseBranch,
418+
pullRequestTargetBranch,
407419
`release-stage-${newVersion}`,
408420
`Bump version to "v${newVersion}" with changelog.`,
409421
);
@@ -417,15 +429,25 @@ export abstract class ReleaseAction {
417429
/**
418430
* Checks out the specified target branch, verifies its CI status and stages
419431
* the specified new version in order to create a pull request.
432+
*
433+
* @param newVersion New version to be staged.
434+
* @param compareVersionForReleaseNotes Version used for comparing with `HEAD` of
435+
* the staging branch in order build the release notes.
436+
* @param stagingBranch Branch within the new version should be staged.
420437
* @returns an object describing the created pull request.
421438
*/
422439
protected async checkoutBranchAndStageVersion(
423440
newVersion: semver.SemVer,
441+
compareVersionForReleaseNotes: semver.SemVer,
424442
stagingBranch: string,
425443
): Promise<{releaseNotes: ReleaseNotes; pullRequest: PullRequest}> {
426444
await this.verifyPassingGithubStatus(stagingBranch);
427445
await this.checkoutUpstreamBranch(stagingBranch);
428-
return await this.stageVersionForBranchAndCreatePullRequest(newVersion, stagingBranch);
446+
return await this.stageVersionForBranchAndCreatePullRequest(
447+
newVersion,
448+
compareVersionForReleaseNotes,
449+
stagingBranch,
450+
);
429451
}
430452

431453
/**
@@ -481,7 +503,7 @@ export abstract class ReleaseAction {
481503
versionBumpCommitSha: string,
482504
prerelease: boolean,
483505
) {
484-
const tagName = releaseNotes.version.format();
506+
const tagName = getReleaseTagForVersion(releaseNotes.version);
485507
await this.git.github.git.createRef({
486508
...this.git.remoteParams,
487509
ref: `refs/tags/${tagName}`,

ng-dev/release/publish/actions/branch-off-next-branch.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import * as semver from 'semver';
1111
import {green, info, yellow} from '../../../utils/console';
1212
import {semverInc} from '../../../utils/semver';
1313
import {ReleaseNotes} from '../../notes/release-notes';
14-
import {ActiveReleaseTrains} from '../../versioning/active-release-trains';
15-
import {computeNewPrereleaseVersionForNext} from '../../versioning/next-prerelease-version';
14+
import {
15+
computeNewPrereleaseVersionForNext,
16+
getReleaseNotesCompareVersionForNext,
17+
} from '../../versioning/next-prerelease-version';
1618
import {ReleaseAction} from '../actions';
1719
import {
1820
getCommitMessageForExceptionalNextVersionBump,
@@ -42,6 +44,10 @@ export abstract class BranchOffNextBranchBaseAction extends ReleaseAction {
4244
}
4345

4446
override async perform() {
47+
const compareVersionForReleaseNotes = await getReleaseNotesCompareVersionForNext(
48+
this.active,
49+
this.config,
50+
);
4551
const newVersion = await this._computeNewVersion();
4652
const newBranch = `${newVersion.major}.${newVersion.minor}.x`;
4753

@@ -53,6 +59,7 @@ export abstract class BranchOffNextBranchBaseAction extends ReleaseAction {
5359
// created branch instead of re-fetching from the upstream.
5460
const {pullRequest, releaseNotes} = await this.stageVersionForBranchAndCreatePullRequest(
5561
newVersion,
62+
compareVersionForReleaseNotes,
5663
newBranch,
5764
);
5865

ng-dev/release/publish/actions/cut-lts-patch.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,11 @@ export class CutLongTermSupportPatchAction extends ReleaseAction {
3030
override async perform() {
3131
const ltsBranch = await this._promptForTargetLtsBranch();
3232
const newVersion = semverInc(ltsBranch.version, 'patch');
33+
const compareVersionForReleaseNotes = ltsBranch.version;
34+
3335
const {pullRequest, releaseNotes} = await this.checkoutBranchAndStageVersion(
3436
newVersion,
37+
compareVersionForReleaseNotes,
3538
ltsBranch.name,
3639
);
3740

0 commit comments

Comments
 (0)