feat(dev-infra): Set up new common release notes generation tooling (#41905)

Enables the new common release notes generation within the ng-dev release publishing
tooling.

PR Close #41905
This commit is contained in:
Joey Perrott 2021-04-19 11:58:32 -07:00 committed by Alex Rickabaugh
parent 90d4b2277b
commit 393ce94718
20 changed files with 325 additions and 316 deletions

View File

@ -691,8 +691,8 @@ function getReleaseConfig(config = getConfig()) {
if (((_b = config.release) === null || _b === void 0 ? void 0 : _b.buildPackages) === undefined) { if (((_b = config.release) === null || _b === void 0 ? void 0 : _b.buildPackages) === undefined) {
errors.push(`No "buildPackages" function configured for releasing.`); errors.push(`No "buildPackages" function configured for releasing.`);
} }
if (((_c = config.release) === null || _c === void 0 ? void 0 : _c.generateReleaseNotesForHead) === undefined) { if (((_c = config.release) === null || _c === void 0 ? void 0 : _c.releaseNotes) === undefined) {
errors.push(`No "generateReleaseNotesForHead" function configured for releasing.`); errors.push(`No "releaseNotes" configured for releasing.`);
} }
assertNoErrors(errors); assertNoErrors(errors);
return config.release; return config.release;

View File

@ -25,7 +25,7 @@ var os = require('os');
var shelljs = require('shelljs'); var shelljs = require('shelljs');
var minimatch = require('minimatch'); var minimatch = require('minimatch');
var ora = require('ora'); var ora = require('ora');
require('ejs'); var ejs = require('ejs');
var glob = require('glob'); var glob = require('glob');
var ts = require('typescript'); var ts = require('typescript');
@ -647,6 +647,17 @@ function promptConfirm(message, defaultValue) {
}); });
}); });
} }
/** Prompts the user for one line of input. */
function promptInput(message) {
return tslib.__awaiter(this, void 0, void 0, function () {
return tslib.__generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, inquirer.prompt({ type: 'input', name: 'result', message: message })];
case 1: return [2 /*return*/, (_a.sent()).result];
}
});
});
}
/** /**
* Supported levels for logging functions. * Supported levels for logging functions.
* *
@ -5108,8 +5119,8 @@ function getReleaseConfig(config = getConfig()) {
if (((_b = config.release) === null || _b === void 0 ? void 0 : _b.buildPackages) === undefined) { if (((_b = config.release) === null || _b === void 0 ? void 0 : _b.buildPackages) === undefined) {
errors.push(`No "buildPackages" function configured for releasing.`); errors.push(`No "buildPackages" function configured for releasing.`);
} }
if (((_c = config.release) === null || _c === void 0 ? void 0 : _c.generateReleaseNotesForHead) === undefined) { if (((_c = config.release) === null || _c === void 0 ? void 0 : _c.releaseNotes) === undefined) {
errors.push(`No "generateReleaseNotesForHead" function configured for releasing.`); errors.push(`No "releaseNotes" configured for releasing.`);
} }
assertNoErrors(errors); assertNoErrors(errors);
return config.release; return config.release;
@ -5742,21 +5753,183 @@ function isCommitClosingPullRequest(api, sha, id) {
const typesToIncludeInReleaseNotes = Object.values(COMMIT_TYPES) const typesToIncludeInReleaseNotes = Object.values(COMMIT_TYPES)
.filter(type => type.releaseNotesLevel === ReleaseNotesLevel.Visible) .filter(type => type.releaseNotesLevel === ReleaseNotesLevel.Visible)
.map(type => type.name); .map(type => type.name);
/** Context class used for rendering release notes. */
/** class RenderContext {
* Gets the default pattern for extracting release notes for the given version. constructor(data) {
* This pattern matches for the conventional-changelog Angular preset. this.data = data;
*/ /** An array of group names in sort order if defined. */
function getDefaultExtractReleaseNotesPattern(version) { this.groupOrder = this.data.groupOrder || [];
const escapedVersion = version.format().replace('.', '\\.'); /** An array of scopes to hide from the release entry output. */
// TODO: Change this once we have a canonical changelog generation tool. Also update this this.hiddenScopes = this.data.hiddenScopes || [];
// based on the conventional-changelog version. They removed anchors in more recent versions. /** The title of the release, or `false` if no title should be used. */
return new RegExp(`(<a name="${escapedVersion}"></a>.*?)(?:<a name="|$)`, 's'); this.title = this.data.title;
/** An array of commits in the release period. */
this.commits = this.data.commits;
/** The version of the release. */
this.version = this.data.version;
/** The date stamp string for use in the release notes entry. */
this.dateStamp = buildDateStamp(this.data.date);
}
/**
* Organizes and sorts the commits into groups of commits.
*
* Groups are sorted either by default `Array.sort` order, or using the provided group order from
* the configuration. Commits are order in the same order within each groups commit list as they
* appear in the provided list of commits.
* */
asCommitGroups(commits) {
/** The discovered groups to organize into. */
const groups = new Map();
// Place each commit in the list into its group.
commits.forEach(commit => {
const key = commit.npmScope ? `${commit.npmScope}/${commit.scope}` : commit.scope;
const groupCommits = groups.get(key) || [];
groups.set(key, groupCommits);
groupCommits.push(commit);
});
/**
* Array of CommitGroups containing the discovered commit groups. Sorted in alphanumeric order
* of the group title.
*/
const commitGroups = Array.from(groups.entries())
.map(([title, commits]) => ({ title, commits }))
.sort((a, b) => a.title > b.title ? 1 : a.title < b.title ? -1 : 0);
// If the configuration provides a sorting order, updated the sorted list of group keys to
// satisfy the order of the groups provided in the list with any groups not found in the list at
// the end of the sorted list.
if (this.groupOrder.length) {
for (const groupTitle of this.groupOrder.reverse()) {
const currentIdx = commitGroups.findIndex(k => k.title === groupTitle);
if (currentIdx !== -1) {
const removedGroups = commitGroups.splice(currentIdx, 1);
commitGroups.splice(0, 0, ...removedGroups);
}
}
}
return commitGroups;
}
/**
* A filter function for filtering a list of commits to only include commits which should appear
* in release notes.
*/
includeInReleaseNotes() {
return (commit) => {
if (!typesToIncludeInReleaseNotes.includes(commit.type)) {
return false;
}
if (this.hiddenScopes.includes(commit.scope)) {
return false;
}
return true;
};
}
/**
* A filter function for filtering a list of commits to only include commits which contain a
* truthy value, or for arrays an array with 1 or more elements, for the provided field.
*/
contains(field) {
return (commit) => {
const fieldValue = commit[field];
if (!fieldValue) {
return false;
}
if (Array.isArray(fieldValue) && fieldValue.length === 0) {
return false;
}
return true;
};
}
/**
* A filter function for filtering a list of commits to only include commits which contain a
* unique value for the provided field across all commits in the list.
*/
unique(field) {
const set = new Set();
return (commit) => {
const include = !set.has(commit[field]);
set.add(commit[field]);
return include;
};
}
} }
/**
* Builds a date stamp for stamping in release notes.
*
* Uses the current date, or a provided date in the format of YYYY-MM-DD, i.e. 1970-11-05.
*/
function buildDateStamp(date = new Date()) {
const year = `${date.getFullYear()}`;
const month = `${(date.getMonth() + 1)}`.padStart(2, '0');
const day = `${date.getDate()}`.padStart(2, '0');
return [year, month, day].join('-');
}
/** Gets the path for the changelog file in a given project. */ /** Gets the path for the changelog file in a given project. */
function getLocalChangelogFilePath(projectDir) { function getLocalChangelogFilePath(projectDir) {
return path.join(projectDir, changelogPath); return path.join(projectDir, changelogPath);
} }
/** Release note generation. */
class ReleaseNotes {
constructor(version, config) {
this.version = version;
this.config = config;
/** An instance of GitClient. */
this.git = GitClient.getInstance();
/** A promise resolving to a list of Commits since the latest semver tag on the branch. */
this.commits = getCommitsInRange(this.git.getLatestSemverTag().format(), 'HEAD');
}
/** Construct a release note generation instance. */
static fromLatestTagToHead(version, config) {
return tslib.__awaiter(this, void 0, void 0, function* () {
return new ReleaseNotes(version, config);
});
}
/** Retrieve the release note generated for a Github Release. */
getGithubReleaseEntry() {
return tslib.__awaiter(this, void 0, void 0, function* () {
return ejs.renderFile(path.join(__dirname, 'templates/github-release.ejs'), yield this.generateRenderContext(), { rmWhitespace: true });
});
}
/** Retrieve the release note generated for a CHANGELOG entry. */
getChangelogEntry() {
return tslib.__awaiter(this, void 0, void 0, function* () {
return ejs.renderFile(path.join(__dirname, 'templates/changelog.ejs'), yield this.generateRenderContext(), { rmWhitespace: true });
});
}
/**
* Prompt the user for a title for the release, if the project's configuration is defined to use a
* title.
*/
promptForReleaseTitle() {
return tslib.__awaiter(this, void 0, void 0, function* () {
if (this.title === undefined) {
if (this.config.releaseNotes.useReleaseTitle) {
this.title = yield promptInput('Please provide a title for the release:');
}
else {
this.title = false;
}
}
return this.title;
});
}
/** Build the render context data object for constructing the RenderContext instance. */
generateRenderContext() {
return tslib.__awaiter(this, void 0, void 0, function* () {
if (!this.renderContext) {
this.renderContext = new RenderContext({
commits: yield this.commits,
github: this.git.remoteConfig,
version: this.version.format(),
groupOrder: this.config.releaseNotes.groupOrder,
hiddenScopes: this.config.releaseNotes.hiddenScopes,
title: yield this.promptForReleaseTitle(),
});
}
return this.renderContext;
});
}
}
/** /**
* @license * @license
@ -5831,22 +6004,6 @@ class ReleaseAction {
info(green(' ✓ Upstream commit is passing all github status checks.')); info(green(' ✓ Upstream commit is passing all github status checks.'));
}); });
} }
/** Generates the changelog for the specified for the current `HEAD`. */
_generateReleaseNotesForHead(version) {
return tslib.__awaiter(this, void 0, void 0, function* () {
const changelogPath = getLocalChangelogFilePath(this.projectDir);
yield this.config.generateReleaseNotesForHead(changelogPath);
info(green(` ✓ Updated the changelog to capture changes for "${version}".`));
});
}
/** Extract the release notes for the given version from the changelog file. */
_extractReleaseNotesForVersion(changelogContent, version) {
const pattern = this.config.extractReleaseNotesPattern !== undefined ?
this.config.extractReleaseNotesPattern(version) :
getDefaultExtractReleaseNotesPattern(version);
const matchedNotes = pattern.exec(changelogContent);
return matchedNotes === null ? null : matchedNotes[1];
}
/** /**
* Prompts the user for potential release notes edits that need to be made. Once * Prompts the user for potential release notes edits that need to be made. Once
* confirmed, a new commit for the release point is created. * confirmed, a new commit for the release point is created.
@ -6019,27 +6176,13 @@ class ReleaseAction {
* the current Git `HEAD`. This is useful for cherry-picking the changelog. * the current Git `HEAD`. This is useful for cherry-picking the changelog.
* @returns A boolean indicating whether the release notes have been prepended. * @returns A boolean indicating whether the release notes have been prepended.
*/ */
prependReleaseNotesFromVersionBranch(version, containingBranch) { prependReleaseNotesToChangelog(releaseNotes) {
return tslib.__awaiter(this, void 0, void 0, function* () { return tslib.__awaiter(this, void 0, void 0, function* () {
const { data } = yield this.git.github.repos.getContents(Object.assign(Object.assign({}, this.git.remoteParams), { path: '/' + changelogPath, ref: containingBranch }));
const branchChangelog = Buffer.from(data.content, 'base64').toString();
let releaseNotes = this._extractReleaseNotesForVersion(branchChangelog, version);
// If no release notes could be extracted, return "false" so that the caller
// can tell that changelog prepending failed.
if (releaseNotes === null) {
return false;
}
const localChangelogPath = getLocalChangelogFilePath(this.projectDir); const localChangelogPath = getLocalChangelogFilePath(this.projectDir);
const localChangelog = yield fs.promises.readFile(localChangelogPath, 'utf8'); const localChangelog = yield fs.promises.readFile(localChangelogPath, 'utf8');
// If the extracted release notes do not have any new lines at the end and the const releaseNotesEntry = yield releaseNotes.getChangelogEntry();
// local changelog is not empty, we add lines manually so that there is space yield fs.promises.writeFile(localChangelogPath, `${releaseNotesEntry}\n\n${localChangelog}`);
// between the previous and cherry-picked release notes. info(green(` ✓ Updated the changelog to capture changes for "${releaseNotes.version}".`));
if (!/[\r\n]+$/.test(releaseNotes) && localChangelog !== '') {
releaseNotes = `${releaseNotes}\n\n`;
}
// Prepend the extracted release notes to the local changelog and write it back.
yield fs.promises.writeFile(localChangelogPath, releaseNotes + localChangelog);
return true;
}); });
} }
/** Checks out an upstream branch with a detached head. */ /** Checks out an upstream branch with a detached head. */
@ -6059,25 +6202,6 @@ class ReleaseAction {
this.git.run(['commit', '--no-verify', '-m', message, ...files]); this.git.run(['commit', '--no-verify', '-m', message, ...files]);
}); });
} }
/**
* Creates a cherry-pick commit for the release notes of the specified version that
* has been pushed to the given branch.
* @returns a boolean indicating whether the commit has been created successfully.
*/
createCherryPickReleaseNotesCommitFrom(version, branchName) {
return tslib.__awaiter(this, void 0, void 0, function* () {
const commitMessage = getReleaseNoteCherryPickCommitMessage(version);
// Fetch, extract and prepend the release notes to the local changelog. If that is not
// possible, abort so that we can ask the user to manually cherry-pick the changelog.
if (!(yield this.prependReleaseNotesFromVersionBranch(version, branchName))) {
return false;
}
// Create a changelog cherry-pick commit.
yield this.createCommit(commitMessage, [changelogPath]);
info(green(` ✓ Created changelog cherry-pick commit for: "${version}".`));
return true;
});
}
/** /**
* Stages the specified new version for the current branch and creates a * Stages the specified new version for the current branch and creates a
* pull request that targets the given base branch. * pull request that targets the given base branch.
@ -6085,13 +6209,14 @@ class ReleaseAction {
*/ */
stageVersionForBranchAndCreatePullRequest(newVersion, pullRequestBaseBranch) { stageVersionForBranchAndCreatePullRequest(newVersion, pullRequestBaseBranch) {
return tslib.__awaiter(this, void 0, void 0, function* () { return tslib.__awaiter(this, void 0, void 0, function* () {
const releaseNotes = yield ReleaseNotes.fromLatestTagToHead(newVersion, this.config);
yield this.updateProjectVersion(newVersion); yield this.updateProjectVersion(newVersion);
yield this._generateReleaseNotesForHead(newVersion); yield this.prependReleaseNotesToChangelog(releaseNotes);
yield this.waitForEditsAndCreateReleaseCommit(newVersion); yield this.waitForEditsAndCreateReleaseCommit(newVersion);
const pullRequest = yield this.pushChangesToForkAndCreatePullRequest(pullRequestBaseBranch, `release-stage-${newVersion}`, `Bump version to "v${newVersion}" with changelog.`); const pullRequest = yield this.pushChangesToForkAndCreatePullRequest(pullRequestBaseBranch, `release-stage-${newVersion}`, `Bump version to "v${newVersion}" with changelog.`);
info(green(' ✓ Release staging pull request has been created.')); info(green(' ✓ Release staging pull request has been created.'));
info(yellow(` Please ask team members to review: ${pullRequest.url}.`)); info(yellow(` Please ask team members to review: ${pullRequest.url}.`));
return pullRequest; return { releaseNotes, pullRequest };
}); });
} }
/** /**
@ -6111,21 +6236,18 @@ class ReleaseAction {
* into the `next` primary development branch. A pull request is created for this. * into the `next` primary development branch. A pull request is created for this.
* @returns a boolean indicating successful creation of the cherry-pick pull request. * @returns a boolean indicating successful creation of the cherry-pick pull request.
*/ */
cherryPickChangelogIntoNextBranch(newVersion, stagingBranch) { cherryPickChangelogIntoNextBranch(releaseNotes, stagingBranch) {
return tslib.__awaiter(this, void 0, void 0, function* () { return tslib.__awaiter(this, void 0, void 0, function* () {
const nextBranch = this.active.next.branchName; const nextBranch = this.active.next.branchName;
const commitMessage = getReleaseNoteCherryPickCommitMessage(newVersion); const commitMessage = getReleaseNoteCherryPickCommitMessage(releaseNotes.version);
// Checkout the next branch. // Checkout the next branch.
yield this.checkoutUpstreamBranch(nextBranch); yield this.checkoutUpstreamBranch(nextBranch);
// Cherry-pick the release notes into the current branch. If it fails, yield this.prependReleaseNotesToChangelog(releaseNotes);
// ask the user to manually copy the release notes into the next branch. // Create a changelog cherry-pick commit.
if (!(yield this.createCherryPickReleaseNotesCommitFrom(newVersion, stagingBranch))) { yield this.createCommit(commitMessage, [changelogPath]);
error(yellow(` ✘ Could not cherry-pick release notes for v${newVersion}.`)); info(green(` ✓ Created changelog cherry-pick commit for: "${releaseNotes.version}".`));
error(yellow(` Please copy the release notes manually into the "${nextBranch}" branch.`));
return false;
}
// Create a cherry-pick pull request that should be merged by the caretaker. // Create a cherry-pick pull request that should be merged by the caretaker.
const { url, id } = yield this.pushChangesToForkAndCreatePullRequest(nextBranch, `changelog-cherry-pick-${newVersion}`, commitMessage, `Cherry-picks the changelog from the "${stagingBranch}" branch to the next ` + const { url, id } = yield this.pushChangesToForkAndCreatePullRequest(nextBranch, `changelog-cherry-pick-${releaseNotes.version}`, commitMessage, `Cherry-picks the changelog from the "${stagingBranch}" branch to the next ` +
`branch (${nextBranch}).`); `branch (${nextBranch}).`);
info(green(` ✓ Pull request for cherry-picking the changelog into "${nextBranch}" ` + info(green(` ✓ Pull request for cherry-picking the changelog into "${nextBranch}" ` +
'has been created.')); 'has been created.'));
@ -6253,10 +6375,10 @@ class CutLongTermSupportPatchAction extends ReleaseAction {
return tslib.__awaiter(this, void 0, void 0, function* () { return tslib.__awaiter(this, void 0, void 0, function* () {
const ltsBranch = yield this._promptForTargetLtsBranch(); const ltsBranch = yield this._promptForTargetLtsBranch();
const newVersion = semverInc(ltsBranch.version, 'patch'); const newVersion = semverInc(ltsBranch.version, 'patch');
const { id } = yield this.checkoutBranchAndStageVersion(newVersion, ltsBranch.name); const { pullRequest: { id }, releaseNotes } = yield this.checkoutBranchAndStageVersion(newVersion, ltsBranch.name);
yield this.waitForPullRequestToBeMerged(id); yield this.waitForPullRequestToBeMerged(id);
yield this.buildAndPublish(newVersion, ltsBranch.name, ltsBranch.npmDistTag); yield this.buildAndPublish(newVersion, ltsBranch.name, ltsBranch.npmDistTag);
yield this.cherryPickChangelogIntoNextBranch(newVersion, ltsBranch.name); yield this.cherryPickChangelogIntoNextBranch(releaseNotes, ltsBranch.name);
}); });
} }
/** Prompts the user to select an LTS branch for which a patch should but cut. */ /** Prompts the user to select an LTS branch for which a patch should but cut. */
@ -6330,10 +6452,10 @@ class CutNewPatchAction extends ReleaseAction {
return tslib.__awaiter(this, void 0, void 0, function* () { return tslib.__awaiter(this, void 0, void 0, function* () {
const { branchName } = this.active.latest; const { branchName } = this.active.latest;
const newVersion = this._newVersion; const newVersion = this._newVersion;
const { id } = yield this.checkoutBranchAndStageVersion(newVersion, branchName); const { pullRequest: { id }, releaseNotes } = yield this.checkoutBranchAndStageVersion(newVersion, branchName);
yield this.waitForPullRequestToBeMerged(id); yield this.waitForPullRequestToBeMerged(id);
yield this.buildAndPublish(newVersion, branchName, 'latest'); yield this.buildAndPublish(newVersion, branchName, 'latest');
yield this.cherryPickChangelogIntoNextBranch(newVersion, branchName); yield this.cherryPickChangelogIntoNextBranch(releaseNotes, branchName);
}); });
} }
static isActive(active) { static isActive(active) {
@ -6400,14 +6522,14 @@ class CutNextPrereleaseAction extends ReleaseAction {
const releaseTrain = this._getActivePrereleaseTrain(); const releaseTrain = this._getActivePrereleaseTrain();
const { branchName } = releaseTrain; const { branchName } = releaseTrain;
const newVersion = yield this._newVersion; const newVersion = yield this._newVersion;
const { id } = yield this.checkoutBranchAndStageVersion(newVersion, branchName); const { pullRequest: { id }, releaseNotes } = yield this.checkoutBranchAndStageVersion(newVersion, branchName);
yield this.waitForPullRequestToBeMerged(id); yield this.waitForPullRequestToBeMerged(id);
yield this.buildAndPublish(newVersion, branchName, 'next'); yield this.buildAndPublish(newVersion, branchName, 'next');
// If the pre-release has been cut from a branch that is not corresponding // If the pre-release has been cut from a branch that is not corresponding
// to the next release-train, cherry-pick the changelog into the primary // to the next release-train, cherry-pick the changelog into the primary
// development branch. i.e. the `next` branch that is usually `master`. // development branch. i.e. the `next` branch that is usually `master`.
if (releaseTrain !== this.active.next) { if (releaseTrain !== this.active.next) {
yield this.cherryPickChangelogIntoNextBranch(newVersion, branchName); yield this.cherryPickChangelogIntoNextBranch(releaseNotes, branchName);
} }
}); });
} }
@ -6467,10 +6589,10 @@ class CutReleaseCandidateAction extends ReleaseAction {
return tslib.__awaiter(this, void 0, void 0, function* () { return tslib.__awaiter(this, void 0, void 0, function* () {
const { branchName } = this.active.releaseCandidate; const { branchName } = this.active.releaseCandidate;
const newVersion = this._newVersion; const newVersion = this._newVersion;
const { id } = yield this.checkoutBranchAndStageVersion(newVersion, branchName); const { pullRequest: { id }, releaseNotes } = yield this.checkoutBranchAndStageVersion(newVersion, branchName);
yield this.waitForPullRequestToBeMerged(id); yield this.waitForPullRequestToBeMerged(id);
yield this.buildAndPublish(newVersion, branchName, 'next'); yield this.buildAndPublish(newVersion, branchName, 'next');
yield this.cherryPickChangelogIntoNextBranch(newVersion, branchName); yield this.cherryPickChangelogIntoNextBranch(releaseNotes, branchName);
}); });
} }
static isActive(active) { static isActive(active) {
@ -6511,7 +6633,7 @@ class CutStableAction extends ReleaseAction {
const { branchName } = this.active.releaseCandidate; const { branchName } = this.active.releaseCandidate;
const newVersion = this._newVersion; const newVersion = this._newVersion;
const isNewMajor = (_a = this.active.releaseCandidate) === null || _a === void 0 ? void 0 : _a.isMajor; const isNewMajor = (_a = this.active.releaseCandidate) === null || _a === void 0 ? void 0 : _a.isMajor;
const { id } = yield this.checkoutBranchAndStageVersion(newVersion, branchName); const { pullRequest: { id }, releaseNotes } = yield this.checkoutBranchAndStageVersion(newVersion, branchName);
yield this.waitForPullRequestToBeMerged(id); yield this.waitForPullRequestToBeMerged(id);
yield this.buildAndPublish(newVersion, branchName, 'latest'); yield this.buildAndPublish(newVersion, branchName, 'latest');
// If a new major version is published and becomes the "latest" release-train, we need // If a new major version is published and becomes the "latest" release-train, we need
@ -6529,7 +6651,7 @@ class CutStableAction extends ReleaseAction {
yield invokeYarnInstallCommand(this.projectDir); yield invokeYarnInstallCommand(this.projectDir);
yield invokeSetNpmDistCommand(ltsTagForPatch, previousPatch.version); yield invokeSetNpmDistCommand(ltsTagForPatch, previousPatch.version);
} }
yield this.cherryPickChangelogIntoNextBranch(newVersion, branchName); yield this.cherryPickChangelogIntoNextBranch(releaseNotes, branchName);
}); });
} }
/** Gets the new stable version of the release candidate release-train. */ /** Gets the new stable version of the release candidate release-train. */
@ -6581,13 +6703,13 @@ class MoveNextIntoFeatureFreezeAction extends ReleaseAction {
// Stage the new version for the newly created branch, and push changes to a // Stage the new version for the newly created branch, and push changes to a
// fork in order to create a staging pull request. Note that we re-use the newly // fork in order to create a staging pull request. Note that we re-use the newly
// created branch instead of re-fetching from the upstream. // created branch instead of re-fetching from the upstream.
const stagingPullRequest = yield this.stageVersionForBranchAndCreatePullRequest(newVersion, newBranch); const { pullRequest: { id }, releaseNotes } = yield this.stageVersionForBranchAndCreatePullRequest(newVersion, newBranch);
// Wait for the staging PR to be merged. Then build and publish the feature-freeze next // Wait for the staging PR to be merged. Then build and publish the feature-freeze next
// pre-release. Finally, cherry-pick the release notes into the next branch in combination // pre-release. Finally, cherry-pick the release notes into the next branch in combination
// with bumping the version to the next minor too. // with bumping the version to the next minor too.
yield this.waitForPullRequestToBeMerged(stagingPullRequest.id); yield this.waitForPullRequestToBeMerged(id);
yield this.buildAndPublish(newVersion, newBranch, 'next'); yield this.buildAndPublish(newVersion, newBranch, 'next');
yield this._createNextBranchUpdatePullRequest(newVersion, newBranch); yield this._createNextBranchUpdatePullRequest(releaseNotes, newVersion);
}); });
} }
/** Creates a new version branch from the next branch. */ /** Creates a new version branch from the next branch. */
@ -6605,7 +6727,7 @@ class MoveNextIntoFeatureFreezeAction extends ReleaseAction {
* Creates a pull request for the next branch that bumps the version to the next * Creates a pull request for the next branch that bumps the version to the next
* minor, and cherry-picks the changelog for the newly branched-off feature-freeze version. * minor, and cherry-picks the changelog for the newly branched-off feature-freeze version.
*/ */
_createNextBranchUpdatePullRequest(newVersion, newBranch) { _createNextBranchUpdatePullRequest(releaseNotes, newVersion) {
return tslib.__awaiter(this, void 0, void 0, function* () { return tslib.__awaiter(this, void 0, void 0, function* () {
const { branchName: nextBranch, version } = this.active.next; const { branchName: nextBranch, version } = this.active.next;
// We increase the version for the next branch to the next minor. The team can decide // We increase the version for the next branch to the next minor. The team can decide
@ -6617,18 +6739,13 @@ class MoveNextIntoFeatureFreezeAction extends ReleaseAction {
// Create an individual commit for the next version bump. The changelog should go into // Create an individual commit for the next version bump. The changelog should go into
// a separate commit that makes it clear where the changelog is cherry-picked from. // a separate commit that makes it clear where the changelog is cherry-picked from.
yield this.createCommit(bumpCommitMessage, [packageJsonPath]); yield this.createCommit(bumpCommitMessage, [packageJsonPath]);
yield this.prependReleaseNotesToChangelog(releaseNotes);
const commitMessage = getReleaseNoteCherryPickCommitMessage(releaseNotes.version);
yield this.createCommit(commitMessage, [changelogPath]);
let nextPullRequestMessage = `The previous "next" release-train has moved into the ` + let nextPullRequestMessage = `The previous "next" release-train has moved into the ` +
`release-candidate phase. This PR updates the next branch to the subsequent ` + `release-candidate phase. This PR updates the next branch to the subsequent ` +
`release-train.`; `release-train.\n\nAlso this PR cherry-picks the changelog for ` +
const hasChangelogCherryPicked = yield this.createCherryPickReleaseNotesCommitFrom(newVersion, newBranch); `v${newVersion} into the ${nextBranch} branch so that the changelog is up to date.`;
if (hasChangelogCherryPicked) {
nextPullRequestMessage += `\n\nAlso this PR cherry-picks the changelog for ` +
`v${newVersion} into the ${nextBranch} branch so that the changelog is up to date.`;
}
else {
error(yellow(` ✘ Could not cherry-pick release notes for v${newVersion}.`));
error(yellow(` Please copy the release note manually into "${nextBranch}".`));
}
const nextUpdatePullRequest = yield this.pushChangesToForkAndCreatePullRequest(nextBranch, `next-release-train-${newNextVersion}`, `Update next branch to reflect new release-train "v${newNextVersion}".`, nextPullRequestMessage); const nextUpdatePullRequest = yield this.pushChangesToForkAndCreatePullRequest(nextBranch, `next-release-train-${newNextVersion}`, `Update next branch to reflect new release-train "v${newNextVersion}".`, nextPullRequestMessage);
info(green(` ✓ Pull request for updating the "${nextBranch}" branch has been created.`)); info(green(` ✓ Pull request for updating the "${nextBranch}" branch has been created.`));
info(yellow(` Please ask team members to review: ${nextUpdatePullRequest.url}.`)); info(yellow(` Please ask team members to review: ${nextUpdatePullRequest.url}.`));

View File

@ -31,7 +31,7 @@ describe('default target labels', () => {
releaseConfig = { releaseConfig = {
npmPackages: ['@angular/dev-infra-test-pkg'], npmPackages: ['@angular/dev-infra-test-pkg'],
buildPackages: async () => [], buildPackages: async () => [],
generateReleaseNotesForHead: async () => {}, releaseNotes: {}
}; };
// The label determination will print warn messages. These should not be // The label determination will print warn messages. These should not be

View File

@ -32,7 +32,7 @@ describe('ng-dev release build', () => {
/** Invokes the build command handler. */ /** Invokes the build command handler. */
async function invokeBuild({json}: {json?: boolean} = {}) { async function invokeBuild({json}: {json?: boolean} = {}) {
spyOn(releaseConfig, 'getReleaseConfig') spyOn(releaseConfig, 'getReleaseConfig')
.and.returnValue({npmPackages, buildPackages, generateReleaseNotesForHead: async () => {}}); .and.returnValue({npmPackages, buildPackages, releaseNotes: {}});
await ReleaseBuildCommandModule.handler({json: !!json, $0: '', _: []}); await ReleaseBuildCommandModule.handler({json: !!json, $0: '', _: []});
} }

View File

@ -26,20 +26,10 @@ export interface ReleaseConfig {
npmPackages: string[]; npmPackages: string[];
/** Builds release packages and returns a list of paths pointing to the output. */ /** Builds release packages and returns a list of paths pointing to the output. */
buildPackages: () => Promise<BuiltPackage[]|null>; buildPackages: () => Promise<BuiltPackage[]|null>;
/** Generates the release notes from the most recent tag to `HEAD`. */
generateReleaseNotesForHead: (outputPath: string) => Promise<void>;
/**
* Gets a pattern for extracting the release notes of the a given version.
* @returns A pattern matching the notes for a given version (including the header).
*/
// TODO: Remove this in favor of a canonical changelog format across the Angular organization.
extractReleaseNotesPattern?: (version: semver.SemVer) => RegExp;
/** The list of github labels to add to the release PRs. */ /** The list of github labels to add to the release PRs. */
releasePrLabels?: string[]; releasePrLabels?: string[];
/** Configuration for creating release notes during publishing. */ /** Configuration for creating release notes during publishing. */
// TODO(josephperrott): Make releaseNotes a required attribute on the interface when tooling is releaseNotes: ReleaseNotesConfig;
// integrated.
releaseNotes?: ReleaseNotesConfig;
} }
/** Configuration for creating release notes during publishing. */ /** Configuration for creating release notes during publishing. */
@ -75,8 +65,8 @@ export function getReleaseConfig(config: Partial<DevInfraReleaseConfig> = getCon
if (config.release?.buildPackages === undefined) { if (config.release?.buildPackages === undefined) {
errors.push(`No "buildPackages" function configured for releasing.`); errors.push(`No "buildPackages" function configured for releasing.`);
} }
if (config.release?.generateReleaseNotesForHead === undefined) { if (config.release?.releaseNotes === undefined) {
errors.push(`No "generateReleaseNotesForHead" function configured for releasing.`); errors.push(`No "releaseNotes" configured for releasing.`);
} }
assertNoErrors(errors); assertNoErrors(errors);

View File

@ -24,7 +24,7 @@ import {changelogPath, packageJsonPath, waitForPullRequestInterval} from './cons
import {invokeBazelCleanCommand, invokeReleaseBuildCommand, invokeYarnInstallCommand} from './external-commands'; import {invokeBazelCleanCommand, invokeReleaseBuildCommand, invokeYarnInstallCommand} from './external-commands';
import {findOwnedForksOfRepoQuery} from './graphql-queries'; import {findOwnedForksOfRepoQuery} from './graphql-queries';
import {getPullRequestState} from './pull-request-state'; import {getPullRequestState} from './pull-request-state';
import {getDefaultExtractReleaseNotesPattern, getLocalChangelogFilePath} from './release-notes/release-notes'; import {getLocalChangelogFilePath, ReleaseNotes} from './release-notes/release-notes';
/** Interface describing a Github repository. */ /** Interface describing a Github repository. */
export interface GithubRepo { export interface GithubRepo {
@ -132,22 +132,6 @@ export abstract class ReleaseAction {
info(green(' ✓ Upstream commit is passing all github status checks.')); info(green(' ✓ Upstream commit is passing all github status checks.'));
} }
/** Generates the changelog for the specified for the current `HEAD`. */
private async _generateReleaseNotesForHead(version: semver.SemVer) {
const changelogPath = getLocalChangelogFilePath(this.projectDir);
await this.config.generateReleaseNotesForHead(changelogPath);
info(green(` ✓ Updated the changelog to capture changes for "${version}".`));
}
/** Extract the release notes for the given version from the changelog file. */
private _extractReleaseNotesForVersion(changelogContent: string, version: semver.SemVer): string
|null {
const pattern = this.config.extractReleaseNotesPattern !== undefined ?
this.config.extractReleaseNotesPattern(version) :
getDefaultExtractReleaseNotesPattern(version);
const matchedNotes = pattern.exec(changelogContent);
return matchedNotes === null ? null : matchedNotes[1];
}
/** /**
* Prompts the user for potential release notes edits that need to be made. Once * Prompts the user for potential release notes edits that need to be made. Once
@ -334,28 +318,12 @@ export abstract class ReleaseAction {
* the current Git `HEAD`. This is useful for cherry-picking the changelog. * the current Git `HEAD`. This is useful for cherry-picking the changelog.
* @returns A boolean indicating whether the release notes have been prepended. * @returns A boolean indicating whether the release notes have been prepended.
*/ */
protected async prependReleaseNotesFromVersionBranch( protected async prependReleaseNotesToChangelog(releaseNotes: ReleaseNotes): Promise<void> {
version: semver.SemVer, containingBranch: string): Promise<boolean> {
const {data} = await this.git.github.repos.getContents(
{...this.git.remoteParams, path: '/' + changelogPath, ref: containingBranch});
const branchChangelog = Buffer.from(data.content, 'base64').toString();
let releaseNotes = this._extractReleaseNotesForVersion(branchChangelog, version);
// If no release notes could be extracted, return "false" so that the caller
// can tell that changelog prepending failed.
if (releaseNotes === null) {
return false;
}
const localChangelogPath = getLocalChangelogFilePath(this.projectDir); const localChangelogPath = getLocalChangelogFilePath(this.projectDir);
const localChangelog = await fs.readFile(localChangelogPath, 'utf8'); const localChangelog = await fs.readFile(localChangelogPath, 'utf8');
// If the extracted release notes do not have any new lines at the end and the const releaseNotesEntry = await releaseNotes.getChangelogEntry();
// local changelog is not empty, we add lines manually so that there is space await fs.writeFile(localChangelogPath, `${releaseNotesEntry}\n\n${localChangelog}`);
// between the previous and cherry-picked release notes. info(green(` ✓ Updated the changelog to capture changes for "${releaseNotes.version}".`));
if (!/[\r\n]+$/.test(releaseNotes) && localChangelog !== '') {
releaseNotes = `${releaseNotes}\n\n`;
}
// Prepend the extracted release notes to the local changelog and write it back.
await fs.writeFile(localChangelogPath, releaseNotes + localChangelog);
return true;
} }
/** Checks out an upstream branch with a detached head. */ /** Checks out an upstream branch with a detached head. */
@ -373,27 +341,6 @@ export abstract class ReleaseAction {
this.git.run(['commit', '--no-verify', '-m', message, ...files]); this.git.run(['commit', '--no-verify', '-m', message, ...files]);
} }
/**
* Creates a cherry-pick commit for the release notes of the specified version that
* has been pushed to the given branch.
* @returns a boolean indicating whether the commit has been created successfully.
*/
protected async createCherryPickReleaseNotesCommitFrom(
version: semver.SemVer, branchName: string): Promise<boolean> {
const commitMessage = getReleaseNoteCherryPickCommitMessage(version);
// Fetch, extract and prepend the release notes to the local changelog. If that is not
// possible, abort so that we can ask the user to manually cherry-pick the changelog.
if (!await this.prependReleaseNotesFromVersionBranch(version, branchName)) {
return false;
}
// Create a changelog cherry-pick commit.
await this.createCommit(commitMessage, [changelogPath]);
info(green(` ✓ Created changelog cherry-pick commit for: "${version}".`));
return true;
}
/** /**
* Stages the specified new version for the current branch and creates a * Stages the specified new version for the current branch and creates a
@ -401,9 +348,11 @@ export abstract class ReleaseAction {
* @returns an object describing the created pull request. * @returns an object describing the created pull request.
*/ */
protected async stageVersionForBranchAndCreatePullRequest( protected async stageVersionForBranchAndCreatePullRequest(
newVersion: semver.SemVer, pullRequestBaseBranch: string): Promise<PullRequest> { newVersion: semver.SemVer, pullRequestBaseBranch: string):
Promise<{releaseNotes: ReleaseNotes, pullRequest: PullRequest}> {
const releaseNotes = await ReleaseNotes.fromLatestTagToHead(newVersion, this.config);
await this.updateProjectVersion(newVersion); await this.updateProjectVersion(newVersion);
await this._generateReleaseNotesForHead(newVersion); await this.prependReleaseNotesToChangelog(releaseNotes);
await this.waitForEditsAndCreateReleaseCommit(newVersion); await this.waitForEditsAndCreateReleaseCommit(newVersion);
const pullRequest = await this.pushChangesToForkAndCreatePullRequest( const pullRequest = await this.pushChangesToForkAndCreatePullRequest(
@ -413,7 +362,7 @@ export abstract class ReleaseAction {
info(green(' ✓ Release staging pull request has been created.')); info(green(' ✓ Release staging pull request has been created.'));
info(yellow(` Please ask team members to review: ${pullRequest.url}.`)); info(yellow(` Please ask team members to review: ${pullRequest.url}.`));
return pullRequest; return {releaseNotes, pullRequest};
} }
/** /**
@ -422,7 +371,7 @@ export abstract class ReleaseAction {
* @returns an object describing the created pull request. * @returns an object describing the created pull request.
*/ */
protected async checkoutBranchAndStageVersion(newVersion: semver.SemVer, stagingBranch: string): protected async checkoutBranchAndStageVersion(newVersion: semver.SemVer, stagingBranch: string):
Promise<PullRequest> { Promise<{releaseNotes: ReleaseNotes, pullRequest: PullRequest}> {
await this.verifyPassingGithubStatus(stagingBranch); await this.verifyPassingGithubStatus(stagingBranch);
await this.checkoutUpstreamBranch(stagingBranch); await this.checkoutUpstreamBranch(stagingBranch);
return await this.stageVersionForBranchAndCreatePullRequest(newVersion, stagingBranch); return await this.stageVersionForBranchAndCreatePullRequest(newVersion, stagingBranch);
@ -434,25 +383,22 @@ export abstract class ReleaseAction {
* @returns a boolean indicating successful creation of the cherry-pick pull request. * @returns a boolean indicating successful creation of the cherry-pick pull request.
*/ */
protected async cherryPickChangelogIntoNextBranch( protected async cherryPickChangelogIntoNextBranch(
newVersion: semver.SemVer, stagingBranch: string): Promise<boolean> { releaseNotes: ReleaseNotes, stagingBranch: string): Promise<boolean> {
const nextBranch = this.active.next.branchName; const nextBranch = this.active.next.branchName;
const commitMessage = getReleaseNoteCherryPickCommitMessage(newVersion); const commitMessage = getReleaseNoteCherryPickCommitMessage(releaseNotes.version);
// Checkout the next branch. // Checkout the next branch.
await this.checkoutUpstreamBranch(nextBranch); await this.checkoutUpstreamBranch(nextBranch);
// Cherry-pick the release notes into the current branch. If it fails, await this.prependReleaseNotesToChangelog(releaseNotes);
// ask the user to manually copy the release notes into the next branch.
if (!await this.createCherryPickReleaseNotesCommitFrom(newVersion, stagingBranch)) { // Create a changelog cherry-pick commit.
error(yellow(` ✘ Could not cherry-pick release notes for v${newVersion}.`)); await this.createCommit(commitMessage, [changelogPath]);
error( info(green(` ✓ Created changelog cherry-pick commit for: "${releaseNotes.version}".`));
yellow(` Please copy the release notes manually into the "${nextBranch}" branch.`));
return false;
}
// Create a cherry-pick pull request that should be merged by the caretaker. // Create a cherry-pick pull request that should be merged by the caretaker.
const {url, id} = await this.pushChangesToForkAndCreatePullRequest( const {url, id} = await this.pushChangesToForkAndCreatePullRequest(
nextBranch, `changelog-cherry-pick-${newVersion}`, commitMessage, nextBranch, `changelog-cherry-pick-${releaseNotes.version}`, commitMessage,
`Cherry-picks the changelog from the "${stagingBranch}" branch to the next ` + `Cherry-picks the changelog from the "${stagingBranch}" branch to the next ` +
`branch (${nextBranch}).`); `branch (${nextBranch}).`);

View File

@ -41,11 +41,12 @@ export class CutLongTermSupportPatchAction extends ReleaseAction {
async perform() { async perform() {
const ltsBranch = await this._promptForTargetLtsBranch(); const ltsBranch = await this._promptForTargetLtsBranch();
const newVersion = semverInc(ltsBranch.version, 'patch'); const newVersion = semverInc(ltsBranch.version, 'patch');
const {id} = await this.checkoutBranchAndStageVersion(newVersion, ltsBranch.name); const {pullRequest: {id}, releaseNotes} =
await this.checkoutBranchAndStageVersion(newVersion, ltsBranch.name);
await this.waitForPullRequestToBeMerged(id); await this.waitForPullRequestToBeMerged(id);
await this.buildAndPublish(newVersion, ltsBranch.name, ltsBranch.npmDistTag); await this.buildAndPublish(newVersion, ltsBranch.name, ltsBranch.npmDistTag);
await this.cherryPickChangelogIntoNextBranch(newVersion, ltsBranch.name); await this.cherryPickChangelogIntoNextBranch(releaseNotes, ltsBranch.name);
} }
/** Prompts the user to select an LTS branch for which a patch should but cut. */ /** Prompts the user to select an LTS branch for which a patch should but cut. */

View File

@ -28,11 +28,12 @@ export class CutNewPatchAction extends ReleaseAction {
const {branchName} = this.active.latest; const {branchName} = this.active.latest;
const newVersion = this._newVersion; const newVersion = this._newVersion;
const {id} = await this.checkoutBranchAndStageVersion(newVersion, branchName); const {pullRequest: {id}, releaseNotes} =
await this.checkoutBranchAndStageVersion(newVersion, branchName);
await this.waitForPullRequestToBeMerged(id); await this.waitForPullRequestToBeMerged(id);
await this.buildAndPublish(newVersion, branchName, 'latest'); await this.buildAndPublish(newVersion, branchName, 'latest');
await this.cherryPickChangelogIntoNextBranch(newVersion, branchName); await this.cherryPickChangelogIntoNextBranch(releaseNotes, branchName);
} }
static async isActive(active: ActiveReleaseTrains) { static async isActive(active: ActiveReleaseTrains) {

View File

@ -32,7 +32,8 @@ export class CutNextPrereleaseAction extends ReleaseAction {
const {branchName} = releaseTrain; const {branchName} = releaseTrain;
const newVersion = await this._newVersion; const newVersion = await this._newVersion;
const {id} = await this.checkoutBranchAndStageVersion(newVersion, branchName); const {pullRequest: {id}, releaseNotes} =
await this.checkoutBranchAndStageVersion(newVersion, branchName);
await this.waitForPullRequestToBeMerged(id); await this.waitForPullRequestToBeMerged(id);
await this.buildAndPublish(newVersion, branchName, 'next'); await this.buildAndPublish(newVersion, branchName, 'next');
@ -41,7 +42,7 @@ export class CutNextPrereleaseAction extends ReleaseAction {
// to the next release-train, cherry-pick the changelog into the primary // to the next release-train, cherry-pick the changelog into the primary
// development branch. i.e. the `next` branch that is usually `master`. // development branch. i.e. the `next` branch that is usually `master`.
if (releaseTrain !== this.active.next) { if (releaseTrain !== this.active.next) {
await this.cherryPickChangelogIntoNextBranch(newVersion, branchName); await this.cherryPickChangelogIntoNextBranch(releaseNotes, branchName);
} }
} }

View File

@ -26,11 +26,12 @@ export class CutReleaseCandidateAction extends ReleaseAction {
const {branchName} = this.active.releaseCandidate!; const {branchName} = this.active.releaseCandidate!;
const newVersion = this._newVersion; const newVersion = this._newVersion;
const {id} = await this.checkoutBranchAndStageVersion(newVersion, branchName); const {pullRequest: {id}, releaseNotes} =
await this.checkoutBranchAndStageVersion(newVersion, branchName);
await this.waitForPullRequestToBeMerged(id); await this.waitForPullRequestToBeMerged(id);
await this.buildAndPublish(newVersion, branchName, 'next'); await this.buildAndPublish(newVersion, branchName, 'next');
await this.cherryPickChangelogIntoNextBranch(newVersion, branchName); await this.cherryPickChangelogIntoNextBranch(releaseNotes, branchName);
} }
static async isActive(active: ActiveReleaseTrains) { static async isActive(active: ActiveReleaseTrains) {

View File

@ -31,7 +31,8 @@ export class CutStableAction extends ReleaseAction {
const isNewMajor = this.active.releaseCandidate?.isMajor; const isNewMajor = this.active.releaseCandidate?.isMajor;
const {id} = await this.checkoutBranchAndStageVersion(newVersion, branchName); const {pullRequest: {id}, releaseNotes} =
await this.checkoutBranchAndStageVersion(newVersion, branchName);
await this.waitForPullRequestToBeMerged(id); await this.waitForPullRequestToBeMerged(id);
await this.buildAndPublish(newVersion, branchName, 'latest'); await this.buildAndPublish(newVersion, branchName, 'latest');
@ -53,7 +54,7 @@ export class CutStableAction extends ReleaseAction {
await invokeSetNpmDistCommand(ltsTagForPatch, previousPatch.version); await invokeSetNpmDistCommand(ltsTagForPatch, previousPatch.version);
} }
await this.cherryPickChangelogIntoNextBranch(newVersion, branchName); await this.cherryPickChangelogIntoNextBranch(releaseNotes, branchName);
} }
/** Gets the new stable version of the release candidate release-train. */ /** Gets the new stable version of the release candidate release-train. */

View File

@ -8,12 +8,13 @@
import * as semver from 'semver'; import * as semver from 'semver';
import {error, green, info, yellow} from '../../../utils/console'; import {green, info, yellow} from '../../../utils/console';
import {ActiveReleaseTrains} from '../../versioning/active-release-trains'; import {ActiveReleaseTrains} from '../../versioning/active-release-trains';
import {computeNewPrereleaseVersionForNext} from '../../versioning/next-prerelease-version'; import {computeNewPrereleaseVersionForNext} from '../../versioning/next-prerelease-version';
import {ReleaseAction} from '../actions'; import {ReleaseAction} from '../actions';
import {getCommitMessageForExceptionalNextVersionBump} from '../commit-message'; import {getCommitMessageForExceptionalNextVersionBump, getReleaseNoteCherryPickCommitMessage} from '../commit-message';
import {packageJsonPath} from '../constants'; import {changelogPath, packageJsonPath} from '../constants';
import {ReleaseNotes} from '../release-notes/release-notes';
/** /**
* Release action that moves the next release-train into the feature-freeze phase. This means * Release action that moves the next release-train into the feature-freeze phase. This means
@ -39,15 +40,15 @@ export class MoveNextIntoFeatureFreezeAction extends ReleaseAction {
// Stage the new version for the newly created branch, and push changes to a // Stage the new version for the newly created branch, and push changes to a
// fork in order to create a staging pull request. Note that we re-use the newly // fork in order to create a staging pull request. Note that we re-use the newly
// created branch instead of re-fetching from the upstream. // created branch instead of re-fetching from the upstream.
const stagingPullRequest = const {pullRequest: {id}, releaseNotes} =
await this.stageVersionForBranchAndCreatePullRequest(newVersion, newBranch); await this.stageVersionForBranchAndCreatePullRequest(newVersion, newBranch);
// Wait for the staging PR to be merged. Then build and publish the feature-freeze next // Wait for the staging PR to be merged. Then build and publish the feature-freeze next
// pre-release. Finally, cherry-pick the release notes into the next branch in combination // pre-release. Finally, cherry-pick the release notes into the next branch in combination
// with bumping the version to the next minor too. // with bumping the version to the next minor too.
await this.waitForPullRequestToBeMerged(stagingPullRequest.id); await this.waitForPullRequestToBeMerged(id);
await this.buildAndPublish(newVersion, newBranch, 'next'); await this.buildAndPublish(newVersion, newBranch, 'next');
await this._createNextBranchUpdatePullRequest(newVersion, newBranch); await this._createNextBranchUpdatePullRequest(releaseNotes, newVersion);
} }
/** Creates a new version branch from the next branch. */ /** Creates a new version branch from the next branch. */
@ -64,7 +65,8 @@ export class MoveNextIntoFeatureFreezeAction extends ReleaseAction {
* Creates a pull request for the next branch that bumps the version to the next * Creates a pull request for the next branch that bumps the version to the next
* minor, and cherry-picks the changelog for the newly branched-off feature-freeze version. * minor, and cherry-picks the changelog for the newly branched-off feature-freeze version.
*/ */
private async _createNextBranchUpdatePullRequest(newVersion: semver.SemVer, newBranch: string) { private async _createNextBranchUpdatePullRequest(
releaseNotes: ReleaseNotes, newVersion: semver.SemVer) {
const {branchName: nextBranch, version} = this.active.next; const {branchName: nextBranch, version} = this.active.next;
// We increase the version for the next branch to the next minor. The team can decide // We increase the version for the next branch to the next minor. The team can decide
// later if they want next to be a major through the `Configure Next as Major` release action. // later if they want next to be a major through the `Configure Next as Major` release action.
@ -78,19 +80,16 @@ export class MoveNextIntoFeatureFreezeAction extends ReleaseAction {
// a separate commit that makes it clear where the changelog is cherry-picked from. // a separate commit that makes it clear where the changelog is cherry-picked from.
await this.createCommit(bumpCommitMessage, [packageJsonPath]); await this.createCommit(bumpCommitMessage, [packageJsonPath]);
await this.prependReleaseNotesToChangelog(releaseNotes);
const commitMessage = getReleaseNoteCherryPickCommitMessage(releaseNotes.version);
await this.createCommit(commitMessage, [changelogPath]);
let nextPullRequestMessage = `The previous "next" release-train has moved into the ` + let nextPullRequestMessage = `The previous "next" release-train has moved into the ` +
`release-candidate phase. This PR updates the next branch to the subsequent ` + `release-candidate phase. This PR updates the next branch to the subsequent ` +
`release-train.`; `release-train.\n\nAlso this PR cherry-picks the changelog for ` +
const hasChangelogCherryPicked = `v${newVersion} into the ${nextBranch} branch so that the changelog is up to date.`;
await this.createCherryPickReleaseNotesCommitFrom(newVersion, newBranch);
if (hasChangelogCherryPicked) {
nextPullRequestMessage += `\n\nAlso this PR cherry-picks the changelog for ` +
`v${newVersion} into the ${nextBranch} branch so that the changelog is up to date.`;
} else {
error(yellow(` ✘ Could not cherry-pick release notes for v${newVersion}.`));
error(yellow(` Please copy the release note manually into "${nextBranch}".`));
}
const nextUpdatePullRequest = await this.pushChangesToForkAndCreatePullRequest( const nextUpdatePullRequest = await this.pushChangesToForkAndCreatePullRequest(
nextBranch, `next-release-train-${newNextVersion}`, nextBranch, `next-release-train-${newNextVersion}`,

View File

@ -10,25 +10,12 @@ import {join} from 'path';
import * as semver from 'semver'; import * as semver from 'semver';
import {getCommitsInRange} from '../../../commit-message/utils'; import {getCommitsInRange} from '../../../commit-message/utils';
import {getConfig} from '../../../utils/config';
import {promptInput} from '../../../utils/console'; import {promptInput} from '../../../utils/console';
import {GitClient} from '../../../utils/git/index'; import {GitClient} from '../../../utils/git/index';
import {getReleaseConfig} from '../../config/index'; import {ReleaseConfig} from '../../config/index';
import {changelogPath} from '../constants'; import {changelogPath} from '../constants';
import {RenderContext} from './context'; import {RenderContext} from './context';
/**
* Gets the default pattern for extracting release notes for the given version.
* This pattern matches for the conventional-changelog Angular preset.
*/
export function getDefaultExtractReleaseNotesPattern(version: semver.SemVer): RegExp {
const escapedVersion = version.format().replace('.', '\\.');
// TODO: Change this once we have a canonical changelog generation tool. Also update this
// based on the conventional-changelog version. They removed anchors in more recent versions.
return new RegExp(`(<a name="${escapedVersion}"></a>.*?)(?:<a name="|$)`, 's');
}
/** Gets the path for the changelog file in a given project. */ /** Gets the path for the changelog file in a given project. */
export function getLocalChangelogFilePath(projectDir: string): string { export function getLocalChangelogFilePath(projectDir: string): string {
return join(projectDir, changelogPath); return join(projectDir, changelogPath);
@ -37,33 +24,35 @@ export function getLocalChangelogFilePath(projectDir: string): string {
/** Release note generation. */ /** Release note generation. */
export class ReleaseNotes { export class ReleaseNotes {
/** Construct a release note generation instance. */
static async fromLatestTagToHead(version: semver.SemVer, config: ReleaseConfig):
Promise<ReleaseNotes> {
return new ReleaseNotes(version, config);
}
/** An instance of GitClient. */ /** An instance of GitClient. */
private git = GitClient.getInstance(); private git = GitClient.getInstance();
/** The github configuration. */
private readonly github = getConfig().github;
/** The configuration for the release notes generation. */
// TODO(josephperrott): Remove non-null assertion after usage of ReleaseNotes is integrated into
// release publish tooling.
private readonly config = getReleaseConfig().releaseNotes! || {};
/** A promise resolving to a list of Commits since the latest semver tag on the branch. */
private commits = getCommitsInRange(this.git.getLatestSemverTag().format(), 'HEAD');
/** The RenderContext to be used during rendering. */ /** The RenderContext to be used during rendering. */
private renderContext: RenderContext|undefined; private renderContext: RenderContext|undefined;
/** The title to use for the release. */ /** The title to use for the release. */
private title: string|false|undefined; private title: string|false|undefined;
/** A promise resolving to a list of Commits since the latest semver tag on the branch. */
private commits = getCommitsInRange(this.git.getLatestSemverTag().format(), 'HEAD');
constructor(private version: semver.SemVer) {} private constructor(public readonly version: semver.SemVer, private config: ReleaseConfig) {}
/** Retrieve the release note generated for a Github Release. */ /** Retrieve the release note generated for a Github Release. */
async getGithubReleaseEntry(): Promise<string> { async getGithubReleaseEntry(): Promise<string> {
return await renderFile( return renderFile(
join(__dirname, 'templates/github-release.ejs'), await this.generateRenderContext()); join(__dirname, 'templates/github-release.ejs'), await this.generateRenderContext(),
{rmWhitespace: true});
} }
/** Retrieve the release note generated for a CHANGELOG entry. */ /** Retrieve the release note generated for a CHANGELOG entry. */
async getChangelogEntry() { async getChangelogEntry() {
return await renderFile( return renderFile(
join(__dirname, 'templates/changelog.ejs'), await this.generateRenderContext()); join(__dirname, 'templates/changelog.ejs'), await this.generateRenderContext(),
{rmWhitespace: true});
} }
/** /**
@ -72,7 +61,7 @@ export class ReleaseNotes {
*/ */
async promptForReleaseTitle() { async promptForReleaseTitle() {
if (this.title === undefined) { if (this.title === undefined) {
if (this.config.useReleaseTitle) { if (this.config.releaseNotes.useReleaseTitle) {
this.title = await promptInput('Please provide a title for the release:'); this.title = await promptInput('Please provide a title for the release:');
} else { } else {
this.title = false; this.title = false;
@ -86,10 +75,10 @@ export class ReleaseNotes {
if (!this.renderContext) { if (!this.renderContext) {
this.renderContext = new RenderContext({ this.renderContext = new RenderContext({
commits: await this.commits, commits: await this.commits,
github: getConfig().github, github: this.git.remoteConfig,
version: this.version.format(), version: this.version.format(),
groupOrder: this.config.groupOrder, groupOrder: this.config.releaseNotes.groupOrder,
hiddenScopes: this.config.hiddenScopes, hiddenScopes: this.config.releaseNotes.hiddenScopes,
title: await this.promptForReleaseTitle(), title: await this.promptForReleaseTitle(),
}); });
} }

View File

@ -33,5 +33,8 @@ jasmine_node_test(
# enabled in NodeJS. TODO: Remove this with rules_nodejs 3.x where patching is optional. # enabled in NodeJS. TODO: Remove this with rules_nodejs 3.x where patching is optional.
# https://github.com/bazelbuild/rules_nodejs/commit/7d070ffadf9c3b41711382a4737b995f987c14fa. # https://github.com/bazelbuild/rules_nodejs/commit/7d070ffadf9c3b41711382a4737b995f987c14fa.
args = ["--nobazel_patch_module_resolver"], args = ["--nobazel_patch_module_resolver"],
data = [
"//dev-infra/release/publish/release-notes/templates",
],
deps = [":test_lib"], deps = [":test_lib"],
) )

View File

@ -18,6 +18,7 @@ import {ReleaseTrain} from '../../versioning/release-trains';
import {ReleaseAction} from '../actions'; import {ReleaseAction} from '../actions';
import {actions} from '../actions/index'; import {actions} from '../actions/index';
import {changelogPath} from '../constants'; import {changelogPath} from '../constants';
import {ReleaseNotes} from '../release-notes/release-notes';
import {fakeNpmPackageQueryRequest, getChangelogForVersion, getTestingMocksForReleaseAction, parse, setupReleaseActionForTesting, testTmpDir} from './test-utils'; import {fakeNpmPackageQueryRequest, getChangelogForVersion, getTestingMocksForReleaseAction, parse, setupReleaseActionForTesting, testTmpDir} from './test-utils';
@ -89,7 +90,7 @@ describe('common release action logic', () => {
const fakeReleaseNotes = getChangelogForVersion(version.format()); const fakeReleaseNotes = getChangelogForVersion(version.format());
const forkBranchName = `changelog-cherry-pick-${version}`; const forkBranchName = `changelog-cherry-pick-${version}`;
it('should prepend fetched changelog', async () => { it('should prepend the changelog to the next branch', async () => {
const {repo, fork, instance, testTmpDir} = const {repo, fork, instance, testTmpDir} =
setupReleaseActionForTesting(TestAction, baseReleaseTrains); setupReleaseActionForTesting(TestAction, baseReleaseTrains);
@ -109,62 +110,6 @@ describe('common release action logic', () => {
expect(changelogContent).toEqual(`${fakeReleaseNotes}Existing changelog`); expect(changelogContent).toEqual(`${fakeReleaseNotes}Existing changelog`);
}); });
it('should respect a custom release note extraction pattern', async () => {
const {repo, fork, instance, testTmpDir, releaseConfig} =
setupReleaseActionForTesting(TestAction, baseReleaseTrains);
// Custom pattern matching changelog output sections grouped through
// basic level-1 markdown headers (compared to the default anchor pattern).
releaseConfig.extractReleaseNotesPattern = version =>
new RegExp(`(# v${version} \\("[^"]+"\\).*?)(?:# v|$)`, 's');
const customReleaseNotes = `# v${version} ("newton-kepler")\n\nNew Content!`;
// Expect the changelog to be fetched and return a fake changelog to test that
// it is properly appended. Also expect a pull request to be created in the fork.
repo.expectChangelogFetch(branchName, customReleaseNotes)
.expectFindForkRequest(fork)
.expectPullRequestToBeCreated('master', fork, forkBranchName, 200)
.expectPullRequestWait(200);
// Simulate that the fork branch name is available.
fork.expectBranchRequest(forkBranchName, null);
await instance.testCherryPickWithPullRequest(version, branchName);
const changelogContent = readFileSync(join(testTmpDir, changelogPath), 'utf8');
expect(changelogContent).toEqual(`${customReleaseNotes}\n\nExisting changelog`);
});
it('should print an error if release notes cannot be extracted', async () => {
const {repo, fork, instance, testTmpDir, releaseConfig} =
setupReleaseActionForTesting(TestAction, baseReleaseTrains);
// Expect the changelog to be fetched and return a fake changelog to test that
// it is properly appended. Also expect a pull request to be created in the fork.
repo.expectChangelogFetch(branchName, `non analyzable changelog`)
.expectFindForkRequest(fork)
.expectPullRequestToBeCreated('master', fork, forkBranchName, 200)
.expectPullRequestWait(200);
// Simulate that the fork branch name is available.
fork.expectBranchRequest(forkBranchName, null);
spyOn(console, 'error');
await instance.testCherryPickWithPullRequest(version, branchName);
expect(console.error)
.toHaveBeenCalledWith(
jasmine.stringMatching(`Could not cherry-pick release notes for v${version}`));
expect(console.error)
.toHaveBeenCalledWith(jasmine.stringMatching(
`Please copy the release notes manually into the "master" branch.`));
const changelogContent = readFileSync(join(testTmpDir, changelogPath), 'utf8');
expect(changelogContent).toEqual(`Existing changelog`);
});
it('should push changes to a fork for creating a pull request', async () => { it('should push changes to a fork for creating a pull request', async () => {
const {repo, fork, instance, gitClient} = const {repo, fork, instance, gitClient} =
setupReleaseActionForTesting(TestAction, baseReleaseTrains); setupReleaseActionForTesting(TestAction, baseReleaseTrains);
@ -214,6 +159,7 @@ class TestAction extends ReleaseAction {
} }
async testCherryPickWithPullRequest(version: semver.SemVer, branch: string) { async testCherryPickWithPullRequest(version: semver.SemVer, branch: string) {
await this.cherryPickChangelogIntoNextBranch(version, branch); const releaseNotes = await ReleaseNotes.fromLatestTagToHead(version, this.config);
await this.cherryPickChangelogIntoNextBranch(releaseNotes, branch);
} }
} }

View File

@ -140,7 +140,6 @@ describe('move next into feature-freeze action', () => {
'Expected next release-train update branch be created in fork.'); 'Expected next release-train update branch be created in fork.');
expect(externalCommands.invokeReleaseBuildCommand).toHaveBeenCalledTimes(1); expect(externalCommands.invokeReleaseBuildCommand).toHaveBeenCalledTimes(1);
expect(releaseConfig.generateReleaseNotesForHead).toHaveBeenCalledTimes(1);
expect(npm.runNpmPublish).toHaveBeenCalledTimes(2); expect(npm.runNpmPublish).toHaveBeenCalledTimes(2);
expect(npm.runNpmPublish).toHaveBeenCalledWith(`${testTmpDir}/dist/pkg1`, 'next', undefined); expect(npm.runNpmPublish).toHaveBeenCalledWith(`${testTmpDir}/dist/pkg1`, 'next', undefined);
expect(npm.runNpmPublish).toHaveBeenCalledWith(`${testTmpDir}/dist/pkg2`, 'next', undefined); expect(npm.runNpmPublish).toHaveBeenCalledWith(`${testTmpDir}/dist/pkg2`, 'next', undefined);

View File

@ -11,9 +11,10 @@ import * as nock from 'nock';
import {join} from 'path'; import {join} from 'path';
import * as semver from 'semver'; import * as semver from 'semver';
import * as commitMessageUtils from '../../../commit-message/utils';
import {GithubConfig} from '../../../utils/config'; import {GithubConfig} from '../../../utils/config';
import * as console from '../../../utils/console'; import * as console from '../../../utils/console';
import {getBranchPushMatcher, VirtualGitClient} from '../../../utils/testing'; import {getBranchPushMatcher, installVirtualGitClientSpies, VirtualGitClient} from '../../../utils/testing';
import {ReleaseConfig} from '../../config/index'; import {ReleaseConfig} from '../../config/index';
import {ActiveReleaseTrains} from '../../versioning/active-release-trains'; import {ActiveReleaseTrains} from '../../versioning/active-release-trains';
import * as npm from '../../versioning/npm-publish'; import * as npm from '../../versioning/npm-publish';
@ -21,6 +22,7 @@ import {_npmPackageInfoCache, NpmPackageInfo} from '../../versioning/npm-registr
import {ReleaseAction, ReleaseActionConstructor} from '../actions'; import {ReleaseAction, ReleaseActionConstructor} from '../actions';
import * as constants from '../constants'; import * as constants from '../constants';
import * as externalCommands from '../external-commands'; import * as externalCommands from '../external-commands';
import {buildDateStamp} from '../release-notes/context';
import {GithubTestingRepo} from './github-api-testing'; import {GithubTestingRepo} from './github-api-testing';
@ -50,7 +52,7 @@ export function getTestingMocksForReleaseAction() {
'@angular/pkg1', '@angular/pkg1',
'@angular/pkg2', '@angular/pkg2',
], ],
generateReleaseNotesForHead: jasmine.createSpy('generateReleaseNotesForHead').and.resolveTo(), releaseNotes: {},
buildPackages: () => { buildPackages: () => {
throw Error('Not implemented'); throw Error('Not implemented');
}, },
@ -67,6 +69,9 @@ export function getTestingMocksForReleaseAction() {
export function setupReleaseActionForTesting<T extends ReleaseAction>( export function setupReleaseActionForTesting<T extends ReleaseAction>(
actionCtor: ReleaseActionConstructor<T>, active: ActiveReleaseTrains, actionCtor: ReleaseActionConstructor<T>, active: ActiveReleaseTrains,
isNextPublishedToNpm = true): TestReleaseAction<T> { isNextPublishedToNpm = true): TestReleaseAction<T> {
installVirtualGitClientSpies();
spyOn(commitMessageUtils, 'getCommitsInRange').and.returnValue(Promise.resolve([]));
// Reset existing HTTP interceptors. // Reset existing HTTP interceptors.
nock.cleanAll(); nock.cleanAll();
@ -121,7 +126,7 @@ export function parse(version: string): semver.SemVer {
/** Gets a changelog for the specified version. */ /** Gets a changelog for the specified version. */
export function getChangelogForVersion(version: string): string { export function getChangelogForVersion(version: string): string {
return `<a name="${version}"></a>Changelog\n\n`; return `<a name="${version}"></a>\n# ${version} (${buildDateStamp()})\n\n\n`;
} }
export async function expectStagingAndPublishWithoutCherryPick( export async function expectStagingAndPublishWithoutCherryPick(
@ -166,7 +171,6 @@ export async function expectStagingAndPublishWithoutCherryPick(
'Expected release staging branch to be created in fork.'); 'Expected release staging branch to be created in fork.');
expect(externalCommands.invokeReleaseBuildCommand).toHaveBeenCalledTimes(1); expect(externalCommands.invokeReleaseBuildCommand).toHaveBeenCalledTimes(1);
expect(releaseConfig.generateReleaseNotesForHead).toHaveBeenCalledTimes(1);
expect(npm.runNpmPublish).toHaveBeenCalledTimes(2); expect(npm.runNpmPublish).toHaveBeenCalledTimes(2);
expect(npm.runNpmPublish) expect(npm.runNpmPublish)
.toHaveBeenCalledWith(`${testTmpDir}/dist/pkg1`, expectedNpmDistTag, undefined); .toHaveBeenCalledWith(`${testTmpDir}/dist/pkg1`, expectedNpmDistTag, undefined);
@ -235,7 +239,6 @@ export async function expectStagingAndPublishWithCherryPick(
'Expected cherry-pick branch to be created in fork.'); 'Expected cherry-pick branch to be created in fork.');
expect(externalCommands.invokeReleaseBuildCommand).toHaveBeenCalledTimes(1); expect(externalCommands.invokeReleaseBuildCommand).toHaveBeenCalledTimes(1);
expect(releaseConfig.generateReleaseNotesForHead).toHaveBeenCalledTimes(1);
expect(npm.runNpmPublish).toHaveBeenCalledTimes(2); expect(npm.runNpmPublish).toHaveBeenCalledTimes(2);
expect(npm.runNpmPublish) expect(npm.runNpmPublish)
.toHaveBeenCalledWith(`${testTmpDir}/dist/pkg1`, expectedNpmDistTag, undefined); .toHaveBeenCalledWith(`${testTmpDir}/dist/pkg1`, expectedNpmDistTag, undefined);

View File

@ -32,7 +32,7 @@ describe('ng-dev release set-dist-tag', () => {
npmPackages, npmPackages,
publishRegistry, publishRegistry,
buildPackages: async () => [], buildPackages: async () => [],
generateReleaseNotesForHead: async () => {} releaseNotes: {},
}); });
await ReleaseSetDistTagCommand.handler({tagName, targetVersion, $0: '', _: []}); await ReleaseSetDistTagCommand.handler({tagName, targetVersion, $0: '', _: []});
} }

View File

@ -10,6 +10,8 @@ ts_library(
"@npm//@types/jasmine", "@npm//@types/jasmine",
"@npm//@types/minimist", "@npm//@types/minimist",
"@npm//@types/node", "@npm//@types/node",
"@npm//@types/semver",
"@npm//minimist", "@npm//minimist",
"@npm//semver",
], ],
) )

View File

@ -8,6 +8,7 @@
import {SpawnSyncOptions, SpawnSyncReturns} from 'child_process'; import {SpawnSyncOptions, SpawnSyncReturns} from 'child_process';
import * as parseArgs from 'minimist'; import * as parseArgs from 'minimist';
import {SemVer} from 'semver';
import {NgDevConfig} from '../config'; import {NgDevConfig} from '../config';
import {GitClient} from '../git/index'; import {GitClient} from '../git/index';
@ -82,6 +83,15 @@ export class VirtualGitClient<Authenticated extends boolean> extends GitClient<A
/** List of pushed heads to a given remote ref. */ /** List of pushed heads to a given remote ref. */
pushed: {remote: RemoteRef, head: GitHead}[] = []; pushed: {remote: RemoteRef, head: GitHead}[] = [];
/**
* Override the actual GitClient getLatestSemverTag, as an actual tag cannot be retrieved in
* testing.
*/
getLatestSemverTag() {
return new SemVer('0.0.0');
}
/** Override for the actual Git client command execution. */ /** Override for the actual Git client command execution. */
runGraceful(args: string[], options: SpawnSyncOptions = {}): SpawnSyncReturns<string> { runGraceful(args: string[], options: SpawnSyncOptions = {}): SpawnSyncReturns<string> {
const [command, ...rawArgs] = args; const [command, ...rawArgs] = args;