diff --git a/.gitignore b/.gitignore index a2281906f6867..70bbad3393e00 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ yarn-error.log .nzm-* /.versionrc.json +RELEASE_NOTES.md diff --git a/build.sh b/build.sh index 99719cd328874..3bc3228a193f7 100755 --- a/build.sh +++ b/build.sh @@ -93,4 +93,7 @@ if [ "$check_compat" == "true" ]; then /bin/bash scripts/check-api-compatibility.sh fi +# Create the release notes for the current version. These are ephemeral and not saved in source. +node ./scripts/create-release-notes.js + touch $BUILD_INDICATOR diff --git a/scripts/bump.js b/scripts/bump.js index 14bb70fb0a5c8..3daca6711605f 100755 --- a/scripts/bump.js +++ b/scripts/bump.js @@ -74,7 +74,7 @@ async function main() { console.error("🎉 Calling our 'cdk-release' package to make the bump"); console.error("ℹī¸ Set the LEGACY_BUMP env variable to use the old 'standard-version' bump instead"); const cdkRelease = require('@aws-cdk/cdk-release'); - cdkRelease(opts); + cdkRelease.createRelease(opts); } } diff --git a/scripts/create-release-notes.js b/scripts/create-release-notes.js new file mode 100755 index 0000000000000..1e6096c38bc00 --- /dev/null +++ b/scripts/create-release-notes.js @@ -0,0 +1,18 @@ +#!/usr/bin/env node + +const cdkRelease = require('@aws-cdk/cdk-release'); +const ver = require('./resolve-version'); + +async function main() { + await cdkRelease.createReleaseNotes({ + versionFile: ver.versionFile, + changelogFile: ver.changelogFile, + alphaChangelogFile: ver.alphaChangelogFile, + releaseNotesFile: 'RELEASE_NOTES.md', + }); +} + +main().catch(err => { + console.error(err.stack); + process.exit(1); +}); diff --git a/tools/@aws-cdk/cdk-release/lib/index.ts b/tools/@aws-cdk/cdk-release/lib/index.ts index b908e4cccdc7d..d058aec7e98ab 100644 --- a/tools/@aws-cdk/cdk-release/lib/index.ts +++ b/tools/@aws-cdk/cdk-release/lib/index.ts @@ -1,16 +1,17 @@ -import * as path from 'path'; -import * as fs from 'fs-extra'; import { getConventionalCommitsFromGitHistory } from './conventional-commits'; import { defaults } from './defaults'; import { bump } from './lifecycles/bump'; import { writeChangelogs } from './lifecycles/changelog'; import { commit } from './lifecycles/commit'; import { debug, debugObject } from './private/print'; -import { PackageInfo, ReleaseOptions, Versions } from './types'; +import { PackageInfo, ReleaseOptions } from './types'; +import { readVersion } from './versions'; // eslint-disable-next-line @typescript-eslint/no-require-imports const lerna_project = require('@lerna/project'); -module.exports = async function main(opts: ReleaseOptions): Promise { +export * from './release-notes'; + +export async function createRelease(opts: ReleaseOptions): Promise { // handle the default options const args: ReleaseOptions = { ...defaults, @@ -34,15 +35,6 @@ module.exports = async function main(opts: ReleaseOptions): Promise { await commit(args, newVersion.stableVersion, [args.versionFile, ...changelogResults.map(r => r.filePath)]); }; -function readVersion(versionFile: string): Versions { - const versionPath = path.resolve(process.cwd(), versionFile); - const contents = JSON.parse(fs.readFileSync(versionPath, { encoding: 'utf-8' })); - return { - stableVersion: contents.version, - alphaVersion: contents.alphaVersion, - }; -} - function getProjectPackageInfos(): PackageInfo[] { const packages = lerna_project.Project.getPackagesSync(); diff --git a/tools/@aws-cdk/cdk-release/lib/private/files.ts b/tools/@aws-cdk/cdk-release/lib/private/files.ts index d9a68dd894c87..6009633bc72b2 100644 --- a/tools/@aws-cdk/cdk-release/lib/private/files.ts +++ b/tools/@aws-cdk/cdk-release/lib/private/files.ts @@ -1,6 +1,6 @@ import * as fs from 'fs'; -interface WriteFileOpts { +export interface WriteFileOpts { readonly dryRun?: boolean; } diff --git a/tools/@aws-cdk/cdk-release/lib/release-notes.ts b/tools/@aws-cdk/cdk-release/lib/release-notes.ts new file mode 100644 index 0000000000000..4647b4db71533 --- /dev/null +++ b/tools/@aws-cdk/cdk-release/lib/release-notes.ts @@ -0,0 +1,67 @@ +// eslint-disable-next-line @typescript-eslint/no-require-imports +import parseChangelog = require('changelog-parser'); +import { WriteFileOpts, writeFile } from './private/files'; +import { debugObject, LoggingOptions } from './private/print'; +import { Versions } from './types'; +import { readVersion } from './versions'; + +export interface ReleaseNotesOpts { + /** path to the version file for the current branch (e.g., version.v2.json) */ + versionFile: string; + /** path to the primary changelog file (e.g., 'CHANGELOG.v2.md') */ + changelogFile: string; + /** (optional) path to the independent alpha changelog file (e.g., 'CHANGELOG.v2.alpha.md') */ + alphaChangelogFile?: string; + /** path to write out the final release notes (e.g., 'RELEASE_NOTES.md'). */ + releaseNotesFile: string; +} + +/** + * Creates a release notes file from one (or more) changelog files for the current version. + * If an alpha version and alpha changelog file aren't present, this is identical to the contents + * of the (main) changelog for the current version. Otherwise, a combined release is put together + * from the contents of the stable and alpha changelogs. + */ +export async function createReleaseNotes(opts: ReleaseNotesOpts & LoggingOptions & WriteFileOpts) { + const currentVersion = readVersion(opts.versionFile); + debugObject(opts, 'Current version info', currentVersion); + + writeFile(opts, opts.releaseNotesFile, await releaseNoteContents(currentVersion, opts)); +} + +async function releaseNoteContents(currentVersion: Versions, opts: ReleaseNotesOpts) { + const stableChangelogContents = await readChangelogSection(opts.changelogFile, currentVersion.stableVersion); + // If we don't have an alpha version and distinct alpha changelog, the release notes are just the main changelog section. + if (!opts.alphaChangelogFile || !currentVersion.alphaVersion) { return stableChangelogContents; } + + const alphaChangelogContents = await readChangelogSection(opts.alphaChangelogFile, currentVersion.alphaVersion); + + // See https://github.com/aws/aws-cdk-rfcs/blob/master/text/0249-v2-experiments.md#changelog--release-notes for format + return [ + stableChangelogContents, + '---', + `## Alpha modules (${currentVersion.alphaVersion})`, + alphaChangelogContents, + ].join('\n'); +} + +async function readChangelogSection(changelogFile: string, version: string) { + const changelog = await parseChangelog(changelogFile) as Changelog; + const entry = (changelog.versions || []).find(section => section.version === version); + if (!entry) { + throw new Error(`No changelog entry found for version ${version} in ${changelogFile}`); + } + return entry.body; +} + +/** @types/changelog-parser only returns `object`; this is slightly more helpful */ +interface Changelog { + title: string; + description: string; + versions?: ChangelogVersion[]; +} +interface ChangelogVersion { + version: string; + title: string; + body: string; +} diff --git a/tools/@aws-cdk/cdk-release/lib/versions.ts b/tools/@aws-cdk/cdk-release/lib/versions.ts new file mode 100644 index 0000000000000..d92ed61a5f0e5 --- /dev/null +++ b/tools/@aws-cdk/cdk-release/lib/versions.ts @@ -0,0 +1,12 @@ +import * as path from 'path'; +import * as fs from 'fs-extra'; +import { Versions } from './types'; + +export function readVersion(versionFile: string): Versions { + const versionPath = path.resolve(process.cwd(), versionFile); + const contents = JSON.parse(fs.readFileSync(versionPath, { encoding: 'utf-8' })); + return { + stableVersion: contents.version, + alphaVersion: contents.alphaVersion, + }; +} diff --git a/tools/@aws-cdk/cdk-release/package.json b/tools/@aws-cdk/cdk-release/package.json index 9e06c857907db..ed0b5705e94df 100644 --- a/tools/@aws-cdk/cdk-release/package.json +++ b/tools/@aws-cdk/cdk-release/package.json @@ -30,6 +30,7 @@ "devDependencies": { "@aws-cdk/cdk-build-tools": "0.0.0", "@aws-cdk/pkglint": "0.0.0", + "@types/changelog-parser": "^2.7.1", "@types/fs-extra": "^8.1.2", "@types/jest": "^26.0.24", "@types/yargs": "^15.0.14", @@ -37,17 +38,18 @@ }, "dependencies": { "@lerna/project": "^4.0.0", + "changelog-parser": "^2.8.0", "conventional-changelog": "^3.1.24", "conventional-changelog-config-spec": "^2.1.0", "conventional-changelog-preset-loader": "^2.3.4", - "conventional-commits-parser": "^3.2.2", "conventional-changelog-writer": "^4.1.0", + "conventional-commits-parser": "^3.2.2", + "detect-indent": "^6.1.0", + "detect-newline": "^3.1.0", "fs-extra": "^9.1.0", "git-raw-commits": "^2.0.10", "semver": "^7.3.5", - "stringify-package": "^1.0.1", - "detect-indent": "^6.1.0", - "detect-newline": "^3.1.0" + "stringify-package": "^1.0.1" }, "keywords": [ "aws", diff --git a/tools/@aws-cdk/cdk-release/test/release-notes.test.ts b/tools/@aws-cdk/cdk-release/test/release-notes.test.ts new file mode 100644 index 0000000000000..f190b6b84a332 --- /dev/null +++ b/tools/@aws-cdk/cdk-release/test/release-notes.test.ts @@ -0,0 +1,61 @@ +import * as files from '../lib/private/files'; +import { createReleaseNotes } from '../lib/release-notes'; +import * as versions from '../lib/versions'; + +/** MOCKS */ +const mockWriteFile = jest.spyOn(files, 'writeFile').mockImplementation(() => jest.fn()); +const mockReadVersion = jest.spyOn(versions, 'readVersion'); +jest.mock('changelog-parser', () => { return jest.fn(); }); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const changelogParser = require('changelog-parser'); +/** MOCKS */ + +beforeEach(() => { jest.resetAllMocks(); }); + +const DEFAULT_OPTS = { + changelogFile: 'CHANGELOG.md', + releaseNotesFile: 'RELEASE_NOTES.md', + versionFile: 'versions.json', +}; + +test('without alpha releases, only the stable changelog is returned', async () => { + mockReadVersion.mockImplementation((_) => { return { stableVersion: '1.2.3' }; }); + mockChangelogOnceForVersion('1.2.3', 'foo'); + + await createReleaseNotes(DEFAULT_OPTS); + + expectReleaseNotes('foo'); +}); + +test('with alpha releases the contents of both are returned as separate sections', async () => { + mockReadVersion.mockImplementation((_) => { return { stableVersion: '1.2.3', alphaVersion: '1.2.3-alpha' }; }); + mockChangelogOnceForVersion('1.2.3', 'foo'); // stable + mockChangelogOnceForVersion('1.2.3-alpha', 'bar'); // alpha + + await createReleaseNotes({ ...DEFAULT_OPTS, alphaChangelogFile: 'CHANGELOG.alpha.md' }); + + expectReleaseNotes([ + 'foo', + '---', + '## Alpha modules (1.2.3-alpha)', + 'bar', + ]); +}); + +test('throws if no matching version is found in the changelog', async () => { + mockReadVersion.mockImplementation((_) => { return { stableVersion: '1.2.3' }; }); + mockChangelogOnceForVersion('4.5.6', 'foo'); + + await expect(createReleaseNotes(DEFAULT_OPTS)) + .rejects + .toThrow(/No changelog entry found for version 1.2.3 in CHANGELOG.md/); +}); + +function mockChangelogOnceForVersion(version: string, body: string) { + changelogParser.mockImplementationOnce((_: string) => { return { versions: [{ version, body }] }; }); +} + +function expectReleaseNotes(contents: string | string[]) { + const data = (typeof contents === 'string') ? contents : contents.join('\n'); + expect(mockWriteFile).toBeCalledWith(expect.any(Object), 'RELEASE_NOTES.md', data); +} diff --git a/yarn.lock b/yarn.lock index c6815c2021b43..fbc4c23d60599 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1583,7 +1583,7 @@ dependencies: "@types/glob" "*" -"@types/aws-lambda@^8.10.84": +"@types/aws-lambda@^8.10.83", "@types/aws-lambda@^8.10.84": version "8.10.84" resolved "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.84.tgz#b1f391ceeb6908b28d8416d93f27afe8d1348d4e" integrity sha512-5V78eLtmN0d4RA14hKDwcsMQRl3JotQJlhGFDBo/jdE2TyDFRaYwB/UmMUC4SzhSvRGn+YMkh7jGPnXi8COAng== @@ -1621,6 +1621,11 @@ dependencies: "@babel/types" "^7.3.0" +"@types/changelog-parser@^2.7.1": + version "2.7.1" + resolved "https://registry.npmjs.org/@types/changelog-parser/-/changelog-parser-2.7.1.tgz#da124373fc8abfb6951fef83718ea5f041fea527" + integrity sha512-OFZB7OlG6nrkcnvJhcyV2Zm/PUGk40oHyfaEBRjlm+ghrKxbFQI+xao/IzYL0G72fpLCTGGs3USrhe38/FF6QQ== + "@types/eslint@^7.28.1": version "7.28.1" resolved "https://registry.npmjs.org/@types/eslint/-/eslint-7.28.1.tgz#50b07747f1f84c2ba8cd394cf0fe0ba07afce320" @@ -2705,6 +2710,14 @@ chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2: ansi-styles "^4.1.0" supports-color "^7.1.0" +changelog-parser@^2.8.0: + version "2.8.0" + resolved "https://registry.npmjs.org/changelog-parser/-/changelog-parser-2.8.0.tgz#c14293e3e8fab797913c722de965480198650108" + integrity sha512-ZtSwN0hY7t+WpvaXqqXz98RHCNhWX9HsvCRAv1aBLlqJ7BpKtqdM6Nu6JOiUhRAWR7Gov0aN0fUnmflTz0WgZg== + dependencies: + line-reader "^0.2.4" + remove-markdown "^0.2.2" + char-regex@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" @@ -6334,6 +6347,11 @@ lie@~3.3.0: dependencies: immediate "~3.0.5" +line-reader@^0.2.4: + version "0.2.4" + resolved "https://registry.npmjs.org/line-reader/-/line-reader-0.2.4.tgz#c4392b587dea38580c9678570e6e8e49fce52622" + integrity sha1-xDkrWH3qOFgMlnhXDm6OSfzlJiI= + lines-and-columns@^1.1.6: version "1.1.6" resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" @@ -8211,6 +8229,11 @@ release-zalgo@^1.0.0: dependencies: es6-error "^4.0.1" +remove-markdown@^0.2.2: + version "0.2.2" + resolved "https://registry.npmjs.org/remove-markdown/-/remove-markdown-0.2.2.tgz#66b0ceeba9fb77ca9636bb1b0307ce21a32a12a6" + integrity sha1-ZrDO66n7d8qWNrsbAwfOIaMqEqY= + remove-trailing-separator@^1.0.1: version "1.1.0" resolved "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" @@ -9004,7 +9027,7 @@ symbol-tree@^3.2.4: resolved "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== -table@*, table@^6.0.9, table@^6.7.2: +table@*, table@^6.0.9, table@^6.7.1, table@^6.7.2: version "6.7.2" resolved "https://registry.npmjs.org/table/-/table-6.7.2.tgz#a8d39b9f5966693ca8b0feba270a78722cbaf3b0" integrity sha512-UFZK67uvyNivLeQbVtkiUs8Uuuxv24aSL4/Vil2PJVtMgU8Lx0CYkP12uCGa3kjyQzOSgV1+z9Wkb82fCGsO0g==