diff --git a/dev-infra/BUILD.bazel b/dev-infra/BUILD.bazel
index b3ca536634..a45de570d1 100644
--- a/dev-infra/BUILD.bazel
+++ b/dev-infra/BUILD.bazel
@@ -44,6 +44,7 @@ pkg_npm(
"index.bzl",
"//dev-infra/bazel:files",
"//dev-infra/benchmark:files",
+ "//dev-infra/release/publish/release-notes/templates",
],
substitutions = {
# angular/angular should not consume it's own packages, so we use
diff --git a/dev-infra/ng-dev.js b/dev-infra/ng-dev.js
index 1ce1b0340e..0ceb6c45bb 100755
--- a/dev-infra/ng-dev.js
+++ b/dev-infra/ng-dev.js
@@ -25,6 +25,7 @@ var cliProgress = require('cli-progress');
var os = require('os');
var minimatch = require('minimatch');
var ora = require('ora');
+require('ejs');
var glob = require('glob');
var ts = require('typescript');
@@ -5580,6 +5581,11 @@ function isCommitClosingPullRequest(api, sha, id) {
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
+/** List of types to be included in the release notes. */
+const typesToIncludeInReleaseNotes = Object.values(COMMIT_TYPES)
+ .filter(type => type.releaseNotesLevel === ReleaseNotesLevel.Visible)
+ .map(type => type.name);
+
/**
* Gets the default pattern for extracting release notes for the given version.
* This pattern matches for the conventional-changelog Angular preset.
diff --git a/dev-infra/release/config/index.ts b/dev-infra/release/config/index.ts
index e8fb95909a..de0602f294 100644
--- a/dev-infra/release/config/index.ts
+++ b/dev-infra/release/config/index.ts
@@ -36,6 +36,25 @@ export interface ReleaseConfig {
extractReleaseNotesPattern?: (version: semver.SemVer) => RegExp;
/** The list of github labels to add to the release PRs. */
releasePrLabels?: string[];
+ /** Configuration for creating release notes during publishing. */
+ // TODO(josephperrott): Make releaseNotes a required attribute on the interface when tooling is
+ // integrated.
+ releaseNotes?: ReleaseNotesConfig;
+}
+
+/** Configuration for creating release notes during publishing. */
+export interface ReleaseNotesConfig {
+ /** Whether to prompt for and include a release title in the generated release notes. */
+ useReleaseTitle?: boolean;
+ /** List of commit scopes to disclude from generated release notes. */
+ hiddenScopes?: string[];
+ /**
+ * List of commit groups, either {npmScope}/{scope} or {scope}, to use for ordering.
+ *
+ * Each group for the release notes, will appear in the order provided in groupOrder and any other
+ * groups will appear after these groups, sorted by `Array.sort`'s default sorting order.
+ */
+ groupOrder?: string[];
}
/** Configuration for releases in the dev-infra configuration. */
diff --git a/dev-infra/release/publish/BUILD.bazel b/dev-infra/release/publish/BUILD.bazel
index a2f62b7dc5..0663649a4c 100644
--- a/dev-infra/release/publish/BUILD.bazel
+++ b/dev-infra/release/publish/BUILD.bazel
@@ -8,15 +8,18 @@ ts_library(
module_name = "@angular/dev-infra-private/release/publish",
visibility = ["//dev-infra:__subpackages__"],
deps = [
+ "//dev-infra/commit-message",
"//dev-infra/pr/merge",
"//dev-infra/release/config",
"//dev-infra/release/versioning",
"//dev-infra/utils",
"@npm//@octokit/rest",
+ "@npm//@types/ejs",
"@npm//@types/inquirer",
"@npm//@types/node",
"@npm//@types/semver",
"@npm//@types/yargs",
+ "@npm//ejs",
"@npm//inquirer",
"@npm//ora",
"@npm//semver",
diff --git a/dev-infra/release/publish/actions.ts b/dev-infra/release/publish/actions.ts
index af50f41a11..40b5aa1d29 100644
--- a/dev-infra/release/publish/actions.ts
+++ b/dev-infra/release/publish/actions.ts
@@ -14,7 +14,7 @@ import * as semver from 'semver';
import {debug, error, green, info, promptConfirm, red, warn, yellow} from '../../utils/console';
import {getListCommitsInBranchUrl, getRepositoryGitUrl} from '../../utils/git/github-urls';
import {GitClient} from '../../utils/git/index';
-import {BuiltPackage, ReleaseConfig} from '../config';
+import {BuiltPackage, ReleaseConfig} from '../config/index';
import {ActiveReleaseTrains} from '../versioning/active-release-trains';
import {runNpmPublish} from '../versioning/npm-publish';
@@ -24,7 +24,7 @@ import {changelogPath, packageJsonPath, waitForPullRequestInterval} from './cons
import {invokeBazelCleanCommand, invokeReleaseBuildCommand, invokeYarnInstallCommand} from './external-commands';
import {findOwnedForksOfRepoQuery} from './graphql-queries';
import {getPullRequestState} from './pull-request-state';
-import {getDefaultExtractReleaseNotesPattern, getLocalChangelogFilePath} from './release-notes';
+import {getDefaultExtractReleaseNotesPattern, getLocalChangelogFilePath} from './release-notes/release-notes';
/** Interface describing a Github repository. */
export interface GithubRepo {
diff --git a/dev-infra/release/publish/index.ts b/dev-infra/release/publish/index.ts
index ea978ded8a..9ce84c3571 100644
--- a/dev-infra/release/publish/index.ts
+++ b/dev-infra/release/publish/index.ts
@@ -11,7 +11,7 @@ import {ListChoiceOptions, prompt} from 'inquirer';
import {GithubConfig} from '../../utils/config';
import {debug, error, info, log, promptConfirm, red, yellow} from '../../utils/console';
import {GitClient} from '../../utils/git/index';
-import {ReleaseConfig} from '../config';
+import {ReleaseConfig} from '../config/index';
import {ActiveReleaseTrains, fetchActiveReleaseTrains, nextBranchName} from '../versioning/active-release-trains';
import {npmIsLoggedIn, npmLogin, npmLogout} from '../versioning/npm-publish';
import {printActiveReleaseTrains} from '../versioning/print-active-trains';
diff --git a/dev-infra/release/publish/release-notes.ts b/dev-infra/release/publish/release-notes.ts
deleted file mode 100644
index c48d17a554..0000000000
--- a/dev-infra/release/publish/release-notes.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-/**
- * @license
- * Copyright Google LLC All Rights Reserved.
- *
- * Use of this source code is governed by an MIT-style license that can be
- * found in the LICENSE file at https://angular.io/license
- */
-
-import {join} from 'path';
-import * as semver from 'semver';
-import {changelogPath} from './constants';
-
-/**
- * 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(`(.*?)(?:.*?)(?:
+# <%- version %><% if (title) { %> "<%- title %>"<% } %> (<%- dateStamp %>)
+
+<%_
+const commitsInChangelog = commits.filter(includeInReleaseNotes());
+for (const group of asCommitGroups(commitsInChangelog)) {
+_%>
+
+### <%- group.title %>
+| Commit | Description |
+| -- | -- |
+<%_
+ for (const commit of group.commits) {
+_%>
+| <%- commit.shortHash %> | <%- commit.header %> |
+<%_
+ }
+}
+_%>
+
+<%_
+const breakingChanges = commits.filter(contains('breakingChanges'));
+if (breakingChanges.length) {
+_%>
+## Breaking Changes
+
+<%_
+ for (const group of asCommitGroups(breakingChanges)) {
+_%>
+### <%- group.title %>
+
+<%_
+ for (const commit of group.commits) {
+_%>
+<%- commit.breakingChanges[0].text %>
+
+<%_
+ }
+ }
+}
+_%>
+
+<%_
+const deprecations = commits.filter(contains('deprecations'));
+if (deprecations.length) {
+_%>
+## Deprecations
+<%_
+ for (const group of asCommitGroups(deprecations)) {
+_%>
+### <%- group.title %>
+
+<%_
+ for (const commit of group.commits) {
+_%>
+<%- commit.deprecations[0].text %>
+<%_
+ }
+ }
+}
+_%>
+
+<%_
+const authors = commits.filter(unique('author')).map(c => c.author).sort();
+if (authors.length === 1) {
+_%>
+## Special Thanks:
+<%- authors[0]%>
+<%_
+}
+if (authors.length > 1) {
+_%>
+## Special Thanks:
+<%- authors.slice(0, -1).join(', ') %> and <%- authors.slice(-1)[0] %>
+<%_
+}
+_%>
diff --git a/dev-infra/release/publish/release-notes/templates/github-release.ejs b/dev-infra/release/publish/release-notes/templates/github-release.ejs
new file mode 100644
index 0000000000..df335f515d
--- /dev/null
+++ b/dev-infra/release/publish/release-notes/templates/github-release.ejs
@@ -0,0 +1,77 @@
+
+# <%- version %><% if (title) { %> "<%- title %>"<% } %> (<%- dateStamp %>)
+
+<%_
+const commitsInChangelog = commits.filter(includeInReleaseNotes());
+for (const group of asCommitGroups(commitsInChangelog)) {
+_%>
+
+### <%- group.title %>
+| Commit | Description |
+| -- | -- |
+<%_
+ for (const commit of group.commits) {
+_%>
+| <%- commit.shortHash %> | <%- commit.header %> |
+<%_
+ }
+}
+_%>
+
+<%_
+const breakingChanges = commits.filter(contains('breakingChanges'));
+if (breakingChanges.length) {
+_%>
+## Breaking Changes
+
+<%_
+ for (const group of asCommitGroups(breakingChanges)) {
+_%>
+### <%- group.title %>
+
+<%_
+ for (const commit of group.commits) {
+_%>
+<%- commit.breakingChanges[0].text %>
+
+<%_
+ }
+ }
+}
+_%>
+
+<%_
+const deprecations = commits.filter(contains('deprecations'));
+if (deprecations.length) {
+_%>
+## Deprecations
+<%_
+ for (const group of asCommitGroups(deprecations)) {
+_%>
+### <%- group.title %>
+
+<%_
+ for (const commit of group.commits) {
+_%>
+<%- commit.deprecations[0].text %>
+<%_
+ }
+ }
+}
+_%>
+
+<%_
+const authors = commits.filter(unique('author')).map(c => c.author).sort();
+if (authors.length === 1) {
+_%>
+## Special Thanks:
+<%- authors[0]%>
+<%_
+}
+if (authors.length > 1) {
+_%>
+## Special Thanks:
+<%- authors.slice(0, -1).join(', ') %> and <%- authors.slice(-1)[0] %>
+<%_
+}
+_%>
diff --git a/dev-infra/release/publish/test/BUILD.bazel b/dev-infra/release/publish/test/BUILD.bazel
index eea28bad98..b958bdb9c2 100644
--- a/dev-infra/release/publish/test/BUILD.bazel
+++ b/dev-infra/release/publish/test/BUILD.bazel
@@ -8,6 +8,7 @@ ts_library(
]),
module_name = "@angular/dev-infra-private/release/test",
deps = [
+ "//dev-infra/commit-message",
"//dev-infra/release/config",
"//dev-infra/release/publish",
"//dev-infra/release/versioning",
diff --git a/dev-infra/release/publish/test/release-notes/context.spec.ts b/dev-infra/release/publish/test/release-notes/context.spec.ts
new file mode 100644
index 0000000000..d1efffeca8
--- /dev/null
+++ b/dev-infra/release/publish/test/release-notes/context.spec.ts
@@ -0,0 +1,168 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.io/license
+ */
+
+import {CommitFromGitLog, parseCommitFromGitLog} from '../../../../commit-message/parse';
+import {commitMessageBuilder} from '../../../../commit-message/test-util';
+import {RenderContext, RenderContextData,} from '../../release-notes/context';
+
+const defaultContextData: RenderContextData = {
+ commits: [],
+ github: {
+ name: 'repoName',
+ owner: 'repoOwner',
+ },
+ title: false,
+ version: '1.2.3',
+};
+
+describe('RenderContext', () => {
+ beforeAll(() => {
+ jasmine.clock().install();
+ });
+
+ it('contains a date stamp using the current date by default', async () => {
+ jasmine.clock().mockDate(new Date(1996, 11, 11));
+ const renderContext = new RenderContext(defaultContextData);
+ expect(renderContext.dateStamp).toBe('1996-12-11');
+ });
+
+ it('contains a date stamp using a provided date', async () => {
+ const data = {...defaultContextData, date: new Date(2000, 0, 20)};
+ const renderContext = new RenderContext(data);
+ expect(renderContext.dateStamp).toBe('2000-01-20');
+ });
+
+ it('filters to include only commits which have specified field', () => {
+ const renderContext = new RenderContext(defaultContextData);
+ const matchingCommits = commitsFromList(2, 15);
+ expect(commits.filter(renderContext.contains('breakingChanges'))).toEqual(matchingCommits);
+ });
+
+ it('filters to include only the first commit discovered with a unique value for a specified field',
+ () => {
+ const renderContext = new RenderContext(defaultContextData);
+ const matchingCommits = commitsFromList(0, 1, 2, 3, 4, 7, 12);
+ expect(commits.filter(renderContext.unique('type'))).toEqual(matchingCommits);
+ });
+
+
+ describe('filters to include commits which are to be included in the release notes', () => {
+ it('including all scopes by default', () => {
+ const renderContext = new RenderContext(defaultContextData);
+ const matchingCommits = commitsFromList(0, 2, 5, 6, 8, 10, 11, 12, 15, 16);
+ expect(commits.filter(renderContext.includeInReleaseNotes())).toEqual(matchingCommits);
+ });
+
+ it('excluding hidden scopes defined in the config', () => {
+ const renderContext = new RenderContext({...defaultContextData, hiddenScopes: ['core']});
+ const matchingCommits = commitsFromList(0, 2, 6, 8, 10, 11, 15, 16);
+ expect(commits.filter(renderContext.includeInReleaseNotes())).toEqual(matchingCommits);
+ });
+ });
+
+ describe('organized lists of commits into groups', () => {
+ let devInfraCommits: CommitFromGitLog[];
+ let coreCommits: CommitFromGitLog[];
+ let compilerCommits: CommitFromGitLog[];
+ let unorganizedCommits: CommitFromGitLog[];
+ function assertOrganizedGroupsMatch(
+ generatedGroups: {title: string, commits: CommitFromGitLog[]}[],
+ providedGroups: {title: string, commits: CommitFromGitLog[]}[]) {
+ expect(generatedGroups.length).toBe(providedGroups.length);
+ generatedGroups.forEach(({title, commits}, idx) => {
+ expect(title).toBe(providedGroups[idx].title);
+ expect(commits).toEqual(jasmine.arrayWithExactContents(providedGroups[idx].commits));
+ });
+ }
+
+ beforeEach(() => {
+ devInfraCommits = commits.filter(c => c.scope === 'dev-infra');
+ coreCommits = commits.filter(c => c.scope === 'core');
+ compilerCommits = commits.filter(c => c.scope === 'compiler');
+ unorganizedCommits =
+ [...devInfraCommits, ...coreCommits, ...compilerCommits].sort(() => Math.random() - 0.5);
+ });
+
+
+
+ it('with default sorting', () => {
+ const renderContext = new RenderContext(defaultContextData);
+ const organizedCommits = renderContext.asCommitGroups(unorganizedCommits);
+
+ assertOrganizedGroupsMatch(organizedCommits, [
+ {title: 'compiler', commits: compilerCommits},
+ {title: 'core', commits: coreCommits},
+ {title: 'dev-infra', commits: devInfraCommits},
+ ]);
+ });
+
+ it('sorted by the provided order in the config', () => {
+ const renderContext =
+ new RenderContext({...defaultContextData, groupOrder: ['core', 'dev-infra']});
+ const organizedCommits = renderContext.asCommitGroups(unorganizedCommits);
+
+ assertOrganizedGroupsMatch(organizedCommits, [
+ {title: 'core', commits: coreCommits},
+ {title: 'dev-infra', commits: devInfraCommits},
+ {title: 'compiler', commits: compilerCommits},
+ ]);
+ });
+ });
+
+ afterAll(() => {
+ jasmine.clock().uninstall();
+ });
+});
+
+
+
+const buildCommitMessage = commitMessageBuilder({
+ prefix: '',
+ type: '',
+ npmScope: '',
+ scope: '',
+ summary: 'This is a short summary of the change',
+ body: 'This is a longer description of the change',
+ footer: '',
+});
+
+function buildCommit(type: string, scope: string, withBreakingChange = false) {
+ const footer = withBreakingChange ? 'BREAKING CHANGE: something is broken now' : '';
+ const parts = {type, scope, footer};
+ return parseCommitFromGitLog(Buffer.from(buildCommitMessage(parts)));
+}
+
+
+function commitsFromList(...indexes: number[]) {
+ const output: CommitFromGitLog[] = [];
+ for (const i of indexes) {
+ output.push(commits[i]);
+ }
+ return output;
+}
+
+
+const commits: CommitFromGitLog[] = [
+ buildCommit('fix', 'platform-browser'),
+ buildCommit('test', 'dev-infra'),
+ buildCommit('feat', 'dev-infra', true),
+ buildCommit('build', 'docs-infra'),
+ buildCommit('docs', 'router'),
+ buildCommit('feat', 'core'),
+ buildCommit('feat', 'common'),
+ buildCommit('refactor', 'compiler'),
+ buildCommit('fix', 'docs-infra'),
+ buildCommit('test', 'core'),
+ buildCommit('feat', 'compiler-cli'),
+ buildCommit('fix', 'dev-infra'),
+ buildCommit('perf', 'core'),
+ buildCommit('docs', 'forms'),
+ buildCommit('refactor', 'dev-infra'),
+ buildCommit('feat', 'docs-infra', true),
+ buildCommit('fix', 'compiler'),
+];
diff --git a/dev-infra/tmpl-package.json b/dev-infra/tmpl-package.json
index 8d89c7228e..77ee606719 100644
--- a/dev-infra/tmpl-package.json
+++ b/dev-infra/tmpl-package.json
@@ -17,6 +17,7 @@
"chalk": "",
"cli-progress": "",
"conventional-commits-parser": "",
+ "ejs": "",
"git-raw-commits": "",
"glob": "",
"inquirer": "",
diff --git a/package.json b/package.json
index 8317637cb3..cd5710ca97 100644
--- a/package.json
+++ b/package.json
@@ -175,6 +175,7 @@
"@octokit/graphql": "^4.3.1",
"@types/cli-progress": "^3.4.2",
"@types/conventional-commits-parser": "^3.0.1",
+ "@types/ejs": "^3.0.6",
"@types/git-raw-commits": "^2.0.0",
"@types/minimist": "^1.2.0",
"@yarnpkg/lockfile": "^1.1.0",
@@ -187,6 +188,7 @@
"cli-progress": "^3.7.0",
"conventional-changelog": "^2.0.3",
"conventional-commits-parser": "^3.2.1",
+ "ejs": "^3.1.6",
"entities": "1.1.1",
"firebase-tools": "^7.11.0",
"firefox-profile": "1.0.3",
diff --git a/yarn.lock b/yarn.lock
index 2c63b45537..624527783f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2586,6 +2586,11 @@
dependencies:
"@types/node" "*"
+"@types/ejs@^3.0.6":
+ version "3.0.6"
+ resolved "https://registry.yarnpkg.com/@types/ejs/-/ejs-3.0.6.tgz#aca442289df623bfa8e47c23961f0357847b83fe"
+ integrity sha512-fj1hi+ZSW0xPLrJJD+YNwIh9GZbyaIepG26E/gXvp8nCa2pYokxUYO1sK9qjGxp2g8ryZYuon7wmjpwE2cyASQ==
+
"@types/estree@0.0.39":
version "0.0.39"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f"
@@ -3642,6 +3647,11 @@ async-settle@^1.0.0:
dependencies:
async-done "^1.2.2"
+async@0.9.x:
+ version "0.9.2"
+ resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d"
+ integrity sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0=
+
async@1.2.x:
version "1.2.1"
resolved "https://registry.yarnpkg.com/async/-/async-1.2.1.tgz#a4816a17cd5ff516dfa2c7698a453369b9790de0"
@@ -6455,6 +6465,13 @@ ee-first@1.1.1:
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
+ejs@^3.1.6:
+ version "3.1.6"
+ resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.6.tgz#5bfd0a0689743bb5268b3550cceeebbc1702822a"
+ integrity sha512-9lt9Zse4hPucPkoP7FHDF0LQAlGyF9JVpnClFLFH3aSSbxmyoqINRpp/9wePWJTUl4KOQwRL72Iw3InHPDkoGw==
+ dependencies:
+ jake "^10.6.1"
+
electron-to-chromium@^1.3.390:
version "1.3.394"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.394.tgz#50e927bb9f6a559ed21d284e7683ec5e2c784835"
@@ -7118,6 +7135,13 @@ file-uri-to-path@1.0.0:
resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"
integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==
+filelist@^1.0.1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.2.tgz#80202f21462d4d1c2e214119b1807c1bc0380e5b"
+ integrity sha512-z7O0IS8Plc39rTCq6i6iHxk43duYOn8uFJiWSewIq0Bww1RNybVHSCjahmcC87ZqAm4OTvFzlzeGu3XAzG1ctQ==
+ dependencies:
+ minimatch "^3.0.4"
+
filesize@^3.1.3:
version "3.6.1"
resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.6.1.tgz#090bb3ee01b6f801a8a8be99d31710b3422bb317"
@@ -9378,6 +9402,16 @@ istanbul-reports@^3.0.2:
html-escaper "^2.0.0"
istanbul-lib-report "^3.0.0"
+jake@^10.6.1:
+ version "10.8.2"
+ resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.2.tgz#ebc9de8558160a66d82d0eadc6a2e58fbc500a7b"
+ integrity sha512-eLpKyrfG3mzvGE2Du8VoPbeSkRry093+tyNjdYaBbJS9v17knImYGNXQCUV0gLxQtF82m3E8iRb/wdSQZLoq7A==
+ dependencies:
+ async "0.9.x"
+ chalk "^2.4.2"
+ filelist "^1.0.1"
+ minimatch "^3.0.4"
+
jasmine-ajax@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/jasmine-ajax/-/jasmine-ajax-4.0.0.tgz#7d8ba7e47e3f7e780e155fe9aa563faafa7e1a26"