Skip to content

build: group changelog entries by packages #17017

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,21 +166,37 @@ we use the git commit messages to **generate the Angular Material change log**.

### Commit Message Format
Each commit message consists of a **header**, a **body** and a **footer**. The header has a special
format that includes a **type**, a **scope** and a **subject**:
format that includes a **type**, a **package**, a **scope** and a **subject**:

```
<type>(<scope>): <subject>
<type>(<package>/<scope>): <subject>
<BLANK LINE>
<body>
<BLANK LINE>
<footer>
```

The **header** is mandatory and the **scope** of the header is optional.
The **header** is mandatory. For changes which are shown in the changelog (`fix`, `feat`,
`perf` and `revert`), the **package** and **scope** fields are mandatory.

The `package` and `scope` fields can be omitted if the change does not affect a specific
package and is not displayed in the changelog (e.g. build changes or refactorings).

Any line of the commit message cannot be longer 100 characters! This allows the message to be easier
to read on GitHub as well as in various git tools.

Example:

```
fix(material/button): unable to disable button through binding

Fixes a bug in the Angular Material `button` component where buttons
cannot be disabled through an binding. This is because the `disabled`
input did not set the `.mat-button-disabled` class on the host element.

Fixes #1234
```

### Revert
If the commit reverts a previous commit, it should begin with `revert: `, followed by the header of
the reverted commit. In the body it should say: `This reverts commit <hash>.`, where the hash is
Expand All @@ -201,6 +217,10 @@ Must be one of the following:
(example scopes: gulp, broccoli, npm)
* **chore**: Other changes that don't modify `src` or `test` files

### Package
The commit message should specify which package is affected by the change. For example:
`material`, `cdk-experimental`, etc.

### Scope
The scope could be anything specifying place of the commit change. For example
`datepicker`, `dialog`, etc.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"stage-release": "ts-node --project tools/release/ tools/release/stage-release.ts",
"publish-release": "ts-node --project tools/release/ tools/release/publish-release.ts",
"check-release-output": "ts-node --project tools/release tools/release/check-release-output.ts",
"changelog": "ts-node --project tools/release tools/release/changelog.ts",
"preinstall": "node ./tools/npm/check-npm.js",
"format:ts": "git-clang-format HEAD $(git diff HEAD --name-only | grep -v \"\\.d\\.ts\")",
"format:bazel": "yarn -s bazel:buildifier --lint=fix --mode=fix",
Expand Down
60 changes: 60 additions & 0 deletions tools/release/changelog-commit-template.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
{{#if scope}} **{{scope}}:**
{{~/if}} {{#if subject}}
{{~subject}}
{{~else}}
{{~header}}
{{~/if}}

{{~!-- commit link --}} {{#if @root.linkReferences~}}
([{{hash}}](
{{~#if @root.repository}}
{{~#if @root.host}}
{{[email protected]}}/
{{~/if}}
{{~#if @root.owner}}
{{[email protected]}}/
{{~/if}}
{{[email protected]}}
{{~else}}
{{[email protected]}}
{{~/if}}/
{{[email protected]}}/{{hash}}))
{{~else}}
{{~hash}}
{{~/if}}

{{~!-- commit references --}}
{{~#if references~}}
, closes
{{~#each references}} {{#if @root.linkReferences~}}
[
{{~#if this.owner}}
{{~this.owner}}/
{{~/if}}
{{~this.repository}}#{{this.issue}}](
{{~#if @root.repository}}
{{~#if @root.host}}
{{[email protected]}}/
{{~/if}}
{{~#if this.repository}}
{{~#if this.owner}}
{{~this.owner}}/
{{~/if}}
{{~this.repository}}
{{~else}}
{{~#if @root.owner}}
{{[email protected]}}/
{{~/if}}
{{[email protected]}}
{{~/if}}
{{~else}}
{{[email protected]}}
{{~/if}}/
{{[email protected]}}/{{this.issue}})
{{~else}}
{{~#if this.owner}}
{{~this.owner}}/
{{~/if}}
{{~this.repository}}#{{this.issue}}
{{~/if}}{{/each}}
{{~/if}}
21 changes: 21 additions & 0 deletions tools/release/changelog-root-template.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{{> header}}

{{#each packageGroups}}
### {{title}}

{{#if breakingChanges.length}}
_Breaking changes:_

{{#each breakingChanges}}
* {{#if commit.scope}}**{{commit.scope}}:** {{/if}}{{text}}
{{/each}}

{{/if}}
| | |
| ---------- | --------------------- |
{{#each commits}}
| {{type}} | {{> commit root=@root}} |
{{/each}}

{{/each}}

151 changes: 125 additions & 26 deletions tools/release/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,33 @@ import {createReadStream, createWriteStream, readFileSync} from 'fs';
import {prompt} from 'inquirer';
import {join} from 'path';
import {Readable} from 'stream';
import {releasePackages} from './release-output/release-packages';

// These imports lack type definitions.
const conventionalChangelog = require('conventional-changelog');
const changelogCompare = require('conventional-changelog-writer/lib/util');
const merge2 = require('merge2');

/** Interface that describes a package in the changelog. */
interface ChangelogPackage {
commits: any[];
breakingChanges: any[];
}

/** Hardcoded order of packages shown in the changelog. */
const changelogPackageOrder = [
'cdk',
'material',
'google-maps',
'youtube-player',
'material-moment-adapter',
'cdk-experimental',
'material-experimental',
];

/** List of packages which are excluded in the changelog. */
const excludedChangelogPackages = ['google-maps'];

/** Prompts for a changelog release name and prepends the new changelog. */
export async function promptAndGenerateChangelog(changelogPath: string) {
const releaseName = await promptChangelogReleaseName();
Expand All @@ -21,11 +43,16 @@ export async function promptAndGenerateChangelog(changelogPath: string) {
*/
export async function prependChangelogFromLatestTag(changelogPath: string, releaseName: string) {
const outputStream: Readable = conventionalChangelog(
/* core options */ {preset: 'angular'},
/* context options */ {title: releaseName},
/* raw-commits options */ null,
/* commit parser options */ null,
/* writer options */ createChangelogWriterOptions(changelogPath));
/* core options */ {preset: 'angular'},
/* context options */ {title: releaseName},
/* raw-commits options */ null,
/* commit parser options */ {
// Expansion of the convention-changelog-angular preset to extract the package
// name from the commit message.
headerPattern: /^(\w*)(?:\((?:([^/]+)\/)?(.*)\))?: (.*)$/,
headerCorrespondence: ['type', 'package', 'scope', 'subject'],
},
/* writer options */ createChangelogWriterOptions(changelogPath));

// Stream for reading the existing changelog. This is necessary because we want to
// actually prepend the new changelog to the existing one.
Expand All @@ -41,19 +68,20 @@ export async function prependChangelogFromLatestTag(changelogPath: string, relea
// read and write from the same source which causes the content to be thrown off.
previousChangelogStream.on('end', () => {
mergedCompleteChangelog.pipe(createWriteStream(changelogPath))
.once('error', (error: any) => reject(error))
.once('finish', () => resolve());
.once('error', (error: any) => reject(error))
.once('finish', () => resolve());
});
});
}

/** Prompts the terminal for a changelog release name. */
export async function promptChangelogReleaseName(): Promise<string> {
return (await prompt<{releaseName: string}>({
type: 'text',
name: 'releaseName',
message: 'What should be the name of the release?'
})).releaseName;
type: 'text',
name: 'releaseName',
message: 'What should be the name of the release?'
}))
.releaseName;
}

/**
Expand All @@ -68,45 +96,116 @@ export async function promptChangelogReleaseName(): Promise<string> {
*/
function createChangelogWriterOptions(changelogPath: string) {
const existingChangelogContent = readFileSync(changelogPath, 'utf8');
const commitSortFunction = changelogCompare.functionify(['type', 'scope', 'subject']);

return {
// Overwrite the changelog templates so that we can render the commits grouped
// by package names. Templates are based on the original templates of the
// angular preset: "conventional-changelog-angular/templates".
mainTemplate: readFileSync(join(__dirname, 'changelog-root-template.hbs'), 'utf8'),
commitPartial: readFileSync(join(__dirname, 'changelog-commit-template.hbs'), 'utf8'),

// Specify a writer option that can be used to modify the content of a new changelog section.
// See: conventional-changelog/tree/master/packages/conventional-changelog-writer
finalizeContext: (context: any) => {
context.commitGroups = context.commitGroups.filter((group: any) => {
group.commits = group.commits.filter((commit: any) => {

// Commits that change things for "cdk-experimental" or "material-experimental" will also
// show up in the changelog by default. We don't want to show these in the changelog.
if (commit.scope && commit.scope.includes('experimental')) {
console.log(yellow(` ↺ Skipping experimental: "${bold(commit.header)}"`));
return false;
}
const packageGroups: {[packageName: string]: ChangelogPackage} = {};

context.commitGroups.forEach((group: any) => {
group.commits.forEach((commit: any) => {
// Filter out duplicate commits. Note that we cannot compare the SHA because the commits
// will have a different SHA if they are being cherry-picked into a different branch.
if (existingChangelogContent.includes(commit.subject)) {
console.log(yellow(` ↺ Skipping duplicate: "${bold(commit.header)}"`));
return false;
}
return true;

// Commits which just specify a scope that refers to a package but do not follow
// the commit format that is parsed by the conventional-changelog-parser, can be
// still resolved to their package from the scope. This handles the case where
// a commit targets the whole package and does not specify a specific scope.
// e.g. "refactor(material-experimental): support strictness flags".
if (!commit.package && commit.scope) {
const matchingPackage = releasePackages.find(pkgName => pkgName === commit.scope);
if (matchingPackage) {
commit.scope = null;
commit.package = matchingPackage;
}
}

// TODO(devversion): once we formalize the commit message format and
// require specifying the "material" package explicitly, we can remove
// the fallback to the "material" package.
const packageName = commit.package || 'material';
const type = getTypeOfCommitGroupDescription(group.title);

if (!packageGroups[packageName]) {
packageGroups[packageName] = {commits: [], breakingChanges: []};
}
const packageGroup = packageGroups[packageName];

packageGroup.breakingChanges.push(...commit.notes);
packageGroup.commits.push({...commit, type});
});
});

// Filter out commit groups which don't have any commits. Commit groups will become
// empty if we filter out all duplicated commits.
return group.commits.length;
const sortedPackageGroupNames =
Object.keys(packageGroups)
.filter(pkgName => !excludedChangelogPackages.includes(pkgName))
.sort(preferredOrderComparator);

context.packageGroups = sortedPackageGroupNames.map(pkgName => {
const packageGroup = packageGroups[pkgName];
return {
title: pkgName,
commits: packageGroup.commits.sort(commitSortFunction),
breakingChanges: packageGroup.breakingChanges,
};
});

return context;
}
};
}

/**
* Comparator function that sorts a given array of strings based on the
* hardcoded changelog package order. Entries which are not hardcoded are
* sorted in alphabetical order after the hardcoded entries.
*/
function preferredOrderComparator(a: string, b: string): number {
const aIndex = changelogPackageOrder.indexOf(a);
const bIndex = changelogPackageOrder.indexOf(b);
// If a package name could not be found in the hardcoded order, it should be
// sorted after the hardcoded entries in alphabetical order.
if (aIndex === -1) {
return bIndex === -1 ? a.localeCompare(b) : 1;
} else if (bIndex === -1) {
return -1;
}
return aIndex - bIndex;
}

/** Gets the type of a commit group description. */
function getTypeOfCommitGroupDescription(description: string): string {
if (description === 'Features') {
return 'feature';
} else if (description === 'Bug Fixes') {
return 'bug fix';
} else if (description === 'Performance Improvements') {
return 'performance';
} else if (description === 'Reverts') {
return 'revert';
} else if (description === 'Documentation') {
return 'docs';
} else if (description === 'Code Refactoring') {
return 'refactor';
}
return description.toLowerCase();
}

/** Entry-point for generating the changelog when called through the CLI. */
if (require.main === module) {
promptAndGenerateChangelog(join(__dirname, '../../CHANGELOG.md')).then(() => {
console.log(green(' ✓ Successfully updated the changelog.'));
});
}