From 39423322c1fffe247b6a7d48efadd248b9516a65 Mon Sep 17 00:00:00 2001 From: James Henry Date: Sat, 21 Oct 2023 00:27:03 +0400 Subject: [PATCH 1/3] feat(core): support changelog customizations and project changelogs (#19704) --- docs/generated/cli/release.md | 22 +- docs/generated/devkit/FileChange.md | 4 +- .../packages/nx/documents/release.md | 22 +- e2e/release/src/release.test.ts | 99 ++- packages/nx/changelog-renderer/index.spec.ts | 333 ++++++++++ packages/nx/changelog-renderer/index.ts | 275 ++++++++ packages/nx/schemas/nx-schema.json | 141 ++++ .../nx/src/command-line/release/changelog.ts | 592 ++++++++++++++--- .../command-line/release/command-object.ts | 27 +- .../release/config/config.spec.ts | 600 +++++++++++++++++- .../src/command-line/release/config/config.ts | 254 ++++++-- .../config/filter-release-groups.spec.ts | 117 ++++ .../release/utils/markdown.spec.ts | 242 +------ .../command-line/release/utils/markdown.ts | 176 +---- .../release/utils/print-changes.ts | 6 +- packages/nx/src/config/nx-json.ts | 87 ++- 16 files changed, 2326 insertions(+), 671 deletions(-) create mode 100644 packages/nx/changelog-renderer/index.spec.ts create mode 100644 packages/nx/changelog-renderer/index.ts diff --git a/docs/generated/cli/release.md b/docs/generated/cli/release.md index 27318bcefeb38..e0b7d91e76302 100644 --- a/docs/generated/cli/release.md +++ b/docs/generated/cli/release.md @@ -101,22 +101,6 @@ nx release changelog [version] #### Options -##### createRelease - -Type: `string` - -Choices: [github] - -Create a release for the given version on a supported source control service provider, such as Github. - -##### file - -Type: `string` - -Default: `CHANGELOG.md` - -The name of the file to write the changelog to. It can also be set to `false` to disable file generation. Defaults to CHANGELOG.md. - ##### from Type: `string` @@ -139,7 +123,11 @@ Show help ##### interactive -Type: `boolean` +Type: `string` + +Choices: [all, workspace, projects] + +Interactively modify changelog markdown contents in your code editor before applying the changes. You can set it to be interactive for all changelogs, or only the workspace level, or only the project level ##### tagVersionPrefix diff --git a/docs/generated/devkit/FileChange.md b/docs/generated/devkit/FileChange.md index c98fb96f15e05..b82b81b4f4a62 100644 --- a/docs/generated/devkit/FileChange.md +++ b/docs/generated/devkit/FileChange.md @@ -9,7 +9,7 @@ Description of a file change in the Nx virtual file system/ - [content](../../devkit/documents/FileChange#content): Buffer - [options](../../devkit/documents/FileChange#options): TreeWriteOptions - [path](../../devkit/documents/FileChange#path): string -- [type](../../devkit/documents/FileChange#type): "CREATE" | "DELETE" | "UPDATE" +- [type](../../devkit/documents/FileChange#type): "DELETE" | "CREATE" | "UPDATE" ## Properties @@ -39,6 +39,6 @@ Path relative to the workspace root ### type -• **type**: `"CREATE"` \| `"DELETE"` \| `"UPDATE"` +• **type**: `"DELETE"` \| `"CREATE"` \| `"UPDATE"` Type of change: 'CREATE' | 'DELETE' | 'UPDATE' diff --git a/docs/generated/packages/nx/documents/release.md b/docs/generated/packages/nx/documents/release.md index 27318bcefeb38..e0b7d91e76302 100644 --- a/docs/generated/packages/nx/documents/release.md +++ b/docs/generated/packages/nx/documents/release.md @@ -101,22 +101,6 @@ nx release changelog [version] #### Options -##### createRelease - -Type: `string` - -Choices: [github] - -Create a release for the given version on a supported source control service provider, such as Github. - -##### file - -Type: `string` - -Default: `CHANGELOG.md` - -The name of the file to write the changelog to. It can also be set to `false` to disable file generation. Defaults to CHANGELOG.md. - ##### from Type: `string` @@ -139,7 +123,11 @@ Show help ##### interactive -Type: `boolean` +Type: `string` + +Choices: [all, workspace, projects] + +Interactively modify changelog markdown contents in your code editor before applying the changes. You can set it to be interactive for all changelogs, or only the workspace level, or only the project level ##### tagVersionPrefix diff --git a/e2e/release/src/release.test.ts b/e2e/release/src/release.test.ts index 7341ee28df1d3..3f28cc0372356 100644 --- a/e2e/release/src/release.test.ts +++ b/e2e/release/src/release.test.ts @@ -30,6 +30,7 @@ expect.addSnapshotSerializer({ .replaceAll(/\d*B package\.json/g, 'XXXB package.json') .replaceAll(/size:\s*\d*\s?B/g, 'size: XXXB') .replaceAll(/\d*\.\d*\s?kB/g, 'XXX.XXX kb') + .replaceAll(/[a-fA-F0-9]{7}/g, '{COMMIT_SHA}') // We trim each line to reduce the chances of snapshot flakiness .split('\n') .map((r) => r.trim()) @@ -122,10 +123,10 @@ describe('nx release', () => { const changelogOutput = runCLI(`release changelog 999.9.9`); expect(changelogOutput).toMatchInlineSnapshot(` - > NX Generating a CHANGELOG.md entry for v999.9.9 + > NX Generating an entry in CHANGELOG.md for v999.9.9 - + ## v999.9.9 + + ## 999.9.9 + + + ### 🚀 Features @@ -140,7 +141,7 @@ describe('nx release', () => { `); expect(readFile('CHANGELOG.md')).toMatchInlineSnapshot(` - ## v999.9.9 + ## 999.9.9 ### 🚀 Features @@ -544,6 +545,98 @@ describe('nx release', () => { .trim() ).toEqual('1000.0.0-next.0'); + // Update custom nx release config to demonstrate project level changelogs + updateJson('nx.json', (nxJson) => { + nxJson.release = { + groups: { + default: { + // @proj/source will be added as a project by the verdaccio setup, but we aren't versioning or publishing it, so we exclude it here + projects: ['*', '!@proj/source'], + changelog: { + // This should be merged with and take priority over the projectChangelogs config at the root of the config + createRelease: 'github', + }, + }, + }, + changelog: { + projectChangelogs: { + renderOptions: { + createRelease: false, // will be overridden by the group + // Customize the changelog renderer to not print the Thank You section this time (not overridden by the group) + includeAuthors: false, + }, + }, + }, + }; + return nxJson; + }); + + // We need a valid git origin for the command to work when createRelease is set + await runCommandAsync( + `git remote add origin https://github.com/nrwl/fake-repo.git` + ); + + // Perform a dry-run this time to show that it works but also prevent making any requests to github within the test + const changelogDryRunOutput = runCLI( + `release changelog 1000.0.0-next.0 --dry-run` + ); + expect(changelogDryRunOutput).toMatchInlineSnapshot(` + + > NX Previewing an entry in CHANGELOG.md for v1000.0.0-next.0 + + + + + ## 1000.0.0-next.0 + + + + + + ### 🚀 Features + + + + - an awesome new feature + + + + ### ❤️ Thank You + + + + - Test + + + ## 999.9.9 + + + + + > NX Previewing a Github release and an entry in {project-name}/CHANGELOG.md for {project-name}@v1000.0.0-next.0 + + + + ## 1000.0.0-next.0 + + + + + + ### 🚀 Features + + + + - an awesome new feature ([{COMMIT_SHA}](https://github.com/nrwl/fake-repo/commit/{COMMIT_SHA})) + + + > NX Previewing a Github release and an entry in {project-name}/CHANGELOG.md for {project-name}@v1000.0.0-next.0 + + + + ## 1000.0.0-next.0 + + + + + + ### 🚀 Features + + + + - an awesome new feature ([{COMMIT_SHA}](https://github.com/nrwl/fake-repo/commit/{COMMIT_SHA})) + + + > NX Previewing a Github release and an entry in {project-name}/CHANGELOG.md for {project-name}@v1000.0.0-next.0 + + + + ## 1000.0.0-next.0 + + + + + + ### 🚀 Features + + + + - an awesome new feature ([{COMMIT_SHA}](https://github.com/nrwl/fake-repo/commit/{COMMIT_SHA})) + + + `); + // port and process cleanup await killProcessAndPorts(process.pid, verdaccioPort); }, 500000); diff --git a/packages/nx/changelog-renderer/index.spec.ts b/packages/nx/changelog-renderer/index.spec.ts new file mode 100644 index 0000000000000..815189cc6b576 --- /dev/null +++ b/packages/nx/changelog-renderer/index.spec.ts @@ -0,0 +1,333 @@ +import type { GitCommit } from '../src/command-line/release/utils/git'; +import defaultChangelogRenderer from './index'; + +describe('defaultChangelogRenderer()', () => { + const commits: GitCommit[] = [ + { + message: 'fix: all packages fixed', + shortHash: '4130f65', + author: { + name: 'James Henry', + email: 'jh@example.com', + }, + body: '"\n\nM\tpackages/pkg-a/src/index.ts\nM\tpackages/pkg-b/src/index.ts\n"', + authors: [ + { + name: 'James Henry', + email: 'jh@example.com', + }, + ], + description: 'all packages fixed', + type: 'fix', + scope: '', + references: [ + { + value: '4130f65', + type: 'hash', + }, + ], + isBreaking: false, + }, + { + message: 'feat(pkg-b): and another new capability', + shortHash: '7dc5ec3', + author: { + name: 'James Henry', + email: 'jh@example.com', + }, + body: '"\n\nM\tpackages/pkg-b/src/index.ts\n"', + authors: [ + { + name: 'James Henry', + email: 'jh@example.com', + }, + ], + description: 'and another new capability', + type: 'feat', + scope: 'pkg-b', + references: [ + { + value: '7dc5ec3', + type: 'hash', + }, + ], + isBreaking: false, + }, + { + message: 'feat(pkg-a): new hotness', + shortHash: 'd7a58a2', + author: { + name: 'James Henry', + email: 'jh@example.com', + }, + body: '"\n\nM\tpackages/pkg-a/src/index.ts\n"', + authors: [ + { + name: 'James Henry', + email: 'jh@example.com', + }, + ], + description: 'new hotness', + type: 'feat', + scope: 'pkg-a', + references: [ + { + value: 'd7a58a2', + type: 'hash', + }, + ], + isBreaking: false, + }, + { + message: 'feat(pkg-b): brand new thing', + shortHash: 'feace4a', + author: { + name: 'James Henry', + email: 'jh@example.com', + }, + body: '"\n\nM\tpackages/pkg-b/src/index.ts\n"', + authors: [ + { + name: 'James Henry', + email: 'jh@example.com', + }, + ], + description: 'brand new thing', + type: 'feat', + scope: 'pkg-b', + references: [ + { + value: 'feace4a', + type: 'hash', + }, + ], + isBreaking: false, + }, + { + message: 'fix(pkg-a): squashing bugs', + shortHash: '6301405', + author: { + name: 'James Henry', + email: 'jh@example.com', + }, + body: '"\n\nM\tpackages/pkg-a/src/index.ts\n', + authors: [ + { + name: 'James Henry', + email: 'jh@example.com', + }, + ], + description: 'squashing bugs', + type: 'fix', + scope: 'pkg-a', + references: [ + { + value: '6301405', + type: 'hash', + }, + ], + isBreaking: false, + }, + ]; + + describe('workspaceChangelog', () => { + it('should generate markdown for all projects by organizing commits by type, then grouped by scope within the type (sorted alphabetically), then chronologically within the scope group', async () => { + const markdown = await defaultChangelogRenderer({ + commits, + releaseVersion: 'v1.1.0', + project: null, + entryWhenNoChanges: false, + changelogRenderOptions: { + includeAuthors: true, + }, + }); + expect(markdown).toMatchInlineSnapshot(` + "## v1.1.0 + + + ### 🚀 Features + + - **pkg-a:** new hotness + - **pkg-b:** brand new thing + - **pkg-b:** and another new capability + + ### 🩹 Fixes + + - all packages fixed + - **pkg-a:** squashing bugs + + ### ❤️ Thank You + + - James Henry" + `); + }); + + it('should not generate a Thank You section when changelogRenderOptions.includeAuthors is false', async () => { + const markdown = await defaultChangelogRenderer({ + commits, + releaseVersion: 'v1.1.0', + project: null, + entryWhenNoChanges: false, + changelogRenderOptions: { + includeAuthors: false, + }, + }); + expect(markdown).toMatchInlineSnapshot(` + "## v1.1.0 + + + ### 🚀 Features + + - **pkg-a:** new hotness + - **pkg-b:** brand new thing + - **pkg-b:** and another new capability + + ### 🩹 Fixes + + - all packages fixed + - **pkg-a:** squashing bugs" + `); + }); + }); + + describe('project level configs', () => { + it('should generate markdown for the given project by organizing commits by type, then chronologically', async () => { + const otherOpts = { + commits, + releaseVersion: 'v1.1.0', + entryWhenNoChanges: false as const, + changelogRenderOptions: { + includeAuthors: true, + }, + }; + + expect( + await defaultChangelogRenderer({ + ...otherOpts, + project: 'pkg-a', + }) + ).toMatchInlineSnapshot(` + "## v1.1.0 + + + ### 🚀 Features + + - **pkg-a:** new hotness + + + ### 🩹 Fixes + + - **pkg-a:** squashing bugs + + + ### ❤️ Thank You + + - James Henry" + `); + + expect( + await defaultChangelogRenderer({ + ...otherOpts, + project: 'pkg-a', + // test that the includeAuthors option is being respected for project changelogs and therefore no Thank You section exists + changelogRenderOptions: { + includeAuthors: false, + }, + }) + ).toMatchInlineSnapshot(` + "## v1.1.0 + + + ### 🚀 Features + + - **pkg-a:** new hotness + + + ### 🩹 Fixes + + - **pkg-a:** squashing bugs" + `); + + expect( + await defaultChangelogRenderer({ + ...otherOpts, + project: 'pkg-b', + }) + ).toMatchInlineSnapshot(` + "## v1.1.0 + + + ### 🚀 Features + + - **pkg-b:** brand new thing + + - **pkg-b:** and another new capability + + + ### ❤️ Thank You + + - James Henry" + `); + }); + }); + + describe('entryWhenNoChanges', () => { + it('should respect the entryWhenNoChanges option for the workspace changelog', async () => { + const otherOpts = { + commits: [], + releaseVersion: 'v1.1.0', + project: null, // workspace changelog + changelogRenderOptions: { + includeAuthors: true, + }, + }; + + expect( + await defaultChangelogRenderer({ + ...otherOpts, + entryWhenNoChanges: 'Nothing at all!', + }) + ).toMatchInlineSnapshot(` + "## v1.1.0 + + Nothing at all!" + `); + + expect( + await defaultChangelogRenderer({ + ...otherOpts, + entryWhenNoChanges: false, // should not create an entry + }) + ).toMatchInlineSnapshot(`""`); + }); + + it('should respect the entryWhenNoChanges option for project changelogs', async () => { + const otherOpts = { + commits: [], + releaseVersion: 'v1.1.0', + project: 'pkg-a', + changelogRenderOptions: { + includeAuthors: true, + }, + }; + + expect( + await defaultChangelogRenderer({ + ...otherOpts, + entryWhenNoChanges: 'Nothing at all!', + }) + ).toMatchInlineSnapshot(` + "## v1.1.0 + + Nothing at all!" + `); + + expect( + await defaultChangelogRenderer({ + ...otherOpts, + entryWhenNoChanges: false, // should not create an entry + }) + ).toMatchInlineSnapshot(`""`); + }); + }); +}); diff --git a/packages/nx/changelog-renderer/index.ts b/packages/nx/changelog-renderer/index.ts new file mode 100644 index 0000000000000..16961742f5c10 --- /dev/null +++ b/packages/nx/changelog-renderer/index.ts @@ -0,0 +1,275 @@ +import type { GitCommit } from '../src/command-line/release/utils/git'; +import { + RepoSlug, + formatReferences, +} from '../src/command-line/release/utils/github'; + +// axios types and values don't seem to match +import _axios = require('axios'); +const axios = _axios as any as typeof _axios['default']; + +/** + * The ChangelogRenderOptions are specific to each ChangelogRenderer implementation, and are taken + * from the user's nx.json configuration and passed as is into the ChangelogRenderer function. + */ +export type ChangelogRenderOptions = Record; + +/** + * A ChangelogRenderer function takes in the extracted commits and other relevant metadata + * and returns a string, or a Promise of a string of changelog contents (usually markdown). + * + * @param {Object} config The configuration object for the ChangelogRenderer + * @param {GitCommit[]} config.commits The collection of extracted commits to generate a changelog for + * @param {string} config.releaseVersion The version that is being released + * @param {string | null} config.project The name of specific project to generate a changelog for, or `null` if the overall workspace changelog + * @param {string | false} config.entryWhenNoChanges The (already interpolated) string to use as the changelog entry when there are no changes, or `false` if no entry should be generated + * @param {ChangelogRenderOptions} config.changelogRenderOptions The options specific to the ChangelogRenderer implementation + */ +export type ChangelogRenderer = (config: { + commits: GitCommit[]; + releaseVersion: string; + project: string | null; + entryWhenNoChanges: string | false; + changelogRenderOptions: DefaultChangelogRenderOptions; + repoSlug?: RepoSlug; +}) => Promise | string; + +/** + * The specific options available to the default implementation of the ChangelogRenderer that nx exports + * for the common case. + */ +export interface DefaultChangelogRenderOptions extends ChangelogRenderOptions { + /** + * Whether or not the commit authors should be added to the bottom of the changelog in a "Thank You" + * section. Defaults to true. + */ + includeAuthors?: boolean; +} + +/** + * The default ChangelogRenderer implementation that nx exports for the common case of generating markdown + * from the given commits and other metadata. + */ +const defaultChangelogRenderer: ChangelogRenderer = async ({ + commits, + releaseVersion, + project, + entryWhenNoChanges, + changelogRenderOptions, + repoSlug, +}): Promise => { + const markdownLines: string[] = []; + const breakingChanges = []; + + const commitTypes = { + feat: { title: '🚀 Features' }, + perf: { title: '🔥 Performance' }, + fix: { title: '🩹 Fixes' }, + refactor: { title: '💅 Refactors' }, + docs: { title: '📖 Documentation' }, + build: { title: '📦 Build' }, + types: { title: '🌊 Types' }, + chore: { title: '🏡 Chore' }, + examples: { title: '🏀 Examples' }, + test: { title: '✅ Tests' }, + style: { title: '🎨 Styles' }, + ci: { title: '🤖 CI' }, + }; + + // workspace root level changelog + if (project === null) { + // No changes for the workspace + if (commits.length === 0) { + if (entryWhenNoChanges) { + markdownLines.push( + '', + `## ${releaseVersion}\n\n${entryWhenNoChanges}`, + '' + ); + } + return markdownLines.join('\n').trim(); + } + + const typeGroups = groupBy(commits, 'type'); + + markdownLines.push('', `## ${releaseVersion}`, ''); + + for (const type of Object.keys(commitTypes)) { + const group = typeGroups[type]; + if (!group || group.length === 0) { + continue; + } + + markdownLines.push('', '### ' + commitTypes[type].title, ''); + + /** + * In order to make the final changelog most readable, we organize commits as follows: + * - By scope, where scopes are in alphabetical order (commits with no scope are listed first) + * - Within a particular scope grouping, we list commits in chronological order + */ + const commitsInChronologicalOrder = group.reverse(); + const commitsGroupedByScope = groupBy( + commitsInChronologicalOrder, + 'scope' + ); + const scopesSortedAlphabetically = Object.keys( + commitsGroupedByScope + ).sort(); + + for (const scope of scopesSortedAlphabetically) { + const commits = commitsGroupedByScope[scope]; + for (const commit of commits) { + const line = formatCommit(commit, repoSlug); + markdownLines.push(line); + if (commit.isBreaking) { + breakingChanges.push(line); + } + } + } + } + } else { + // project level changelog + const scopeGroups = groupBy(commits, 'scope'); + + // Generating for a named project, but that project has no changes in the current set of commits, exit early + if (!scopeGroups[project]) { + if (entryWhenNoChanges) { + markdownLines.push( + '', + `## ${releaseVersion}\n\n${entryWhenNoChanges}`, + '' + ); + } + return markdownLines.join('\n').trim(); + } + + markdownLines.push('', `## ${releaseVersion}`, ''); + + const typeGroups = groupBy(scopeGroups[project], 'type'); + for (const type of Object.keys(commitTypes)) { + const group = typeGroups[type]; + if (!group || group.length === 0) { + continue; + } + + markdownLines.push('', `### ${commitTypes[type].title}`, ''); + + const commitsInChronologicalOrder = group.reverse(); + for (const commit of commitsInChronologicalOrder) { + const line = formatCommit(commit, repoSlug); + markdownLines.push(line + '\n'); + if (commit.isBreaking) { + breakingChanges.push(line); + } + } + } + } + + if (breakingChanges.length > 0) { + markdownLines.push('', '#### ⚠️ Breaking Changes', '', ...breakingChanges); + } + + if (changelogRenderOptions.includeAuthors) { + const _authors = new Map; github?: string }>(); + for (const commit of commits) { + if (!commit.author) { + continue; + } + const name = formatName(commit.author.name); + if (!name || name.includes('[bot]')) { + continue; + } + if (_authors.has(name)) { + const entry = _authors.get(name); + entry.email.add(commit.author.email); + } else { + _authors.set(name, { email: new Set([commit.author.email]) }); + } + } + + // Try to map authors to github usernames + if (repoSlug) { + await Promise.all( + [..._authors.keys()].map(async (authorName) => { + const meta = _authors.get(authorName); + for (const email of meta.email) { + // For these pseudo-anonymized emails we can just extract the Github username from before the @ + // It could either be in the format: username@ or github_id+username@ + if (email.endsWith('@users.noreply.github.com')) { + const match = email.match( + /^(\d+\+)?([^@]+)@users\.noreply\.github\.com$/ + ); + if (match && match[2]) { + meta.github = match[2]; + break; + } + } + // Look up any other emails against the ungh.cc API + const { data } = await axios + .get( + `https://ungh.cc/users/find/${email}` + ) + .catch(() => ({ data: { user: null } })); + if (data?.user) { + meta.github = data.user.username; + break; + } + } + }) + ); + } + + const authors = [..._authors.entries()].map((e) => ({ + name: e[0], + ...e[1], + })); + + if (authors.length > 0) { + markdownLines.push( + '', + '### ' + '❤️ Thank You', + '', + ...authors + // Sort the contributors by name + .sort((a, b) => a.name.localeCompare(b.name)) + .map((i) => { + // Tag the author's Github username if we were able to resolve it so that Github adds them as a contributor + const github = i.github ? ` @${i.github}` : ''; + return `- ${i.name}${github}`; + }) + ); + } + } + + return markdownLines.join('\n').trim(); +}; + +export default defaultChangelogRenderer; + +function formatName(name = '') { + return name + .split(' ') + .map((p) => p.trim()) + .join(' '); +} + +function groupBy(items: any[], key: string) { + const groups = {}; + for (const item of items) { + groups[item[key]] = groups[item[key]] || []; + groups[item[key]].push(item); + } + return groups; +} + +function formatCommit(commit: GitCommit, repoSlug?: RepoSlug): string { + let commitLine = + '- ' + + (commit.scope ? `**${commit.scope.trim()}:** ` : '') + + (commit.isBreaking ? '⚠️ ' : '') + + commit.description; + if (repoSlug) { + commitLine += formatReferences(commit.references, repoSlug); + } + return commitLine; +} diff --git a/packages/nx/schemas/nx-schema.json b/packages/nx/schemas/nx-schema.json index 6d27dac5d7297..1e3df3ecb154d 100644 --- a/packages/nx/schemas/nx-schema.json +++ b/packages/nx/schemas/nx-schema.json @@ -95,6 +95,86 @@ "useDaemonProcess": { "type": "boolean", "description": "Specifies whether the daemon should be used for the default tasks runner." + }, + "release": { + "type": "object", + "description": "Configuration for the nx release commands.", + "additionalProperties": false, + "properties": { + "groups": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "projects": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + }, + { + "type": "string" + } + ] + }, + "version": { + "$ref": "#/definitions/NxReleaseVersionConfiguration" + }, + "changelog": { + "oneOf": [ + { + "$ref": "#/definitions/NxReleaseChangelogConfiguration" + }, + { + "type": "boolean", + "enum": [false] + } + ] + }, + "releaseTagPattern": { + "type": "string" + } + }, + "required": ["projects"] + } + }, + "changelog": { + "type": "object", + "properties": { + "workspaceChangelog": { + "oneOf": [ + { + "$ref": "#/definitions/NxReleaseChangelogConfiguration" + }, + { + "type": "boolean", + "enum": [false] + } + ] + }, + "projectChangelogs": { + "oneOf": [ + { + "$ref": "#/definitions/NxReleaseChangelogConfiguration" + }, + { + "type": "boolean", + "enum": [false] + } + ] + } + } + }, + "version": { + "$ref": "#/definitions/NxReleaseVersionConfiguration" + }, + "releaseTagPattern": { + "type": "string" + } + } } }, "definitions": { @@ -389,6 +469,67 @@ } } ] + }, + "NxReleaseVersionConfiguration": { + "type": "object", + "properties": { + "generator": { + "type": "string" + }, + "generatorOptions": { + "type": "object", + "additionalProperties": true + } + } + }, + "NxReleaseChangelogConfiguration": { + "type": "object", + "properties": { + "createRelease": { + "oneOf": [ + { + "type": "string", + "enum": ["github"] + }, + { + "type": "boolean", + "enum": [false] + } + ] + }, + "entryWhenNoChanges": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "boolean", + "enum": [false] + } + ] + }, + "file": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "boolean", + "enum": [false] + } + ] + }, + "renderer": { + "type": "string" + }, + "renderOptions": { + "$ref": "#/definitions/ChangelogRenderOptions" + } + } + }, + "ChangelogRenderOptions": { + "type": "object", + "additionalProperties": true } } } diff --git a/packages/nx/src/command-line/release/changelog.ts b/packages/nx/src/command-line/release/changelog.ts index 9f8e4b25efe08..5645a5410442a 100644 --- a/packages/nx/src/command-line/release/changelog.ts +++ b/packages/nx/src/command-line/release/changelog.ts @@ -1,13 +1,26 @@ import * as chalk from 'chalk'; import { readFileSync, writeFileSync } from 'node:fs'; -import { prerelease } from 'semver'; +import { prerelease, valid } from 'semver'; import { dirSync } from 'tmp'; -import { FsTree } from '../../generators/tree'; +import type { ChangelogRenderer } from '../../../changelog-renderer'; +import { readNxJson } from '../../config/nx-json'; +import { ProjectGraphProjectNode } from '../../config/project-graph'; +import { FsTree, Tree } from '../../generators/tree'; +import { registerTsProject } from '../../plugins/js/utils/register'; +import { createProjectGraphAsync } from '../../project-graph/project-graph'; +import { interpolate } from '../../tasks-runner/utils'; import { logger } from '../../utils/logger'; import { output } from '../../utils/output'; import { joinPathFragments } from '../../utils/path'; +import { getRootTsConfigPath } from '../../utils/typescript'; import { workspaceRoot } from '../../utils/workspace-root'; import { ChangelogOptions } from './command-object'; +import { + NxReleaseConfig, + createNxReleaseConfig, + handleNxReleaseConfigError, +} from './config/config'; +import { filterReleaseGroups } from './config/filter-release-groups'; import { GitCommit, getGitDiff, @@ -17,44 +30,81 @@ import { import { GithubRelease, GithubRequestConfig, - RepoSlug, createOrUpdateGithubRelease, getGitHubRepoSlug, getGithubReleaseByTag, resolveGithubToken, } from './utils/github'; import { launchEditor } from './utils/launch-editor'; -import { generateMarkdown, parseChangelogMarkdown } from './utils/markdown'; +import { parseChangelogMarkdown } from './utils/markdown'; import { printChanges, printDiff } from './utils/print-changes'; +class ReleaseVersion { + rawVersion: string; + gitTag: string; + isPrerelease: boolean; + + constructor({ + version, // short form version string with no prefixes or patterns, e.g. 1.0.0 + releaseTagPattern, // full pattern to interpolate, e.g. "v{version}" or "{projectName}@{version}" + projectName, // optional project name to interpolate into the releaseTagPattern + }: { + version: string; + releaseTagPattern: string; + projectName?: string; + }) { + this.rawVersion = version; + this.gitTag = interpolate(releaseTagPattern, { + version, + projectName, + }); + this.isPrerelease = isPrerelease(version); + } +} + export async function changelogHandler(args: ChangelogOptions): Promise { - /** - * TODO: allow the prefix and version to be controllable via config as well once we flesh out - * changelog customization, and how it will interact with independently released projects. - */ - const tagVersionPrefix = args.tagVersionPrefix ?? 'v'; - // Allow the user to pass the version with or without the prefix already applied - const releaseVersion = args.version.startsWith(tagVersionPrefix) - ? args.version - : `${tagVersionPrefix}${args.version}`; + // Right now, the given version must be valid semver in order to proceed + if (!valid(args.version)) { + output.error({ + title: `The given version "${args.version}" is not a valid semver version. Please provide your version in the format "1.0.0", "1.0.0-beta.1" etc`, + }); + process.exit(1); + } - // We are either creating/previewing a changelog file, a Github release, or both - let logTitle = args.dryRun ? 'Previewing a ' : 'Generating a '; - switch (true) { - case args.file !== false && args.createRelease === 'github': - logTitle += `${args.file} entry and a Github release for ${chalk.white( - releaseVersion - )}`; - break; - case args.file !== false: - logTitle += `${args.file} entry for ${chalk.white(releaseVersion)}`; - break; - case args.createRelease === 'github': - logTitle += `Github release for ${chalk.white(releaseVersion)}`; + const projectGraph = await createProjectGraphAsync({ exitOnError: true }); + const nxJson = readNxJson(); + + if (args.verbose) { + process.env.NX_VERBOSE_LOGGING = 'true'; } - output.log({ - title: logTitle, + // Apply default configuration to any optional user configuration + const { error: configError, nxReleaseConfig } = await createNxReleaseConfig( + projectGraph, + nxJson.release + ); + if (configError) { + return await handleNxReleaseConfigError(configError); + } + + const { + error: filterError, + releaseGroups, + releaseGroupToFilteredProjects, + } = filterReleaseGroups( + projectGraph, + nxReleaseConfig, + args.projects, + args.groups + ); + if (filterError) { + output.error(filterError); + process.exit(1); + } + + const releaseVersion = new ReleaseVersion({ + version: args.version, + releaseTagPattern: nxReleaseConfig.releaseTagPattern, }); const from = args.from || (await getLastGitTag()); @@ -77,61 +127,219 @@ export async function changelogHandler(args: ChangelogOptions): Promise { return false; }); + const tree = new FsTree(workspaceRoot, args.verbose); + + await generateChangelogForWorkspace( + tree, + releaseVersion, + !!args.dryRun, + // Only trigger interactive mode for the workspace changelog if the user explicitly requested it via "all" or "workspace" + args.interactive === 'all' || args.interactive === 'workspace', + commits, + nxReleaseConfig.changelog.workspaceChangelog, + args.gitRemote + ); + + if (args.projects?.length) { + /** + * Run changelog generation for all remaining release groups and filtered projects within them + */ + for (const releaseGroup of releaseGroups) { + const projectNodes = Array.from( + releaseGroupToFilteredProjects.get(releaseGroup) + ).map((name) => projectGraph.nodes[name]); + + await generateChangelogForProjects( + tree, + args.version, + !!args.dryRun, + // Only trigger interactive mode for the workspace changelog if the user explicitly requested it via "all" or "projects" + args.interactive === 'all' || args.interactive === 'projects', + commits, + releaseGroup.changelog, + releaseGroup.releaseTagPattern, + projectNodes, + args.gitRemote + ); + } + + return process.exit(0); + } + + /** + * Run changelog generation for all remaining release groups + */ + for (const releaseGroup of releaseGroups) { + const projectNodes = releaseGroup.projects.map( + (name) => projectGraph.nodes[name] + ); + + await generateChangelogForProjects( + tree, + args.version, + !!args.dryRun, + // Only trigger interactive mode for the workspace changelog if the user explicitly requested it via "all" or "projects" + args.interactive === 'all' || args.interactive === 'projects', + commits, + releaseGroup.changelog, + releaseGroup.releaseTagPattern, + projectNodes, + args.gitRemote + ); + } + + if (args.dryRun) { + logger.warn( + `\nNOTE: The "dryRun" flag means no changelogs were actually created.` + ); + } + + process.exit(0); +} + +function isPrerelease(version: string): boolean { + // prerelease returns an array of matching prerelease "components", or null if the version is not a prerelease + return prerelease(version) !== null; +} + +function resolveChangelogRenderer( + changelogRendererPath: string +): ChangelogRenderer { + // Try and load the provided (or default) changelog renderer + let changelogRenderer: ChangelogRenderer; + let cleanupTranspiler = () => {}; + try { + const rootTsconfigPath = getRootTsConfigPath(); + if (rootTsconfigPath) { + cleanupTranspiler = registerTsProject(rootTsconfigPath); + } + const r = require(changelogRendererPath); + changelogRenderer = r.default || r; + } catch { + } finally { + cleanupTranspiler(); + } + return changelogRenderer; +} + +async function generateChangelogForWorkspace( + tree: Tree, + releaseVersion: ReleaseVersion, + dryRun: boolean, + interactive: boolean, + commits: GitCommit[], + config: NxReleaseConfig['changelog']['workspaceChangelog'], + gitRemote?: string +) { + // The entire feature is disabled at the workspace level, exit early + if (config === false) { + return; + } + + const changelogRenderer = resolveChangelogRenderer(config.renderer); + + let interpolatedTreePath = config.file || ''; + if (interpolatedTreePath) { + interpolatedTreePath = interpolate(interpolatedTreePath, { + projectName: '', // n/a for the workspace changelog + projectRoot: '', // n/a for the workspace changelog + workspaceRoot: '', // within the tree, workspaceRoot is the root + }); + } + + // We are either creating/previewing a changelog file, a Github release, or both + let logTitle = dryRun ? 'Previewing a' : 'Generating a'; + switch (true) { + case interpolatedTreePath && config.createRelease === 'github': + logTitle += ` Github release and an entry in ${interpolatedTreePath} for ${chalk.white( + releaseVersion.gitTag + )}`; + break; + case !!interpolatedTreePath: + logTitle += `n entry in ${interpolatedTreePath} for ${chalk.white( + releaseVersion.gitTag + )}`; + break; + case config.createRelease === 'github': + logTitle += ` Github release for ${chalk.white(releaseVersion.gitTag)}`; + } + + output.log({ + title: logTitle, + }); + const githubRepoSlug = - args.createRelease === 'github' - ? getGitHubRepoSlug(args.gitRemote) + config.createRelease === 'github' + ? getGitHubRepoSlug(gitRemote) : undefined; - const finalMarkdown = await resolveFinalMarkdown( - args, + let contents = await changelogRenderer({ commits, - releaseVersion, - githubRepoSlug - ); + releaseVersion: releaseVersion.rawVersion, + project: null, + repoSlug: githubRepoSlug, + entryWhenNoChanges: config.entryWhenNoChanges, + changelogRenderOptions: config.renderOptions, + }); + + /** + * If interactive mode, make the changelog contents available for the user to modify in their editor of choice, + * in a similar style to git interactive rebases/merges. + */ + if (interactive) { + const tmpDir = dirSync().name; + const changelogPath = joinPathFragments( + tmpDir, + // Include the tree path in the name so that it is easier to identify which changelog file is being edited + `PREVIEW__${interpolatedTreePath.replace(/\//g, '_')}` + ); + writeFileSync(changelogPath, contents); + await launchEditor(changelogPath); + contents = readFileSync(changelogPath, 'utf-8'); + } /** * The exact logic we use for printing the summary/diff to the user is dependent upon whether they are creating - * a CHANGELOG.md file, a Github release, or both. + * a changelog file, a Github release, or both. */ let printSummary = () => {}; const noDiffInChangelogMessage = chalk.yellow( `NOTE: There was no diff detected for the changelog entry. Maybe you intended to pass alternative git references via --from and --to?` ); - if (args.file !== false) { - const tree = new FsTree(workspaceRoot, args.verbose); - - let rootChangelogContents = tree.read(args.file)?.toString() ?? ''; + if (interpolatedTreePath) { + let rootChangelogContents = + tree.read(interpolatedTreePath)?.toString() ?? ''; if (rootChangelogContents) { + // NOTE: right now existing releases are always expected to be in markdown format, but in the future we could potentially support others via a custom parser option const changelogReleases = parseChangelogMarkdown( - rootChangelogContents, - args.tagVersionPrefix + rootChangelogContents ).releases; const existingVersionToUpdate = changelogReleases.find( - (r) => `${tagVersionPrefix}${r.version}` === releaseVersion + (r) => r.version === releaseVersion.rawVersion ); if (existingVersionToUpdate) { rootChangelogContents = rootChangelogContents.replace( - `## ${releaseVersion}\n\n\n${existingVersionToUpdate.body}`, - finalMarkdown + `## ${releaseVersion.rawVersion}\n\n\n${existingVersionToUpdate.body}`, + contents ); } else { // No existing version, simply prepend the new release to the top of the file - rootChangelogContents = `${finalMarkdown}\n\n${rootChangelogContents}`; + rootChangelogContents = `${contents}\n\n${rootChangelogContents}`; } } else { - // No existing changelog contents, simply create a new one using the generated markdown - rootChangelogContents = finalMarkdown; + // No existing changelog contents, simply create a new one using the generated contents + rootChangelogContents = contents; } - tree.write(args.file, rootChangelogContents); + tree.write(interpolatedTreePath, rootChangelogContents); printSummary = () => - printChanges(tree, !!args.dryRun, 3, false, noDiffInChangelogMessage); + printChanges(tree, !!dryRun, 3, false, noDiffInChangelogMessage); } - if (args.createRelease === 'github') { + if (config.createRelease === 'github') { if (!githubRepoSlug) { output.error({ title: `Unable to create a Github release because the Github repo slug could not be determined.`, @@ -152,7 +360,7 @@ export async function changelogHandler(args: ChangelogOptions): Promise { try { existingGithubReleaseForVersion = await getGithubReleaseByTag( githubRequestConfig, - releaseVersion + releaseVersion.gitTag ); } catch (err) { if (err.response?.status === 401) { @@ -175,28 +383,28 @@ export async function changelogHandler(args: ChangelogOptions): Promise { let existingPrintSummaryFn = printSummary; printSummary = () => { - const logTitle = `https://github.com/${githubRepoSlug}/releases/tag/${releaseVersion}`; + const logTitle = `https://github.com/${githubRepoSlug}/releases/tag/${releaseVersion.gitTag}`; if (existingGithubReleaseForVersion) { console.error( `${chalk.white('UPDATE')} ${logTitle}${ - args.dryRun ? chalk.keyword('orange')(' [dry-run]') : '' + dryRun ? chalk.keyword('orange')(' [dry-run]') : '' }` ); } else { console.error( `${chalk.green('CREATE')} ${logTitle}${ - args.dryRun ? chalk.keyword('orange')(' [dry-run]') : '' + dryRun ? chalk.keyword('orange')(' [dry-run]') : '' }` ); } // Only print the diff here if we are not already going to be printing changes from the Tree - if (args.file === false) { + if (!interpolatedTreePath) { console.log(''); printDiff( existingGithubReleaseForVersion ? existingGithubReleaseForVersion.body : '', - finalMarkdown, + contents, 3, noDiffInChangelogMessage ); @@ -204,15 +412,13 @@ export async function changelogHandler(args: ChangelogOptions): Promise { existingPrintSummaryFn(); }; - if (!args.dryRun) { + if (!dryRun) { await createOrUpdateGithubRelease( githubRequestConfig, { - version: releaseVersion, - body: finalMarkdown, - prerelease: isPrerelease( - releaseVersion.replace(args.tagVersionPrefix, '') - ), + version: releaseVersion.gitTag, + prerelease: releaseVersion.isPrerelease, + body: contents, }, existingGithubReleaseForVersion ); @@ -220,45 +426,233 @@ export async function changelogHandler(args: ChangelogOptions): Promise { } printSummary(); - - if (args.dryRun) { - logger.warn(`\nNOTE: The "dryRun" flag means no changes were made.`); - } - - process.exit(0); } -/** - * Based on the commits available, and some optional additional user modifications, - * generate the final markdown for the changelog which will be used for a CHANGELOG.md - * file and/or a Github release. - */ -async function resolveFinalMarkdown( - args: ChangelogOptions, +async function generateChangelogForProjects( + tree: Tree, + rawVersion: string, + dryRun: boolean, + interactive: boolean, commits: GitCommit[], - releaseVersion: string, - githubRepoSlug?: RepoSlug -): Promise { - let markdown = await generateMarkdown( - commits, - releaseVersion, - githubRepoSlug - ); - /** - * If interactive mode, make the markdown available for the user to modify in their editor of choice, - * in a similar style to git interactive rebases/merges. - */ - if (args.interactive) { - const tmpDir = dirSync().name; - const changelogPath = joinPathFragments(tmpDir, 'c.md'); - writeFileSync(changelogPath, markdown); - await launchEditor(changelogPath); - markdown = readFileSync(changelogPath, 'utf-8'); + config: NxReleaseConfig['changelog']['projectChangelogs'], + releaseTagPattern: string, + projects: ProjectGraphProjectNode[], + gitRemote?: string +) { + // The entire feature is disabled at the project level, exit early + if (config === false) { + return; } - return markdown; -} -function isPrerelease(version: string): boolean { - // prerelease returns an array of matching prerelease "components", or null if the version is not a prerelease - return prerelease(version) !== null; + const changelogRenderer = resolveChangelogRenderer(config.renderer); + + for (const project of projects) { + let interpolatedTreePath = config.file || ''; + if (interpolatedTreePath) { + interpolatedTreePath = interpolate(interpolatedTreePath, { + projectName: project.name, + projectRoot: project.data.root, + workspaceRoot: '', // within the tree, workspaceRoot is the root + }); + } + + const releaseVersion = new ReleaseVersion({ + version: rawVersion, + releaseTagPattern, + projectName: project.name, + }); + + // We are either creating/previewing a changelog file, a Github release, or both + let logTitle = dryRun ? 'Previewing a' : 'Generating a'; + switch (true) { + case interpolatedTreePath && config.createRelease === 'github': + logTitle += ` Github release and an entry in ${interpolatedTreePath} for ${chalk.white( + releaseVersion.gitTag + )}`; + break; + case !!interpolatedTreePath: + logTitle += `n entry in ${interpolatedTreePath} for ${chalk.white( + releaseVersion.gitTag + )}`; + break; + case config.createRelease === 'github': + logTitle += ` Github release for ${chalk.white(releaseVersion.gitTag)}`; + } + + output.log({ + title: logTitle, + }); + + const githubRepoSlug = + config.createRelease === 'github' + ? getGitHubRepoSlug(gitRemote) + : undefined; + + let contents = await changelogRenderer({ + commits, + releaseVersion: releaseVersion.rawVersion, + project: null, + repoSlug: githubRepoSlug, + entryWhenNoChanges: + typeof config.entryWhenNoChanges === 'string' + ? interpolate(config.entryWhenNoChanges, { + projectName: project.name, + projectRoot: project.data.root, + workspaceRoot: '', // within the tree, workspaceRoot is the root + }) + : false, + changelogRenderOptions: config.renderOptions, + }); + + /** + * If interactive mode, make the changelog contents available for the user to modify in their editor of choice, + * in a similar style to git interactive rebases/merges. + */ + if (interactive) { + const tmpDir = dirSync().name; + const changelogPath = joinPathFragments( + tmpDir, + // Include the tree path in the name so that it is easier to identify which changelog file is being edited + `PREVIEW__${interpolatedTreePath.replace(/\//g, '_')}` + ); + writeFileSync(changelogPath, contents); + await launchEditor(changelogPath); + contents = readFileSync(changelogPath, 'utf-8'); + } + + /** + * The exact logic we use for printing the summary/diff to the user is dependent upon whether they are creating + * a changelog file, a Github release, or both. + */ + let printSummary = () => {}; + const noDiffInChangelogMessage = chalk.yellow( + `NOTE: There was no diff detected for the changelog entry. Maybe you intended to pass alternative git references via --from and --to?` + ); + + if (interpolatedTreePath) { + let changelogContents = tree.read(interpolatedTreePath)?.toString() ?? ''; + if (changelogContents) { + // NOTE: right now existing releases are always expected to be in markdown format, but in the future we could potentially support others via a custom parser option + const changelogReleases = + parseChangelogMarkdown(changelogContents).releases; + + const existingVersionToUpdate = changelogReleases.find( + (r) => r.version === releaseVersion.rawVersion + ); + if (existingVersionToUpdate) { + changelogContents = changelogContents.replace( + `## ${releaseVersion.rawVersion}\n\n\n${existingVersionToUpdate.body}`, + contents + ); + } else { + // No existing version, simply prepend the new release to the top of the file + changelogContents = `${contents}\n\n${changelogContents}`; + } + } else { + // No existing changelog contents, simply create a new one using the generated contents + changelogContents = contents; + } + + tree.write(interpolatedTreePath, changelogContents); + + printSummary = () => + printChanges( + tree, + !!dryRun, + 3, + false, + noDiffInChangelogMessage, + // Only print the change for the current changelog file at this point + (f) => f.path === interpolatedTreePath + ); + } + + if (config.createRelease === 'github') { + if (!githubRepoSlug) { + output.error({ + title: `Unable to create a Github release because the Github repo slug could not be determined.`, + bodyLines: [ + `Please ensure you have a valid Github remote configured. You can run \`git remote -v\` to list your current remotes.`, + ], + }); + process.exit(1); + } + + const token = await resolveGithubToken(); + const githubRequestConfig: GithubRequestConfig = { + repo: githubRepoSlug, + token, + }; + + let existingGithubReleaseForVersion: GithubRelease; + try { + existingGithubReleaseForVersion = await getGithubReleaseByTag( + githubRequestConfig, + releaseVersion.gitTag + ); + } catch (err) { + if (err.response?.status === 401) { + output.error({ + title: `Unable to resolve data via the Github API. You can use any of the following options to resolve this:`, + bodyLines: [ + '- Set the `GITHUB_TOKEN` or `GH_TOKEN` environment variable to a valid Github token with `repo` scope', + '- Have an active session via the official gh CLI tool (https://cli.github.com) in your current terminal', + ], + }); + process.exit(1); + } + if (err.response?.status === 404) { + // No existing release found, this is fine + } else { + // Rethrow unknown errors for now + throw err; + } + } + + let existingPrintSummaryFn = printSummary; + printSummary = () => { + const logTitle = `https://github.com/${githubRepoSlug}/releases/tag/${releaseVersion.gitTag}`; + if (existingGithubReleaseForVersion) { + console.error( + `${chalk.white('UPDATE')} ${logTitle}${ + dryRun ? chalk.keyword('orange')(' [dry-run]') : '' + }` + ); + } else { + console.error( + `${chalk.green('CREATE')} ${logTitle}${ + dryRun ? chalk.keyword('orange')(' [dry-run]') : '' + }` + ); + } + // Only print the diff here if we are not already going to be printing changes from the Tree + if (!interpolatedTreePath) { + console.log(''); + printDiff( + existingGithubReleaseForVersion + ? existingGithubReleaseForVersion.body + : '', + contents, + 3, + noDiffInChangelogMessage + ); + } + existingPrintSummaryFn(); + }; + + if (!dryRun) { + await createOrUpdateGithubRelease( + githubRequestConfig, + { + version: releaseVersion.gitTag, + prerelease: releaseVersion.isPrerelease, + body: contents, + }, + existingGithubReleaseForVersion + ); + } + } + + printSummary(); + } } diff --git a/packages/nx/src/command-line/release/command-object.ts b/packages/nx/src/command-line/release/command-object.ts index a8126cfaa7475..4f28e5eef0a5b 100644 --- a/packages/nx/src/command-line/release/command-object.ts +++ b/packages/nx/src/command-line/release/command-object.ts @@ -24,11 +24,9 @@ export type ChangelogOptions = NxReleaseArgs & { version: string; to: string; from?: string; - interactive?: boolean; + interactive?: string; gitRemote?: string; tagVersionPrefix?: string; - createRelease?: string; - file?: string | false; }; export type PublishOptions = NxReleaseArgs & @@ -146,7 +144,10 @@ const changelogCommand: CommandModule = { }) .option('interactive', { alias: 'i', - type: 'boolean', + type: 'string', + description: + 'Interactively modify changelog markdown contents in your code editor before applying the changes. You can set it to be interactive for all changelogs, or only the workspace level, or only the project level', + choices: ['all', 'workspace', 'projects'], }) .option('gitRemote', { type: 'string', @@ -160,24 +161,6 @@ const changelogCommand: CommandModule = { 'Prefix to apply to the version when creating the Github release tag', default: 'v', }) - .option('createRelease', { - describe: - 'Create a release for the given version on a supported source control service provider, such as Github.', - type: 'string', - choices: ['github'], - }) - .option('file', { - type: 'string', - description: - 'The name of the file to write the changelog to. It can also be set to `false` to disable file generation. Defaults to CHANGELOG.md.', - default: 'CHANGELOG.md', - coerce: (file) => { - if (file === 'false') { - return false; - } - return file; - }, - }) .check((argv) => { if (!argv.version) { throw new Error('A target version must be specified'); diff --git a/packages/nx/src/command-line/release/config/config.spec.ts b/packages/nx/src/command-line/release/config/config.spec.ts index ad153c2290d1f..9e66faada30a7 100644 --- a/packages/nx/src/command-line/release/config/config.spec.ts +++ b/packages/nx/src/command-line/release/config/config.spec.ts @@ -50,19 +50,38 @@ describe('createNxReleaseConfig()', () => { { "error": null, "nxReleaseConfig": { + "changelog": { + "projectChangelogs": false, + "workspaceChangelog": { + "createRelease": false, + "entryWhenNoChanges": "This was a version bump only, there were no code changes.", + "file": "{workspaceRoot}/CHANGELOG.md", + "renderOptions": { + "includeAuthors": true, + }, + "renderer": "nx/changelog-renderer", + }, + }, "groups": { "__default__": { + "changelog": false, "projects": [ "lib-a", "lib-b", "nx", ], + "releaseTagPattern": "{projectName}@v{version}", "version": { "generator": "@nx/js:release-version", "generatorOptions": {}, }, }, }, + "releaseTagPattern": "v{version}", + "version": { + "generator": "@nx/js:release-version", + "generatorOptions": {}, + }, }, } `); @@ -70,25 +89,44 @@ describe('createNxReleaseConfig()', () => { // empty user config expect(await createNxReleaseConfig(projectGraph, {})) .toMatchInlineSnapshot(` - { - "error": null, - "nxReleaseConfig": { - "groups": { - "__default__": { - "projects": [ - "lib-a", - "lib-b", - "nx", - ], - "version": { - "generator": "@nx/js:release-version", - "generatorOptions": {}, - }, - }, + { + "error": null, + "nxReleaseConfig": { + "changelog": { + "projectChangelogs": false, + "workspaceChangelog": { + "createRelease": false, + "entryWhenNoChanges": "This was a version bump only, there were no code changes.", + "file": "{workspaceRoot}/CHANGELOG.md", + "renderOptions": { + "includeAuthors": true, }, + "renderer": "nx/changelog-renderer", }, - } - `); + }, + "groups": { + "__default__": { + "changelog": false, + "projects": [ + "lib-a", + "lib-b", + "nx", + ], + "releaseTagPattern": "{projectName}@v{version}", + "version": { + "generator": "@nx/js:release-version", + "generatorOptions": {}, + }, + }, + }, + "releaseTagPattern": "v{version}", + "version": { + "generator": "@nx/js:release-version", + "generatorOptions": {}, + }, + }, + } + `); // empty groups expect( @@ -96,25 +134,44 @@ describe('createNxReleaseConfig()', () => { groups: {}, }) ).toMatchInlineSnapshot(` - { - "error": null, - "nxReleaseConfig": { - "groups": { - "__default__": { - "projects": [ - "lib-a", - "lib-b", - "nx", - ], - "version": { - "generator": "@nx/js:release-version", - "generatorOptions": {}, - }, - }, + { + "error": null, + "nxReleaseConfig": { + "changelog": { + "projectChangelogs": false, + "workspaceChangelog": { + "createRelease": false, + "entryWhenNoChanges": "This was a version bump only, there were no code changes.", + "file": "{workspaceRoot}/CHANGELOG.md", + "renderOptions": { + "includeAuthors": true, + }, + "renderer": "nx/changelog-renderer", + }, + }, + "groups": { + "__default__": { + "changelog": false, + "projects": [ + "lib-a", + "lib-b", + "nx", + ], + "releaseTagPattern": "{projectName}@v{version}", + "version": { + "generator": "@nx/js:release-version", + "generatorOptions": {}, }, }, - } - `); + }, + "releaseTagPattern": "v{version}", + "version": { + "generator": "@nx/js:release-version", + "generatorOptions": {}, + }, + }, + } + `); }); }); @@ -131,17 +188,84 @@ describe('createNxReleaseConfig()', () => { { "error": null, "nxReleaseConfig": { + "changelog": { + "projectChangelogs": false, + "workspaceChangelog": { + "createRelease": false, + "entryWhenNoChanges": "This was a version bump only, there were no code changes.", + "file": "{workspaceRoot}/CHANGELOG.md", + "renderOptions": { + "includeAuthors": true, + }, + "renderer": "nx/changelog-renderer", + }, + }, + "groups": { + "group-1": { + "changelog": false, + "projects": [ + "lib-a", + ], + "releaseTagPattern": "{projectName}@v{version}", + "version": { + "generator": "@nx/js:release-version", + "generatorOptions": {}, + }, + }, + }, + "releaseTagPattern": "v{version}", + "version": { + "generator": "@nx/js:release-version", + "generatorOptions": {}, + }, + }, + } + `); + }); + + it('should convert any projects patterns into actual project names in the final config', async () => { + const res = await createNxReleaseConfig(projectGraph, { + groups: { + 'group-1': { + projects: ['lib-*'], // should match both lib-a and lib-b + }, + }, + }); + expect(res).toMatchInlineSnapshot(` + { + "error": null, + "nxReleaseConfig": { + "changelog": { + "projectChangelogs": false, + "workspaceChangelog": { + "createRelease": false, + "entryWhenNoChanges": "This was a version bump only, there were no code changes.", + "file": "{workspaceRoot}/CHANGELOG.md", + "renderOptions": { + "includeAuthors": true, + }, + "renderer": "nx/changelog-renderer", + }, + }, "groups": { "group-1": { + "changelog": false, "projects": [ "lib-a", + "lib-b", ], + "releaseTagPattern": "{projectName}@v{version}", "version": { "generator": "@nx/js:release-version", "generatorOptions": {}, }, }, }, + "releaseTagPattern": "v{version}", + "version": { + "generator": "@nx/js:release-version", + "generatorOptions": {}, + }, }, } `); @@ -171,11 +295,25 @@ describe('createNxReleaseConfig()', () => { { "error": null, "nxReleaseConfig": { + "changelog": { + "projectChangelogs": false, + "workspaceChangelog": { + "createRelease": false, + "entryWhenNoChanges": "This was a version bump only, there were no code changes.", + "file": "{workspaceRoot}/CHANGELOG.md", + "renderOptions": { + "includeAuthors": true, + }, + "renderer": "nx/changelog-renderer", + }, + }, "groups": { "group-1": { + "changelog": false, "projects": [ "lib-a", ], + "releaseTagPattern": "{projectName}@v{version}", "version": { "generator": "@custom/generator", "generatorOptions": { @@ -184,15 +322,409 @@ describe('createNxReleaseConfig()', () => { }, }, "group-2": { + "changelog": false, "projects": [ "lib-b", ], + "releaseTagPattern": "{projectName}@v{version}", "version": { "generator": "@custom/generator-alternative", "generatorOptions": {}, }, }, }, + "releaseTagPattern": "v{version}", + "version": { + "generator": "@nx/js:release-version", + "generatorOptions": {}, + }, + }, + } + `); + }); + }); + + describe('user config -> top level version', () => { + it('should respect modifying version at the top level and it should be inherited by the catch all group', async () => { + const res = await createNxReleaseConfig(projectGraph, { + version: { + // only modifying options, use default generator + generatorOptions: { + foo: 'bar', + }, + }, + }); + expect(res).toMatchInlineSnapshot(` + { + "error": null, + "nxReleaseConfig": { + "changelog": { + "projectChangelogs": false, + "workspaceChangelog": { + "createRelease": false, + "entryWhenNoChanges": "This was a version bump only, there were no code changes.", + "file": "{workspaceRoot}/CHANGELOG.md", + "renderOptions": { + "includeAuthors": true, + }, + "renderer": "nx/changelog-renderer", + }, + }, + "groups": { + "__default__": { + "changelog": false, + "projects": [ + "lib-a", + "lib-b", + "nx", + ], + "releaseTagPattern": "{projectName}@v{version}", + "version": { + "generator": "@nx/js:release-version", + "generatorOptions": { + "foo": "bar", + }, + }, + }, + }, + "releaseTagPattern": "v{version}", + "version": { + "generator": "@nx/js:release-version", + "generatorOptions": { + "foo": "bar", + }, + }, + }, + } + `); + }); + }); + + describe('user config -> top level changelog', () => { + it('should respect disabling all changelogs at the top level', async () => { + const res = await createNxReleaseConfig(projectGraph, { + changelog: { + projectChangelogs: false, + workspaceChangelog: false, + }, + }); + expect(res).toMatchInlineSnapshot(` + { + "error": null, + "nxReleaseConfig": { + "changelog": { + "projectChangelogs": false, + "workspaceChangelog": false, + }, + "groups": { + "__default__": { + "changelog": false, + "projects": [ + "lib-a", + "lib-b", + "nx", + ], + "releaseTagPattern": "{projectName}@v{version}", + "version": { + "generator": "@nx/js:release-version", + "generatorOptions": {}, + }, + }, + }, + "releaseTagPattern": "v{version}", + "version": { + "generator": "@nx/js:release-version", + "generatorOptions": {}, + }, + }, + } + `); + }); + + it('should respect any adjustments to default changelog config at the top level and apply as defaults at the group level', async () => { + const res = await createNxReleaseConfig(projectGraph, { + changelog: { + workspaceChangelog: { + // override single field in user config + entryWhenNoChanges: 'Custom no changes!', + }, + projectChangelogs: { + // override single field in user config + file: './{projectRoot}/custom-path.md', + renderOptions: { + includeAuthors: false, // override deeply nested field in user config + }, + }, + }, + }); + expect(res).toMatchInlineSnapshot(` + { + "error": null, + "nxReleaseConfig": { + "changelog": { + "projectChangelogs": { + "createRelease": false, + "entryWhenNoChanges": "This was a version bump only for {projectName} to align it with other projects, there were no code changes.", + "file": "./{projectRoot}/custom-path.md", + "renderOptions": { + "includeAuthors": false, + }, + "renderer": "nx/changelog-renderer", + }, + "workspaceChangelog": { + "createRelease": false, + "entryWhenNoChanges": "Custom no changes!", + "file": "{workspaceRoot}/CHANGELOG.md", + "renderOptions": { + "includeAuthors": true, + }, + "renderer": "nx/changelog-renderer", + }, + }, + "groups": { + "__default__": { + "changelog": { + "createRelease": false, + "entryWhenNoChanges": "This was a version bump only for {projectName} to align it with other projects, there were no code changes.", + "file": "./{projectRoot}/custom-path.md", + "renderOptions": { + "includeAuthors": false, + }, + "renderer": "nx/changelog-renderer", + }, + "projects": [ + "lib-a", + "lib-b", + "nx", + ], + "releaseTagPattern": "{projectName}@v{version}", + "version": { + "generator": "@nx/js:release-version", + "generatorOptions": {}, + }, + }, + }, + "releaseTagPattern": "v{version}", + "version": { + "generator": "@nx/js:release-version", + "generatorOptions": {}, + }, + }, + } + `); + }); + }); + + describe('user config -> top level and group level changelog combined', () => { + it('should respect any adjustments to default changelog config at the top level and group level in the final config, CASE 1', async () => { + const res = await createNxReleaseConfig(projectGraph, { + changelog: { + projectChangelogs: { + // overriding field at the root should be inherited by all groups that do not set their own override + file: './{projectRoot}/custom-path.md', + renderOptions: { + includeAuthors: true, // should be overridden by group level config + }, + }, + }, + groups: { + 'group-1': { + projects: ['lib-a'], + changelog: { + createRelease: 'github', // set field in group config + renderOptions: { + includeAuthors: false, // override deeply nested field in group config + }, + }, + }, + 'group-2': { + projects: ['lib-b'], + changelog: false, // disabled changelog for this group + }, + 'group-3': { + projects: ['nx'], + changelog: { + file: './{projectRoot}/a-different-custom-path-at-the-group.md', // a different override field at the group level + }, + }, + }, + }); + expect(res).toMatchInlineSnapshot(` + { + "error": null, + "nxReleaseConfig": { + "changelog": { + "projectChangelogs": { + "createRelease": false, + "entryWhenNoChanges": "This was a version bump only for {projectName} to align it with other projects, there were no code changes.", + "file": "./{projectRoot}/custom-path.md", + "renderOptions": { + "includeAuthors": true, + }, + "renderer": "nx/changelog-renderer", + }, + "workspaceChangelog": { + "createRelease": false, + "entryWhenNoChanges": "This was a version bump only, there were no code changes.", + "file": "{workspaceRoot}/CHANGELOG.md", + "renderOptions": { + "includeAuthors": true, + }, + "renderer": "nx/changelog-renderer", + }, + }, + "groups": { + "group-1": { + "changelog": { + "createRelease": "github", + "entryWhenNoChanges": "This was a version bump only for {projectName} to align it with other projects, there were no code changes.", + "file": "./{projectRoot}/custom-path.md", + "renderOptions": { + "includeAuthors": false, + }, + "renderer": "nx/changelog-renderer", + }, + "projects": [ + "lib-a", + ], + "releaseTagPattern": "{projectName}@v{version}", + "version": { + "generator": "@nx/js:release-version", + "generatorOptions": {}, + }, + }, + "group-2": { + "changelog": false, + "projects": [ + "lib-b", + ], + "releaseTagPattern": "{projectName}@v{version}", + "version": { + "generator": "@nx/js:release-version", + "generatorOptions": {}, + }, + }, + "group-3": { + "changelog": { + "createRelease": false, + "entryWhenNoChanges": "This was a version bump only for {projectName} to align it with other projects, there were no code changes.", + "file": "./{projectRoot}/a-different-custom-path-at-the-group.md", + "renderOptions": { + "includeAuthors": true, + }, + "renderer": "nx/changelog-renderer", + }, + "projects": [ + "nx", + ], + "releaseTagPattern": "{projectName}@v{version}", + "version": { + "generator": "@nx/js:release-version", + "generatorOptions": {}, + }, + }, + }, + "releaseTagPattern": "v{version}", + "version": { + "generator": "@nx/js:release-version", + "generatorOptions": {}, + }, + }, + } + `); + }); + + it('should respect any adjustments to default changelog config at the top level and group level in the final config, CASE 2', async () => { + const res = await createNxReleaseConfig(projectGraph, { + groups: { + foo: { + projects: 'lib-a', + releaseTagPattern: '{projectName}-{version}', + }, + bar: { + projects: 'lib-b', + }, + }, + changelog: { + workspaceChangelog: { + createRelease: 'github', + }, + // enabling project changelogs at the workspace level should cause each group to have project changelogs enabled + projectChangelogs: { + createRelease: 'github', + }, + }, + }); + + expect(res).toMatchInlineSnapshot(` + { + "error": null, + "nxReleaseConfig": { + "changelog": { + "projectChangelogs": { + "createRelease": "github", + "entryWhenNoChanges": "This was a version bump only for {projectName} to align it with other projects, there were no code changes.", + "file": "{projectRoot}/CHANGELOG.md", + "renderOptions": { + "includeAuthors": true, + }, + "renderer": "nx/changelog-renderer", + }, + "workspaceChangelog": { + "createRelease": "github", + "entryWhenNoChanges": "This was a version bump only, there were no code changes.", + "file": "{workspaceRoot}/CHANGELOG.md", + "renderOptions": { + "includeAuthors": true, + }, + "renderer": "nx/changelog-renderer", + }, + }, + "groups": { + "bar": { + "changelog": { + "createRelease": "github", + "entryWhenNoChanges": "This was a version bump only for {projectName} to align it with other projects, there were no code changes.", + "file": "{projectRoot}/CHANGELOG.md", + "renderOptions": { + "includeAuthors": true, + }, + "renderer": "nx/changelog-renderer", + }, + "projects": [ + "lib-b", + ], + "releaseTagPattern": "{projectName}@v{version}", + "version": { + "generator": "@nx/js:release-version", + "generatorOptions": {}, + }, + }, + "foo": { + "changelog": { + "createRelease": "github", + "entryWhenNoChanges": "This was a version bump only for {projectName} to align it with other projects, there were no code changes.", + "file": "{projectRoot}/CHANGELOG.md", + "renderOptions": { + "includeAuthors": true, + }, + "renderer": "nx/changelog-renderer", + }, + "projects": [ + "lib-a", + ], + "releaseTagPattern": "{projectName}-{version}", + "version": { + "generator": "@nx/js:release-version", + "generatorOptions": {}, + }, + }, + }, + "releaseTagPattern": "v{version}", + "version": { + "generator": "@nx/js:release-version", + "generatorOptions": {}, + }, }, } `); diff --git a/packages/nx/src/command-line/release/config/config.ts b/packages/nx/src/command-line/release/config/config.ts index 2c7e33a69668d..ae86a9fb77240 100644 --- a/packages/nx/src/command-line/release/config/config.ts +++ b/packages/nx/src/command-line/release/config/config.ts @@ -40,7 +40,9 @@ export const CATCH_ALL_RELEASE_GROUP = '__default__'; */ export type NxReleaseConfig = DeepRequired< NxJsonConfiguration['release'] & { - groups: EnsureProjectsArray; + groups: DeepRequired< + EnsureProjectsArray + >; } >; @@ -63,65 +65,106 @@ export async function createNxReleaseConfig( error: null | CreateNxReleaseConfigError; nxReleaseConfig: NxReleaseConfig | null; }> { - const DEFAULT_VERSION_GENERATOR = '@nx/js:release-version'; - const DEFAULT_VERSION_GENERATOR_OPTIONS = {}; - - const allProjects = findMatchingProjects(['*'], projectGraph.nodes); - const userSpecifiedGroups = userConfig.groups || {}; + const WORKSPACE_DEFAULTS: Omit = { + version: { + generator: '@nx/js:release-version', + generatorOptions: {}, + }, + changelog: { + workspaceChangelog: { + createRelease: false, + entryWhenNoChanges: + 'This was a version bump only, there were no code changes.', + file: '{workspaceRoot}/CHANGELOG.md', + renderer: 'nx/changelog-renderer', + renderOptions: { + includeAuthors: true, + }, + }, + // For projectChangelogs if the user has set any changelog config at all, then use one set of defaults, otherwise default to false for the whole feature + projectChangelogs: userConfig.changelog?.projectChangelogs + ? { + createRelease: false, + file: '{projectRoot}/CHANGELOG.md', + entryWhenNoChanges: + 'This was a version bump only for {projectName} to align it with other projects, there were no code changes.', + renderer: 'nx/changelog-renderer', + renderOptions: { + includeAuthors: true, + }, + } + : false, + }, + releaseTagPattern: 'v{version}', + }; + const GROUP_DEFAULTS: Omit = { + version: { + generator: '@nx/js:release-version', + generatorOptions: {}, + }, + changelog: { + createRelease: false, + entryWhenNoChanges: + 'This was a version bump only for {projectName} to align it with other projects, there were no code changes.', + file: '{projectRoot}/CHANGELOG.md', + renderer: 'nx/changelog-renderer', + renderOptions: { + includeAuthors: true, + }, + }, + releaseTagPattern: '{projectName}@v{version}', + }; /** - * No user specified release groups, so we treat all projects as being in one release group - * together in which all projects are released in lock step. + * We first process root level config and apply defaults, so that we know how to handle the group level + * overrides, if applicable. */ - if (Object.keys(userSpecifiedGroups).length === 0) { - // Ensure all projects have the relevant target available, if applicable - if (requiredTargetName) { - const error = ensureProjectsHaveTarget( - allProjects, - projectGraph, - requiredTargetName - ); - if (error) { - return { - error, - nxReleaseConfig: null, - }; - } - } + const rootVersionConfig: NxReleaseConfig['version'] = deepMergeDefaults( + [WORKSPACE_DEFAULTS.version], + userConfig.version + ); + const rootChangelogConfig: NxReleaseConfig['changelog'] = deepMergeDefaults( + [WORKSPACE_DEFAULTS.changelog], + userConfig.changelog as Partial + ); - return { - error: null, - nxReleaseConfig: { - groups: { + const allProjects = findMatchingProjects(['*'], projectGraph.nodes); + const groups: NxReleaseConfig['groups'] = + userConfig.groups && Object.keys(userConfig.groups).length + ? ensureProjectsConfigIsArray(userConfig.groups) + : /** + * No user specified release groups, so we treat all projects as being in one release group + * together in which all projects are released in lock step. + */ + { [CATCH_ALL_RELEASE_GROUP]: { projects: allProjects, - version: { - generator: DEFAULT_VERSION_GENERATOR, - generatorOptions: DEFAULT_VERSION_GENERATOR_OPTIONS, - }, + /** + * For properties which are overriding config at the root, we use the root level config as the + * default values to merge with so that the group that matches a specific project will always + * be the valid source of truth for that type of config. + */ + version: deepMergeDefaults( + [GROUP_DEFAULTS.version], + rootVersionConfig + ), + releaseTagPattern: GROUP_DEFAULTS.releaseTagPattern, + // Directly inherit the root level config for projectChangelogs, if set + changelog: rootChangelogConfig.projectChangelogs || false, }, - }, - }, - }; - } + }; /** - * The user has specified at least one release group. - * * Resolve all the project names into their release groups, and check * that individual projects are not found in multiple groups. */ const releaseGroups: NxReleaseConfig['groups'] = {}; const alreadyMatchedProjects = new Set(); - for (const [releaseGroupName, userSpecifiedGroup] of Object.entries( - userSpecifiedGroups - )) { - // Ensure that the user config for the release group can resolve at least one project + for (const [releaseGroupName, releaseGroup] of Object.entries(groups)) { + // Ensure that the config for the release group can resolve at least one project const matchingProjects = findMatchingProjects( - Array.isArray(userSpecifiedGroup.projects) - ? userSpecifiedGroup.projects - : [userSpecifiedGroup.projects], + releaseGroup.projects, projectGraph.nodes ); if (!matchingProjects.length) { @@ -165,28 +208,48 @@ export async function createNxReleaseConfig( } alreadyMatchedProjects.add(project); } - releaseGroups[releaseGroupName] = { + + // First apply any group level defaults, then apply actual root level config (if applicable), then group level config + const groupChangelogDefaults: Array< + NxReleaseConfig['groups']['string']['changelog'] + > = [GROUP_DEFAULTS.changelog]; + if (rootChangelogConfig.projectChangelogs) { + groupChangelogDefaults.push(rootChangelogConfig.projectChangelogs); + } + + const groupDefaults: NxReleaseConfig['groups']['string'] = { projects: matchingProjects, - version: userSpecifiedGroup.version - ? { - generator: - userSpecifiedGroup.version.generator || DEFAULT_VERSION_GENERATOR, - generatorOptions: - userSpecifiedGroup.version.generatorOptions || - DEFAULT_VERSION_GENERATOR_OPTIONS, - } - : { - generator: DEFAULT_VERSION_GENERATOR, - generatorOptions: DEFAULT_VERSION_GENERATOR_OPTIONS, - }, + version: deepMergeDefaults( + // First apply any group level defaults, then apply actual root level config, then group level config + [GROUP_DEFAULTS.version, rootVersionConfig], + releaseGroup.version + ), + // If the user has set any changelog config at all, including at the root level, then use one set of defaults, otherwise default to false for the whole feature + changelog: + releaseGroup.changelog || rootChangelogConfig.projectChangelogs + ? deepMergeDefaults( + groupChangelogDefaults, + releaseGroup.changelog || {} + ) + : false, + releaseTagPattern: GROUP_DEFAULTS.releaseTagPattern, }; + + releaseGroups[releaseGroupName] = deepMergeDefaults([groupDefaults], { + ...releaseGroup, + // Ensure that the resolved project names take priority over the original user config (which could have contained unresolved globs etc) + projects: matchingProjects, + }); } return { error: null, nxReleaseConfig: { - ...userConfig, + version: rootVersionConfig, + changelog: rootChangelogConfig, groups: releaseGroups, + releaseTagPattern: + userConfig.releaseTagPattern || WORKSPACE_DEFAULTS.releaseTagPattern, }, }; } @@ -234,6 +297,21 @@ export async function handleNxReleaseConfigError( process.exit(1); } +function ensureProjectsConfigIsArray( + groups: NxJsonConfiguration['release']['groups'] +): NxReleaseConfig['groups'] { + const result: NxJsonConfiguration['release']['groups'] = {}; + for (const [groupName, groupConfig] of Object.entries(groups)) { + result[groupName] = { + ...groupConfig, + projects: Array.isArray(groupConfig.projects) + ? groupConfig.projects + : [groupConfig.projects], + }; + } + return result as NxReleaseConfig['groups']; +} + function ensureProjectsHaveTarget( projects: string[], projectGraph: ProjectGraph, @@ -254,3 +332,63 @@ function ensureProjectsHaveTarget( } return null; } + +function isObject(value: any): value is Record { + return value && typeof value === 'object' && !Array.isArray(value); +} + +// Helper function to merge two config objects +function mergeConfig( + objA: DeepRequired, + objB: Partial +): DeepRequired { + const merged: any = { ...objA }; + + for (const key in objB) { + if (objB.hasOwnProperty(key)) { + // If objB[key] is explicitly set to false, null or 0, respect that value + if (objB[key] === false || objB[key] === null || objB[key] === 0) { + merged[key] = objB[key]; + } + // If both objA[key] and objB[key] are objects, recursively merge them + else if (isObject(merged[key]) && isObject(objB[key])) { + merged[key] = mergeConfig(merged[key], objB[key]); + } + // If objB[key] is defined, use it (this will overwrite any existing value in merged[key]) + else if (objB[key] !== undefined) { + merged[key] = objB[key]; + } + } + } + + return merged as DeepRequired; +} + +/** + * This function takes in a strictly typed collection of all possible default values in a particular section of config, + * and an optional set of partial user config, and returns a single, deeply merged config object, where the user + * config takes priority over the defaults in all cases (only an `undefined` value in the user config will be + * overwritten by the defaults, all other falsey values from the user will be respected). + */ +function deepMergeDefaults( + defaultConfigs: DeepRequired[], + userConfig?: Partial +): DeepRequired { + let result: any; + + // First merge defaultConfigs sequentially (meaning later defaults will override earlier ones) + for (const defaultConfig of defaultConfigs) { + if (!result) { + result = defaultConfig; + continue; + } + result = mergeConfig(result, defaultConfig as Partial); + } + + // Finally, merge the userConfig + if (userConfig) { + result = mergeConfig(result, userConfig); + } + + return result as DeepRequired; +} diff --git a/packages/nx/src/command-line/release/config/filter-release-groups.spec.ts b/packages/nx/src/command-line/release/config/filter-release-groups.spec.ts index 7d181eb290809..53aeeca99f3e9 100644 --- a/packages/nx/src/command-line/release/config/filter-release-groups.spec.ts +++ b/packages/nx/src/command-line/release/config/filter-release-groups.spec.ts @@ -9,6 +9,15 @@ describe('filterReleaseGroups()', () => { beforeEach(() => { nxReleaseConfig = { groups: {}, + changelog: { + workspaceChangelog: false, + projectChangelogs: false, + }, + version: { + generator: '', + generatorOptions: {}, + }, + releaseTagPattern: '', }; projectGraph = { nodes: { @@ -53,6 +62,12 @@ describe('filterReleaseGroups()', () => { nxReleaseConfig.groups = { foo: { projects: ['lib-a'], + changelog: false, + version: { + generator: '', + generatorOptions: {}, + }, + releaseTagPattern: '', }, }; const { error } = filterReleaseGroups(projectGraph, nxReleaseConfig, [ @@ -72,9 +87,21 @@ describe('filterReleaseGroups()', () => { nxReleaseConfig.groups = { foo: { projects: ['lib-a'], + changelog: false, + version: { + generator: '', + generatorOptions: {}, + }, + releaseTagPattern: '', }, bar: { projects: ['lib-b'], + changelog: false, + version: { + generator: '', + generatorOptions: {}, + }, + releaseTagPattern: '', }, }; const { error, releaseGroups, releaseGroupToFilteredProjects } = @@ -83,34 +110,58 @@ describe('filterReleaseGroups()', () => { expect(releaseGroups).toMatchInlineSnapshot(` [ { + "changelog": false, "name": "foo", "projects": [ "lib-a", ], + "releaseTagPattern": "", + "version": { + "generator": "", + "generatorOptions": {}, + }, }, { + "changelog": false, "name": "bar", "projects": [ "lib-b", ], + "releaseTagPattern": "", + "version": { + "generator": "", + "generatorOptions": {}, + }, }, ] `); expect(releaseGroupToFilteredProjects).toMatchInlineSnapshot(` Map { { + "changelog": false, "name": "foo", "projects": [ "lib-a", ], + "releaseTagPattern": "", + "version": { + "generator": "", + "generatorOptions": {}, + }, } => Set { "lib-a", }, { + "changelog": false, "name": "bar", "projects": [ "lib-b", ], + "releaseTagPattern": "", + "version": { + "generator": "", + "generatorOptions": {}, + }, } => Set { "lib-b", }, @@ -122,9 +173,21 @@ describe('filterReleaseGroups()', () => { nxReleaseConfig.groups = { foo: { projects: ['lib-a'], + changelog: false, + version: { + generator: '', + generatorOptions: {}, + }, + releaseTagPattern: '', }, bar: { projects: ['lib-b'], + changelog: false, + version: { + generator: '', + generatorOptions: {}, + }, + releaseTagPattern: '', }, }; const { error, releaseGroups, releaseGroupToFilteredProjects } = @@ -133,20 +196,32 @@ describe('filterReleaseGroups()', () => { expect(releaseGroups).toMatchInlineSnapshot(` [ { + "changelog": false, "name": "foo", "projects": [ "lib-a", ], + "releaseTagPattern": "", + "version": { + "generator": "", + "generatorOptions": {}, + }, }, ] `); expect(releaseGroupToFilteredProjects).toMatchInlineSnapshot(` Map { { + "changelog": false, "name": "foo", "projects": [ "lib-a", ], + "releaseTagPattern": "", + "version": { + "generator": "", + "generatorOptions": {}, + }, } => Set { "lib-a", }, @@ -158,6 +233,12 @@ describe('filterReleaseGroups()', () => { nxReleaseConfig.groups = { [CATCH_ALL_RELEASE_GROUP]: { projects: ['lib-a', 'lib-a'], + changelog: false, + version: { + generator: '', + generatorOptions: {}, + }, + releaseTagPattern: '', }, }; const { error, releaseGroups, releaseGroupToFilteredProjects } = @@ -166,22 +247,34 @@ describe('filterReleaseGroups()', () => { expect(releaseGroups).toMatchInlineSnapshot(` [ { + "changelog": false, "name": "__default__", "projects": [ "lib-a", "lib-a", ], + "releaseTagPattern": "", + "version": { + "generator": "", + "generatorOptions": {}, + }, }, ] `); expect(releaseGroupToFilteredProjects).toMatchInlineSnapshot(` Map { { + "changelog": false, "name": "__default__", "projects": [ "lib-a", "lib-a", ], + "releaseTagPattern": "", + "version": { + "generator": "", + "generatorOptions": {}, + }, } => Set { "lib-a", }, @@ -209,9 +302,21 @@ describe('filterReleaseGroups()', () => { nxReleaseConfig.groups = { foo: { projects: ['lib-a'], + changelog: false, + version: { + generator: '', + generatorOptions: {}, + }, + releaseTagPattern: '', }, bar: { projects: ['lib-b'], + changelog: false, + version: { + generator: '', + generatorOptions: {}, + }, + releaseTagPattern: '', }, }; const { error, releaseGroups, releaseGroupToFilteredProjects } = @@ -220,20 +325,32 @@ describe('filterReleaseGroups()', () => { expect(releaseGroups).toMatchInlineSnapshot(` [ { + "changelog": false, "name": "foo", "projects": [ "lib-a", ], + "releaseTagPattern": "", + "version": { + "generator": "", + "generatorOptions": {}, + }, }, ] `); expect(releaseGroupToFilteredProjects).toMatchInlineSnapshot(` Map { { + "changelog": false, "name": "foo", "projects": [ "lib-a", ], + "releaseTagPattern": "", + "version": { + "generator": "", + "generatorOptions": {}, + }, } => Set { "lib-a", }, diff --git a/packages/nx/src/command-line/release/utils/markdown.spec.ts b/packages/nx/src/command-line/release/utils/markdown.spec.ts index 013ac62c061a4..653181d841df0 100644 --- a/packages/nx/src/command-line/release/utils/markdown.spec.ts +++ b/packages/nx/src/command-line/release/utils/markdown.spec.ts @@ -1,163 +1,10 @@ -import { GitCommit } from './git'; -import { parseChangelogMarkdown, generateMarkdown } from './markdown'; +import { parseChangelogMarkdown } from './markdown'; describe('markdown utils', () => { - describe('generateMarkdown()', () => { - it('should generate markdown for commits organized by type, then grouped by scope within the type (sorted alphabetically), then chronologically within the scope group', async () => { - const commits: GitCommit[] = [ - { - message: 'fix: all packages fixed', - shortHash: '4130f65', - author: { - name: 'James Henry', - email: 'jh@example.com', - }, - body: '"\n\nM\tpackages/pkg-a/src/index.ts\nM\tpackages/pkg-b/src/index.ts\n"', - authors: [ - { - name: 'James Henry', - email: 'jh@example.com', - }, - ], - description: 'all packages fixed', - type: 'fix', - scope: '', - references: [ - { - value: '4130f65', - type: 'hash', - }, - ], - isBreaking: false, - }, - { - message: 'feat(pkg-b): and another new capability', - shortHash: '7dc5ec3', - author: { - name: 'James Henry', - email: 'jh@example.com', - }, - body: '"\n\nM\tpackages/pkg-b/src/index.ts\n"', - authors: [ - { - name: 'James Henry', - email: 'jh@example.com', - }, - ], - description: 'and another new capability', - type: 'feat', - scope: 'pkg-b', - references: [ - { - value: '7dc5ec3', - type: 'hash', - }, - ], - isBreaking: false, - }, - { - message: 'feat(pkg-a): new hotness', - shortHash: 'd7a58a2', - author: { - name: 'James Henry', - email: 'jh@example.com', - }, - body: '"\n\nM\tpackages/pkg-a/src/index.ts\n"', - authors: [ - { - name: 'James Henry', - email: 'jh@example.com', - }, - ], - description: 'new hotness', - type: 'feat', - scope: 'pkg-a', - references: [ - { - value: 'd7a58a2', - type: 'hash', - }, - ], - isBreaking: false, - }, - { - message: 'feat(pkg-b): brand new thing', - shortHash: 'feace4a', - author: { - name: 'James Henry', - email: 'jh@example.com', - }, - body: '"\n\nM\tpackages/pkg-b/src/index.ts\n"', - authors: [ - { - name: 'James Henry', - email: 'jh@example.com', - }, - ], - description: 'brand new thing', - type: 'feat', - scope: 'pkg-b', - references: [ - { - value: 'feace4a', - type: 'hash', - }, - ], - isBreaking: false, - }, - { - message: 'fix(pkg-a): squashing bugs', - shortHash: '6301405', - author: { - name: 'James Henry', - email: 'jh@example.com', - }, - body: '"\n\nM\tpackages/pkg-a/src/index.ts\n', - authors: [ - { - name: 'James Henry', - email: 'jh@example.com', - }, - ], - description: 'squashing bugs', - type: 'fix', - scope: 'pkg-a', - references: [ - { - value: '6301405', - type: 'hash', - }, - ], - isBreaking: false, - }, - ]; - - expect(await generateMarkdown(commits, 'v1.1.0')).toMatchInlineSnapshot(` - "## v1.1.0 - - - ### 🚀 Features - - - **pkg-a:** new hotness - - **pkg-b:** brand new thing - - **pkg-b:** and another new capability - - ### 🩹 Fixes - - - all packages fixed - - **pkg-a:** squashing bugs - - ### ❤️ Thank You - - - James Henry" - `); - }); - }); - describe('parseChangelogMarkdown()', () => { it('should extract the versions from the given markdown', () => { const markdown = ` -## v0.0.3 +## 0.0.3 ### 🩹 Fixes @@ -168,7 +15,7 @@ describe('markdown utils', () => { - James Henry -## v0.0.2 +## 0.0.2 ### 🚀 Features @@ -183,7 +30,7 @@ describe('markdown utils', () => { - James Henry `; - expect(parseChangelogMarkdown(markdown, 'v')).toMatchInlineSnapshot(` + expect(parseChangelogMarkdown(markdown)).toMatchInlineSnapshot(` { "releases": [ { @@ -214,86 +61,5 @@ describe('markdown utils', () => { } `); }); - - it('should work for custom tagVersionPrefix values', () => { - expect( - // Empty string - no prefix - parseChangelogMarkdown( - ` -## 0.0.3 - - -### 🩹 Fixes - -- **baz:** bugfix for baz - -## 0.0.2 - - -### 🚀 Features - -- **foo:** some feature in foo - -`, - '' - ) - ).toMatchInlineSnapshot(` - { - "releases": [ - { - "body": "### 🩹 Fixes - - - **baz:** bugfix for baz", - "version": "0.0.3", - }, - { - "body": "### 🚀 Features - - - **foo:** some feature in foo", - "version": "0.0.2", - }, - ], - } - `); - - expect( - parseChangelogMarkdown( - ` -## v.0.0.3 - - -### 🩹 Fixes - -- **baz:** bugfix for baz - -## v.0.0.2 - - -### 🚀 Features - -- **foo:** some feature in foo - - `, - 'v.' // multi-character, and including regex special character - ) - ).toMatchInlineSnapshot(` - { - "releases": [ - { - "body": "### 🩹 Fixes - - - **baz:** bugfix for baz", - "version": "0.0.3", - }, - { - "body": "### 🚀 Features - - - **foo:** some feature in foo", - "version": "0.0.2", - }, - ], - } - `); - }); }); }); diff --git a/packages/nx/src/command-line/release/utils/markdown.ts b/packages/nx/src/command-line/release/utils/markdown.ts index 73e215feb2bf4..b88e8008cb9a4 100644 --- a/packages/nx/src/command-line/release/utils/markdown.ts +++ b/packages/nx/src/command-line/release/utils/markdown.ts @@ -1,178 +1,6 @@ -import { GitCommit } from './git'; -import { RepoSlug, formatReferences } from './github'; - -// axios types and values don't seem to match -import _axios = require('axios'); -const axios = _axios as any as typeof _axios['default']; - -function formatName(name = '') { - return name - .split(' ') - .map((p) => p.trim()) - .join(' '); -} - -function groupBy(items: any[], key: string) { - const groups = {}; - for (const item of items) { - groups[item[key]] = groups[item[key]] || []; - groups[item[key]].push(item); - } - return groups; -} - -function formatCommit(commit: GitCommit, repoSlug?: RepoSlug): string { - let commitLine = - '- ' + - (commit.scope ? `**${commit.scope.trim()}:** ` : '') + - (commit.isBreaking ? '⚠️ ' : '') + - commit.description; - if (repoSlug) { - commitLine += formatReferences(commit.references, repoSlug); - } - return commitLine; -} - -// TODO: allow this to be configurable via config in a future release -export async function generateMarkdown( - commits: GitCommit[], - releaseVersion: string, - repoSlug?: RepoSlug -) { - const typeGroups = groupBy(commits, 'type'); - - const markdown: string[] = []; - const breakingChanges = []; - - const commitTypes = { - feat: { title: '🚀 Features' }, - perf: { title: '🔥 Performance' }, - fix: { title: '🩹 Fixes' }, - refactor: { title: '💅 Refactors' }, - docs: { title: '📖 Documentation' }, - build: { title: '📦 Build' }, - types: { title: '🌊 Types' }, - chore: { title: '🏡 Chore' }, - examples: { title: '🏀 Examples' }, - test: { title: '✅ Tests' }, - style: { title: '🎨 Styles' }, - ci: { title: '🤖 CI' }, - }; - - // Version Title - markdown.push('', `## ${releaseVersion}`, ''); - - for (const type of Object.keys(commitTypes)) { - const group = typeGroups[type]; - if (!group || group.length === 0) { - continue; - } - - markdown.push('', '### ' + commitTypes[type].title, ''); - - /** - * In order to make the final changelog most readable, we organize commits as follows: - * - By scope, where scopes are in alphabetical order (commits with no scope are listed first) - * - Within a particular scope grouping, we list commits in chronological order - */ - const commitsInChronologicalOrder = group.reverse(); - const commitsGroupedByScope = groupBy(commitsInChronologicalOrder, 'scope'); - const scopesSortedAlphabetically = Object.keys( - commitsGroupedByScope - ).sort(); - - for (const scope of scopesSortedAlphabetically) { - const commits = commitsGroupedByScope[scope]; - for (const commit of commits) { - const line = formatCommit(commit, repoSlug); - markdown.push(line); - if (commit.isBreaking) { - breakingChanges.push(line); - } - } - } - } - - if (breakingChanges.length > 0) { - markdown.push('', '#### ⚠️ Breaking Changes', '', ...breakingChanges); - } - - const _authors = new Map; github?: string }>(); - for (const commit of commits) { - if (!commit.author) { - continue; - } - const name = formatName(commit.author.name); - if (!name || name.includes('[bot]')) { - continue; - } - if (_authors.has(name)) { - const entry = _authors.get(name); - entry.email.add(commit.author.email); - } else { - _authors.set(name, { email: new Set([commit.author.email]) }); - } - } - - // Try to map authors to github usernames - if (repoSlug) { - await Promise.all( - [..._authors.keys()].map(async (authorName) => { - const meta = _authors.get(authorName); - for (const email of meta.email) { - // For these pseudo-anonymized emails we can just extract the Github username from before the @ - if (email.endsWith('@users.noreply.github.com')) { - meta.github = email.split('@')[0]; - break; - } - // Look up any other emails against the ungh.cc API - const { data } = await axios - .get( - `https://ungh.cc/users/find/${email}` - ) - .catch(() => ({ data: { user: null } })); - if (data?.user) { - meta.github = data.user.username; - break; - } - } - }) - ); - } - - const authors = [..._authors.entries()].map((e) => ({ name: e[0], ...e[1] })); - - if (authors.length > 0) { - markdown.push( - '', - '### ' + '❤️ Thank You', - '', - ...authors - // Sort the contributors by name - .sort((a, b) => a.name.localeCompare(b.name)) - .map((i) => { - // Tag the author's Github username if we were able to resolve it so that Github adds them as a contributor - const github = i.github ? ` @${i.github}` : ''; - return `- ${i.name}${github}`; - }) - ); - } - - return markdown.join('\n').trim(); -} - -function escapeRegExp(string: string) { - return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); -} - -export function parseChangelogMarkdown( - contents: string, - tagVersionPrefix: any -) { - const escapedTagVersionPrefix = escapeRegExp(tagVersionPrefix); - +export function parseChangelogMarkdown(contents: string) { const CHANGELOG_RELEASE_HEAD_RE = new RegExp( - '^#{2,}\\s+' + escapedTagVersionPrefix + '(\\d+\\.\\d+\\.\\d+)', + '^#{2,}\\s+(\\d+\\.\\d+\\.\\d+)', 'gm' ); diff --git a/packages/nx/src/command-line/release/utils/print-changes.ts b/packages/nx/src/command-line/release/utils/print-changes.ts index 876b375dc0800..a589f9609bfbc 100644 --- a/packages/nx/src/command-line/release/utils/print-changes.ts +++ b/packages/nx/src/command-line/release/utils/print-changes.ts @@ -39,8 +39,10 @@ export function printChanges( isDryRun: boolean, diffContextLines = 1, shouldPrintDryRunMessage = true, - noDiffMessage?: string + noDiffMessage?: string, + changePredicate?: (f: { path: string; content?: Buffer }) => boolean ) { + changePredicate = changePredicate || (() => true); const changes = tree.listChanges(); console.log(''); @@ -51,7 +53,7 @@ export function printChanges( } // Print the changes - changes.forEach((f) => { + changes.filter(changePredicate).forEach((f) => { if (f.type === 'CREATE') { console.error( `${chalk.green('CREATE')} ${f.path}${ diff --git a/packages/nx/src/config/nx-json.ts b/packages/nx/src/config/nx-json.ts index 1acda2fd8847c..eff7a9441f81d 100644 --- a/packages/nx/src/config/nx-json.ts +++ b/packages/nx/src/config/nx-json.ts @@ -1,9 +1,10 @@ -import { dirname, join } from 'path'; import { existsSync } from 'fs'; +import { dirname, join } from 'path'; +import type { ChangelogRenderOptions } from '../../changelog-renderer'; import { readJsonFile } from '../utils/fileutils'; -import { workspaceRoot } from '../utils/workspace-root'; import { PackageManager } from '../utils/package-manager'; +import { workspaceRoot } from '../utils/workspace-root'; import { InputDefinition, TargetConfiguration, @@ -58,6 +59,51 @@ interface NxReleaseVersionConfiguration { generatorOptions?: Record; } +/** + * **ALPHA** + */ +export interface NxReleaseChangelogConfiguration { + /** + * Optionally create a release containing all relevant changes on a supported version control system, it + * is false by default. + * + * NOTE: if createRelease is set on a group of projects, it will cause the default releaseTagPattern of + * "{projectName}@v{version}" to be used for those projects, even when versioning everything together. + */ + createRelease?: 'github' | false; + /** + * This can either be set to a string value that will be written to the changelog file(s) + * at the workspace root and/or within project directories, or set to `false` to specify + * that no changelog entry should be made when there are no code changes. + * + * NOTE: The string value has a sensible default value and supports interpolation of + * {projectName} when generating for project level changelogs. + * + * E.g. for a project level changelog you could customize the message to something like: + * "entryWhenNoChanges": "There were no code changes for {projectName}" + */ + entryWhenNoChanges?: string | false; + /** + * This is either a workspace path where the changelog markdown file will be created and read from, + * or set to false to disable file creation altogether (e.g. if only using Github releases). + * + * Interpolation of {projectName}, {projectRoot} and {workspaceRoot} is supported. + * + * The defaults are: + * - "{workspaceRoot}/CHANGELOG.md" at the workspace level + * - "{projectRoot}/CHANGELOG.md" at the project level + */ + file?: string | false; + /** + * A path to a valid changelog renderer function used to transform commit messages and other metadata into + * the final changelog (usually in markdown format). Its output can be modified using the optional `renderOptions`. + * + * By default, the renderer is set to "nx/changelog-renderer" which nx provides out of the box. + */ + renderer?: string; + renderOptions?: ChangelogRenderOptions; +} + /** * **ALPHA** */ @@ -67,16 +113,47 @@ interface NxReleaseConfiguration { * if they were in a release group together. */ groups?: Record< - string, + string, // group name { + /** + * Required list of one or more projects to include in the release group. Any single project can + * only be used in a maximum of one release group. + */ projects: string[] | string; /** - * If no version config is provided for the group, we will assume that @nx/js:release-version - * is the desired generator implementation, allowing for terser config for the common case. + * Optionally override version configuration for this group. */ version?: NxReleaseVersionConfiguration; + /** + * Optionally override project changelog configuration for this group. + */ + changelog?: NxReleaseChangelogConfiguration | false; + /** + * Optionally override the git/release tag pattern to use for this group. + */ + releaseTagPattern?: string; } >; + changelog?: { + workspaceChangelog?: NxReleaseChangelogConfiguration | false; + projectChangelogs?: NxReleaseChangelogConfiguration | false; + }; + /** + * If no version config is provided, we will assume that @nx/js:release-version + * is the desired generator implementation, allowing for terser config for the common case. + */ + version?: NxReleaseVersionConfiguration; + /** + * Optional override the git/release tag pattern to use. This field is the source of truth + * for changelog generation and release tagging, as well as for conventional-commits parsing. + * + * It supports interpolating the version as {version} and (if releasing independently or forcing + * project level version control system releases) the project name as {projectName} within the string. + * + * The default releaseTagPattern for unified releases is: "v{version}" + * The default releaseTagPattern for releases at the project level is: "{projectName}@v{version}" + */ + releaseTagPattern?: string; } /** From f19d1db8817e88ef8942e248c9293de43934c707 Mon Sep 17 00:00:00 2001 From: Jonathan Cammisuli Date: Fri, 20 Oct 2023 17:20:00 -0400 Subject: [PATCH 2/3] feat(core): remove parcel/watcher (#19751) --- packages/nx/package.json | 1 - packages/nx/src/daemon/client/client.ts | 6 - .../daemon/server/outputs-tracking.spec.ts | 15 +- .../nx/src/daemon/server/outputs-tracking.ts | 10 +- ...project-graph-incremental-recomputation.ts | 29 +--- packages/nx/src/daemon/server/server.ts | 139 ++++++------------ .../nx/src/daemon/server/shutdown-utils.ts | 44 ------ packages/nx/src/daemon/server/watcher.ts | 127 +--------------- 8 files changed, 61 insertions(+), 310 deletions(-) diff --git a/packages/nx/package.json b/packages/nx/package.json index 3c002f4cb203e..a3ea112f90d2a 100644 --- a/packages/nx/package.json +++ b/packages/nx/package.json @@ -33,7 +33,6 @@ }, "homepage": "https://nx.dev", "dependencies": { - "@parcel/watcher": "2.0.4", "@yarnpkg/lockfile": "^1.1.0", "@yarnpkg/parsers": "3.0.0-rc.46", "@zkochan/js-yaml": "0.0.6", diff --git a/packages/nx/src/daemon/client/client.ts b/packages/nx/src/daemon/client/client.ts index d31f3349496c4..7a4a85372e00a 100644 --- a/packages/nx/src/daemon/client/client.ts +++ b/packages/nx/src/daemon/client/client.ts @@ -366,12 +366,6 @@ export class DaemonClient { this._out = await open(DAEMON_OUTPUT_LOG_FILE, 'a'); this._err = await open(DAEMON_OUTPUT_LOG_FILE, 'a'); - if (this.nxJson.tasksRunnerOptions?.default?.options?.useParcelWatcher) { - DAEMON_ENV_SETTINGS['NX_NATIVE_WATCHER'] = 'false'; - } else { - DAEMON_ENV_SETTINGS['NX_NATIVE_WATCHER'] = 'true'; - } - const backgroundProcess = spawn( process.execPath, [join(__dirname, '../server/start.js')], diff --git a/packages/nx/src/daemon/server/outputs-tracking.spec.ts b/packages/nx/src/daemon/server/outputs-tracking.spec.ts index cca4b91699dca..3854e512bd6f8 100644 --- a/packages/nx/src/daemon/server/outputs-tracking.spec.ts +++ b/packages/nx/src/daemon/server/outputs-tracking.spec.ts @@ -1,3 +1,4 @@ +import { EventType } from '../../native'; import { _outputsHashesMatch, _recordOutputsHash, @@ -19,7 +20,7 @@ describe('outputs tracking', () => { it('should invalidate output when it is exact match', () => { _recordOutputsHash(['dist/app/app1'], '123'); processFileChangesInOutputs( - [{ path: 'dist/app/app1', type: 'update' }], + [{ path: 'dist/app/app1', type: EventType.update }], now ); expect(recordedHash('dist/app/app1')).toBeUndefined(); @@ -28,7 +29,7 @@ describe('outputs tracking', () => { it('should invalidate output when it is a child', () => { _recordOutputsHash(['dist/app/app1'], '123'); processFileChangesInOutputs( - [{ path: 'dist/app/app1/child', type: 'update' }], + [{ path: 'dist/app/app1/child', type: EventType.update }], now ); expect(recordedHash('dist/app/app1')).toBeUndefined(); @@ -36,13 +37,19 @@ describe('outputs tracking', () => { it('should invalidate output when it is a parent', () => { _recordOutputsHash(['dist/app/app1'], '123'); - processFileChangesInOutputs([{ path: 'dist/app', type: 'update' }], now); + processFileChangesInOutputs( + [{ path: 'dist/app', type: EventType.update }], + now + ); expect(recordedHash('dist/app/app1')).toBeUndefined(); }); it('should not invalidate anything when no match', () => { _recordOutputsHash(['dist/app/app1'], '123'); - processFileChangesInOutputs([{ path: 'dist/app2', type: 'update' }], now); + processFileChangesInOutputs( + [{ path: 'dist/app2', type: EventType.update }], + now + ); expect(recordedHash('dist/app/app1')).toEqual('123'); }); }); diff --git a/packages/nx/src/daemon/server/outputs-tracking.ts b/packages/nx/src/daemon/server/outputs-tracking.ts index 448ab5fabd5a2..c0a91af22e15b 100644 --- a/packages/nx/src/daemon/server/outputs-tracking.ts +++ b/packages/nx/src/daemon/server/outputs-tracking.ts @@ -1,9 +1,7 @@ -import { lstat } from 'fs-extra'; -import { dirname, join } from 'path'; -import { workspaceRoot } from '../../utils/workspace-root'; +import { dirname } from 'path'; +import { WatchEvent, getFilesForOutputs } from '../../native'; import { collapseExpandedOutputs } from '../../utils/collapse-expanded-outputs'; -import type { Event } from '@parcel/watcher'; -import { getFilesForOutputs } from '../../native'; +import { workspaceRoot } from '../../utils/workspace-root'; let disabled = false; @@ -66,7 +64,7 @@ async function normalizeOutputs(outputs: string[]) { } export function processFileChangesInOutputs( - changeEvents: Event[], + changeEvents: WatchEvent[], now: number = undefined ) { if (!now) { diff --git a/packages/nx/src/daemon/server/project-graph-incremental-recomputation.ts b/packages/nx/src/daemon/server/project-graph-incremental-recomputation.ts index a6b59dd15b257..fe2036ffcf2ad 100644 --- a/packages/nx/src/daemon/server/project-graph-incremental-recomputation.ts +++ b/packages/nx/src/daemon/server/project-graph-incremental-recomputation.ts @@ -140,37 +140,10 @@ function computeWorkspaceConfigHash( return hashArray(projectConfigurationStrings); } -/** - * Temporary work around to handle nested gitignores. The parcel file watcher doesn't handle them well, - * so we need to filter them out here. - * - * TODO(Cammisuli): remove after 16.4 - Rust watcher handles nested gitignores - */ -function filterUpdatedFiles(files: string[]) { - if (files.length === 0 || process.env.NX_NATIVE_WATCHER === 'true') { - return files; - } - - try { - const quoted = files.map((f) => '"' + f + '"'); - const ignored = execSync(`git check-ignore ${quoted.join(' ')}`, { - windowsHide: true, - }) - .toString() - .split('\n'); - return files.filter((f) => ignored.indexOf(f) === -1); - } catch (e) { - // none of the files were ignored - return files; - } -} - async function processCollectedUpdatedAndDeletedFiles() { try { performance.mark('hash-watched-changes-start'); - const updatedFiles = filterUpdatedFiles([ - ...collectedUpdatedFiles.values(), - ]); + const updatedFiles = [...collectedUpdatedFiles.values()]; const deletedFiles = [...collectedDeletedFiles.values()]; let updatedFileHashes = updateFilesInContext(updatedFiles, deletedFiles); performance.mark('hash-watched-changes-end'); diff --git a/packages/nx/src/daemon/server/server.ts b/packages/nx/src/daemon/server/server.ts index 4c1186b54ecf6..cc81918d014ed 100644 --- a/packages/nx/src/daemon/server/server.ts +++ b/packages/nx/src/daemon/server/server.ts @@ -1,65 +1,57 @@ -import { workspaceRoot } from '../../utils/workspace-root'; +import { existsSync, statSync } from 'fs'; import { createServer, Server, Socket } from 'net'; import { join } from 'path'; import { PerformanceObserver } from 'perf_hooks'; +import { hashArray } from '../../hasher/file-hasher'; +import { hashFile } from '../../native'; +import { consumeMessagesFromSocket } from '../../utils/consume-messages-from-socket'; +import { readJsonFile } from '../../utils/fileutils'; +import { PackageJson } from '../../utils/package-json'; +import { nxVersion } from '../../utils/versions'; +import { setupWorkspaceContext } from '../../utils/workspace-context'; +import { workspaceRoot } from '../../utils/workspace-root'; +import { writeDaemonJsonProcessCache } from '../cache'; import { FULL_OS_SOCKET_PATH, isWindows, killSocketOrPath, } from '../socket-utils'; +import { + registeredFileWatcherSockets, + removeRegisteredFileWatcherSocket, +} from './file-watching/file-watcher-sockets'; +import { handleHashTasks } from './handle-hash-tasks'; +import { + handleOutputsHashesMatch, + handleRecordOutputsHash, +} from './handle-outputs-tracking'; +import { handleProcessInBackground } from './handle-process-in-background'; +import { handleRequestFileData } from './handle-request-file-data'; +import { handleRequestProjectGraph } from './handle-request-project-graph'; +import { handleRequestShutdown } from './handle-request-shutdown'; import { serverLogger } from './logger'; import { - getOutputsWatcherSubscription, + disableOutputsTracking, + processFileChangesInOutputs, +} from './outputs-tracking'; +import { addUpdatedAndDeletedFiles } from './project-graph-incremental-recomputation'; +import { getOutputWatcherInstance, - getSourceWatcherSubscription, getWatcherInstance, handleServerProcessTermination, resetInactivityTimeout, respondToClient, respondWithErrorAndExit, SERVER_INACTIVITY_TIMEOUT_MS, - storeOutputsWatcherSubscription, storeOutputWatcherInstance, - storeProcessJsonSubscription, - storeSourceWatcherSubscription, storeWatcherInstance, } from './shutdown-utils'; import { convertChangeEventsToLogMessage, - subscribeToOutputsChanges, - subscribeToWorkspaceChanges, FileWatcherCallback, - subscribeToServerProcessJsonChanges, - watchWorkspace, watchOutputFiles, + watchWorkspace, } from './watcher'; -import { addUpdatedAndDeletedFiles } from './project-graph-incremental-recomputation'; -import { existsSync, statSync } from 'fs'; -import { handleRequestProjectGraph } from './handle-request-project-graph'; -import { handleProcessInBackground } from './handle-process-in-background'; -import { - handleOutputsHashesMatch, - handleRecordOutputsHash, -} from './handle-outputs-tracking'; -import { consumeMessagesFromSocket } from '../../utils/consume-messages-from-socket'; -import { - disableOutputsTracking, - processFileChangesInOutputs, -} from './outputs-tracking'; -import { handleRequestShutdown } from './handle-request-shutdown'; -import { - registeredFileWatcherSockets, - removeRegisteredFileWatcherSocket, -} from './file-watching/file-watcher-sockets'; -import { nxVersion } from '../../utils/versions'; -import { readJsonFile } from '../../utils/fileutils'; -import { PackageJson } from '../../utils/package-json'; -import { getDaemonProcessIdSync, writeDaemonJsonProcessCache } from '../cache'; -import { handleHashTasks } from './handle-hash-tasks'; -import { hashArray } from '../../hasher/file-hasher'; -import { handleRequestFileData } from './handle-request-file-data'; -import { setupWorkspaceContext } from '../../utils/workspace-context'; -import { hashFile } from '../../native'; let performanceObserver: PerformanceObserver | undefined; let workspaceWatcherError: Error | undefined; @@ -109,7 +101,6 @@ const server = createServer(async (socket) => { }); }); registerProcessTerminationListeners(); -registerProcessServerJsonTracking(); async function handleMessage(socket, data: string) { if (workspaceWatcherError) { @@ -239,23 +230,6 @@ function registerProcessTerminationListeners() { ); } -async function registerProcessServerJsonTracking() { - if (useNativeWatcher()) { - return; - } - - storeProcessJsonSubscription( - await subscribeToServerProcessJsonChanges(async () => { - if (getDaemonProcessIdSync() !== process.pid) { - await handleServerProcessTermination({ - server, - reason: 'this process is no longer the current daemon', - }); - } - }) - ); -} - let existingLockHash: string | undefined; function daemonIsOutdated(): boolean { @@ -419,46 +393,20 @@ export async function startServer(): Promise { // this triggers the storage of the lock file hash daemonIsOutdated(); - if (useNativeWatcher()) { - if (!getWatcherInstance()) { - storeWatcherInstance( - await watchWorkspace(server, handleWorkspaceChanges) - ); + if (!getWatcherInstance()) { + storeWatcherInstance( + await watchWorkspace(server, handleWorkspaceChanges) + ); - serverLogger.watcherLog( - `Subscribed to changes within: ${workspaceRoot} (native)` - ); - } - - if (!getOutputWatcherInstance()) { - storeOutputWatcherInstance( - await watchOutputFiles(handleOutputsChanges) - ); - } - } else { - if (!getSourceWatcherSubscription()) { - storeSourceWatcherSubscription( - await subscribeToWorkspaceChanges( - server, - handleWorkspaceChanges - ) - ); - serverLogger.watcherLog( - `Subscribed to changes within: ${workspaceRoot}` - ); - } + serverLogger.watcherLog( + `Subscribed to changes within: ${workspaceRoot} (native)` + ); + } - // temporary disable outputs tracking on linux - const outputsTrackingIsEnabled = process.platform != 'linux'; - if (outputsTrackingIsEnabled) { - if (!getOutputsWatcherSubscription()) { - storeOutputsWatcherSubscription( - await subscribeToOutputsChanges(handleOutputsChanges) - ); - } - } else { - disableOutputsTracking(); - } + if (!getOutputWatcherInstance()) { + storeOutputWatcherInstance( + await watchOutputFiles(handleOutputsChanges) + ); } return resolve(server); @@ -471,8 +419,3 @@ export async function startServer(): Promise { } }); } - -// TODO(cammisuli): remove with nx 16.6 (only our watcher will be supported) -function useNativeWatcher() { - return process.env.NX_NATIVE_WATCHER === 'true'; -} diff --git a/packages/nx/src/daemon/server/shutdown-utils.ts b/packages/nx/src/daemon/server/shutdown-utils.ts index 9bbc05053a65c..a60f84d4412a6 100644 --- a/packages/nx/src/daemon/server/shutdown-utils.ts +++ b/packages/nx/src/daemon/server/shutdown-utils.ts @@ -2,37 +2,11 @@ import { workspaceRoot } from '../../utils/workspace-root'; import type { Server, Socket } from 'net'; import { serverLogger } from './logger'; import { serializeResult } from '../socket-utils'; -import type { AsyncSubscription } from '@parcel/watcher'; import { deleteDaemonJsonProcessCache } from '../cache'; import type { Watcher } from '../../native'; export const SERVER_INACTIVITY_TIMEOUT_MS = 10800000 as const; // 10800000 ms = 3 hours -let sourceWatcherSubscription: AsyncSubscription | undefined; -let outputsWatcherSubscription: AsyncSubscription | undefined; - -export function getSourceWatcherSubscription() { - return sourceWatcherSubscription; -} - -export function storeSourceWatcherSubscription(s: AsyncSubscription) { - sourceWatcherSubscription = s; -} - -export function getOutputsWatcherSubscription() { - return outputsWatcherSubscription; -} - -export function storeOutputsWatcherSubscription(s: AsyncSubscription) { - outputsWatcherSubscription = s; -} - -let processJsonSubscription: AsyncSubscription | undefined; - -export function storeProcessJsonSubscription(s: AsyncSubscription) { - processJsonSubscription = s; -} - let watcherInstance: Watcher | undefined; export function storeWatcherInstance(instance: Watcher) { watcherInstance = instance; @@ -61,24 +35,6 @@ export async function handleServerProcessTermination({ try { server.close(); deleteDaemonJsonProcessCache(); - if (sourceWatcherSubscription) { - await sourceWatcherSubscription.unsubscribe(); - serverLogger.watcherLog( - `Unsubscribed from changes within: ${workspaceRoot} (sources)` - ); - } - if (outputsWatcherSubscription) { - await outputsWatcherSubscription.unsubscribe(); - serverLogger.watcherLog( - `Unsubscribed from changes within: ${workspaceRoot} (outputs)` - ); - } - if (processJsonSubscription) { - await processJsonSubscription.unsubscribe(); - serverLogger.watcherLog( - `Unsubscribed from changes within: ${workspaceRoot} (server-process.json)` - ); - } if (watcherInstance) { await watcherInstance.stop(); diff --git a/packages/nx/src/daemon/server/watcher.ts b/packages/nx/src/daemon/server/watcher.ts index 163a4e1e2fcc8..8e47c72844fb2 100644 --- a/packages/nx/src/daemon/server/watcher.ts +++ b/packages/nx/src/daemon/server/watcher.ts @@ -1,12 +1,4 @@ -/** - * In addition to its native performance, another great advantage of `@parcel/watcher` is that it will - * automatically take advantage of Facebook's watchman tool (https://facebook.github.io/watchman/) if - * the user has it installed (but not require it if they don't). - * - * See https://github.com/parcel-bundler/watcher for more details. - */ import { workspaceRoot } from '../../utils/workspace-root'; -import type { AsyncSubscription, Event } from '@parcel/watcher'; import { dirname, relative } from 'path'; import { FULL_OS_SOCKET_PATH } from '../socket-utils'; import { handleServerProcessTermination } from './shutdown-utils'; @@ -25,34 +17,9 @@ const ALWAYS_IGNORE = [...getAlwaysIgnore(workspaceRoot), FULL_OS_SOCKET_PATH]; export type FileWatcherCallback = ( err: Error | string | null, - changeEvents: Event[] | WatchEvent[] | null + changeEvents: WatchEvent[] | null ) => Promise; -export async function subscribeToOutputsChanges( - cb: FileWatcherCallback -): Promise { - const watcher = await import('@parcel/watcher'); - return await watcher.subscribe( - workspaceRoot, - (err, events) => { - if (err) { - return cb(err, null); - } else { - const workspaceRelativeEvents: Event[] = []; - for (const event of events) { - const workspaceRelativeEvent: Event = { - type: event.type, - path: normalizePath(relative(workspaceRoot, event.path)), - }; - workspaceRelativeEvents.push(workspaceRelativeEvent); - } - cb(null, workspaceRelativeEvents); - } - }, - watcherOptions([...ALWAYS_IGNORE]) - ); -} - export async function watchWorkspace(server: Server, cb: FileWatcherCallback) { const { Watcher } = await import('../../native'); @@ -115,88 +82,14 @@ export async function watchOutputFiles(cb: FileWatcherCallback) { return watcher; } -export async function subscribeToWorkspaceChanges( - server: Server, - cb: FileWatcherCallback -): Promise { - /** - * The imports and exports of @nx/workspace are somewhat messy and far reaching across the repo (and beyond), - * and so it is much safer for us to lazily load here `@parcel/watcher` so that its inclusion is not inadvertently - * executed by packages which do not have its necessary native binaries available. - */ - const watcher = await import('@parcel/watcher'); - const ignoreObj = getIgnoreObject(); - - return await watcher.subscribe( - workspaceRoot, - (err, events) => { - if (err) { - return cb(err, null); - } - - let hasIgnoreFileUpdate = false; - - // Most of our utilities (ignore, hashing etc) require unix-style workspace relative paths - const workspaceRelativeEvents: Event[] = []; - for (const event of events) { - const workspaceRelativeEvent: Event = { - type: event.type, - path: normalizePath(relative(workspaceRoot, event.path)), - }; - if ( - workspaceRelativeEvent.path.endsWith('.gitignore') || - workspaceRelativeEvent.path === '.nxignore' - ) { - hasIgnoreFileUpdate = true; - } - workspaceRelativeEvents.push(workspaceRelativeEvent); - } - - // If the ignore files themselves have changed we need to dynamically update our cached ignoreGlobs - if (hasIgnoreFileUpdate) { - handleServerProcessTermination({ - server, - reason: 'Stopping the daemon the set of ignored files changed.', - }); - } - - const nonIgnoredEvents = workspaceRelativeEvents - .filter(({ path }) => !!path) - .filter(({ path }) => !ignoreObj.ignores(path)); - - if (nonIgnoredEvents && nonIgnoredEvents.length > 0) { - cb(null, nonIgnoredEvents); - } - }, - watcherOptions(getIgnoredGlobs(workspaceRoot)) - ); -} - -// TODO: When we update @parcel/watcher to a version that handles negation globs, then this can be folded into the workspace watcher -export async function subscribeToServerProcessJsonChanges( - cb: () => void -): Promise { - const watcher = await import('@parcel/watcher'); - - return await watcher.subscribe( - dirname(serverProcessJsonPath), - (err, events) => { - for (const event of events) { - if (event.path === serverProcessJsonPath) { - cb(); - } - } - }, - watcherOptions([]) - ); -} - /** * NOTE: An event type of "create" will also apply to the case where the user has restored * an original version of a file after modifying/deleting it by using git, so we adjust * our log language accordingly. */ -export function convertChangeEventsToLogMessage(changeEvents: Event[]): string { +export function convertChangeEventsToLogMessage( + changeEvents: WatchEvent[] +): string { // If only a single file was changed, show the information inline if (changeEvents.length === 1) { const { path, type } = changeEvents[0]; @@ -234,15 +127,3 @@ export function convertChangeEventsToLogMessage(changeEvents: Event[]): string { return `${numCreatedOrRestoredFiles} file(s) created or restored, ${numModifiedFiles} file(s) modified, ${numDeletedFiles} file(s) deleted`; } - -function watcherOptions(ignore: string[]) { - const options: import('@parcel/watcher').Options = { - ignore, - }; - - if (platform() === 'win32') { - options.backend = 'windows'; - } - - return options; -} From ac038e353c32b96655aa8c6145d6d0666361fe76 Mon Sep 17 00:00:00 2001 From: Isaac Mann Date: Fri, 20 Oct 2023 17:35:03 -0400 Subject: [PATCH 3/3] docs(core): changelog for 17.0.0 (#19764) --- docs/changelog/17_0_0.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 docs/changelog/17_0_0.md diff --git a/docs/changelog/17_0_0.md b/docs/changelog/17_0_0.md new file mode 100644 index 0000000000000..f4f2d1bf6ea20 --- /dev/null +++ b/docs/changelog/17_0_0.md @@ -0,0 +1,26 @@ +# Nx 17 + +## Features + +{% cards cols="2" %} +{% card title="Official @nx/vue plugin" type="document" url="/nx-api/vue" /%} +{% card title="Module Federation Improvements" type="document" url="/concepts/module-federation/module-federation-and-nx" /%} +{% card title="Show Task Inputs in the Task Graph Visualizer" type="external" url="https://github.com/nrwl/nx/pull/19597" /%} +{% card title="Move properties from tasksRunnerOptions up to the root of nx.json" type="external" url="https://github.com/nrwl/nx/pull/19243" /%} +{% card title="Each task has its own cache property" type="document" url="/reference/project-configuration#cache" /%} +{% /cards %} + +## Breaking Changes + +Use [the `nx migrate` command](/core-features/automate-updating-dependencies) to automatically account for these breaking changes. + +{% cards cols="2" %} +{% card title="Rename @nx/linter to @nx/eslint" type="document" url="/recipes/other/rescope#rename" /%} +{% card title="All generators use directory flag relative to the CWD" type="external" url="https://github.com/nrwl/nx/pull/19608" /%} +{% card title="Removed workspace generator command" type="external" url="https://github.com/nrwl/nx/pull/19541" /%} +{% card title="Removed workspace-lint command" type="external" url="https://github.com/nrwl/nx/pull/16212" /%} +{% card title="Removed defaultCollection property" type="external" url="https://github.com/nrwl/nx/pull/19708" /%} +{% card title="Removed npmScope property" type="external" url="https://github.com/nrwl/nx/pull/19708" /%} +{% card title="Removed ability for Angular CLI to use our generators and executors" type="external" url="https://github.com/nrwl/nx/pull/19557" /%} +{% card title="Removed exported code from @nrwl/workspace that has been moved elsewhere" type="external" url="https://github.com/nrwl/nx/pull/19588" /%} +{% /cards %}