diff --git a/docs/generated/cli/release.md b/docs/generated/cli/release.md index ae7a7ccf07..1c71fe5a69 100644 --- a/docs/generated/cli/release.md +++ b/docs/generated/cli/release.md @@ -25,12 +25,6 @@ Default: `false` Preview the changes without updating files/creating releases -### first-release - -Type: `boolean` - -Indicates that this is the first release for the selected release group. If the current version cannot be determined as usual, the version on disk will be used as a fallback. This is useful when using git or the registry to determine the current version of packages, since those sources are only available after the first release. Also indicates that changelog generation should not assume a previous git tag exists and that publishing should not check for the existence of the package before running. - ### groups Type: `string` @@ -73,6 +67,12 @@ nx release [specifier] #### Options +##### first-release + +Type: `boolean` + +Indicates that this is the first release for the selected release group. If the current version cannot be determined as usual, the version on disk will be used as a fallback. This is useful when using git or the registry to determine the current version of packages, since those sources are only available after the first release. Also indicates that changelog generation should not assume a previous git tag exists and that publishing should not check for the existence of the package before running. + ##### help Type: `boolean` @@ -113,6 +113,12 @@ nx release version [specifier] #### Options +##### first-release + +Type: `boolean` + +Indicates that this is the first release for the selected release group. If the current version cannot be determined as usual, the version on disk will be used as a fallback. This is useful when using git or the registry to determine the current version of packages, since those sources are only available after the first release. Also indicates that changelog generation should not assume a previous git tag exists and that publishing should not check for the existence of the package before running. + ##### git-commit Type: `boolean` @@ -189,6 +195,12 @@ nx release changelog [version] #### Options +##### first-release + +Type: `boolean` + +Indicates that this is the first release for the selected release group. If the current version cannot be determined as usual, the version on disk will be used as a fallback. This is useful when using git or the registry to determine the current version of packages, since those sources are only available after the first release. Also indicates that changelog generation should not assume a previous git tag exists and that publishing should not check for the existence of the package before running. + ##### from Type: `string` @@ -297,6 +309,12 @@ Type: `string` Exclude certain projects from being processed +##### first-release + +Type: `boolean` + +Indicates that this is the first release for the selected release group. If the current version cannot be determined as usual, the version on disk will be used as a fallback. This is useful when using git or the registry to determine the current version of packages, since those sources are only available after the first release. Also indicates that changelog generation should not assume a previous git tag exists and that publishing should not check for the existence of the package before running. + ##### graph Type: `string` diff --git a/docs/generated/packages/js/generators/release-version.json b/docs/generated/packages/js/generators/release-version.json index 95dd00eba6..1e53ed5a61 100644 --- a/docs/generated/packages/js/generators/release-version.json +++ b/docs/generated/packages/js/generators/release-version.json @@ -30,7 +30,7 @@ "type": "string", "default": "prompt", "description": "Which approach to use to determine the semver specifier used to bump the version of the project.", - "enum": ["prompt", "conventional-commits"] + "enum": ["prompt", "conventional-commits", "version-plans"] }, "preid": { "type": "string", diff --git a/docs/generated/packages/nx/documents/release.md b/docs/generated/packages/nx/documents/release.md index ae7a7ccf07..1c71fe5a69 100644 --- a/docs/generated/packages/nx/documents/release.md +++ b/docs/generated/packages/nx/documents/release.md @@ -25,12 +25,6 @@ Default: `false` Preview the changes without updating files/creating releases -### first-release - -Type: `boolean` - -Indicates that this is the first release for the selected release group. If the current version cannot be determined as usual, the version on disk will be used as a fallback. This is useful when using git or the registry to determine the current version of packages, since those sources are only available after the first release. Also indicates that changelog generation should not assume a previous git tag exists and that publishing should not check for the existence of the package before running. - ### groups Type: `string` @@ -73,6 +67,12 @@ nx release [specifier] #### Options +##### first-release + +Type: `boolean` + +Indicates that this is the first release for the selected release group. If the current version cannot be determined as usual, the version on disk will be used as a fallback. This is useful when using git or the registry to determine the current version of packages, since those sources are only available after the first release. Also indicates that changelog generation should not assume a previous git tag exists and that publishing should not check for the existence of the package before running. + ##### help Type: `boolean` @@ -113,6 +113,12 @@ nx release version [specifier] #### Options +##### first-release + +Type: `boolean` + +Indicates that this is the first release for the selected release group. If the current version cannot be determined as usual, the version on disk will be used as a fallback. This is useful when using git or the registry to determine the current version of packages, since those sources are only available after the first release. Also indicates that changelog generation should not assume a previous git tag exists and that publishing should not check for the existence of the package before running. + ##### git-commit Type: `boolean` @@ -189,6 +195,12 @@ nx release changelog [version] #### Options +##### first-release + +Type: `boolean` + +Indicates that this is the first release for the selected release group. If the current version cannot be determined as usual, the version on disk will be used as a fallback. This is useful when using git or the registry to determine the current version of packages, since those sources are only available after the first release. Also indicates that changelog generation should not assume a previous git tag exists and that publishing should not check for the existence of the package before running. + ##### from Type: `string` @@ -297,6 +309,12 @@ Type: `string` Exclude certain projects from being processed +##### first-release + +Type: `boolean` + +Indicates that this is the first release for the selected release group. If the current version cannot be determined as usual, the version on disk will be used as a fallback. This is useful when using git or the registry to determine the current version of packages, since those sources are only available after the first release. Also indicates that changelog generation should not assume a previous git tag exists and that publishing should not check for the existence of the package before running. + ##### graph Type: `string` diff --git a/e2e/release/project.json b/e2e/release/project.json index 485052d8d9..718f1ee601 100644 --- a/e2e/release/project.json +++ b/e2e/release/project.json @@ -88,6 +88,13 @@ "@nx/nx-source:populate-local-registry-storage" ], "inputs": ["e2eInputs", "^production"] + }, + "e2e-ci--src/version-plans.test.ts": { + "dependsOn": [ + "nx:build-native", + "@nx/nx-source:populate-local-registry-storage" + ], + "inputs": ["e2eInputs", "^production"] } } } diff --git a/e2e/release/src/version-plans.test.ts b/e2e/release/src/version-plans.test.ts new file mode 100644 index 0000000000..389dc0d028 --- /dev/null +++ b/e2e/release/src/version-plans.test.ts @@ -0,0 +1,772 @@ +import { NxJsonConfiguration } from '@nx/devkit'; +import { + cleanupProject, + exists, + newProject, + packageInstall, + runCLI, + runCommand, + runCommandAsync, + tmpProjPath, + uniq, + updateJson, +} from '@nx/e2e/utils'; +import { ensureDir, readdirSync, writeFile } from 'fs-extra'; +import { join } from 'path'; + +expect.addSnapshotSerializer({ + serialize(str: string) { + return ( + str + // Remove all output unique to specific projects to ensure deterministic snapshots + .replaceAll(/my-pkg-\d+/g, '{project-name}') + .replaceAll( + /integrity:\s*.*/g, + 'integrity: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' + ) + .replaceAll(/\b[0-9a-f]{40}\b/g, '{SHASUM}') + .replaceAll(/\d*B index\.js/g, 'XXB index.js') + .replaceAll(/\d*B project\.json/g, 'XXB project.json') + .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}') + .replaceAll(/Test @[\w\d]+/g, 'Test @{COMMIT_AUTHOR}') + // Normalize the version title date. + .replaceAll(/\(\d{4}-\d{2}-\d{2}\)/g, '(YYYY-MM-DD)') + // We trim each line to reduce the chances of snapshot flakiness + .split('\n') + .map((r) => r.trim()) + .join('\n') + ); + }, + test(val: string) { + return val != null && typeof val === 'string'; + }, +}); + +describe('nx release version plans', () => { + let pkg1: string; + let pkg2: string; + let pkg3: string; + let pkg4: string; + let pkg5: string; + + beforeEach(async () => { + newProject({ + unsetProjectNameAndRootFormat: false, + packages: ['@nx/js'], + }); + + pkg1 = uniq('my-pkg-1'); + runCLI(`generate @nx/workspace:npm-package ${pkg1}`); + + pkg2 = uniq('my-pkg-2'); + runCLI(`generate @nx/workspace:npm-package ${pkg2}`); + + pkg3 = uniq('my-pkg-3'); + runCLI(`generate @nx/workspace:npm-package ${pkg3}`); + + pkg4 = uniq('my-pkg-4'); + runCLI(`generate @nx/workspace:npm-package ${pkg4}`); + + pkg5 = uniq('my-pkg-5'); + runCLI(`generate @nx/workspace:npm-package ${pkg5}`); + + await runCommandAsync(`git add .`); + await runCommandAsync(`git commit -m "chore: initial commit"`); + await runCommandAsync(`git tag -a v0.0.0 -m "v0.0.0"`); + await runCommandAsync(`git tag -a ${pkg3}@0.0.0 -m "${pkg3}@0.0.0"`); + await runCommandAsync(`git tag -a ${pkg4}@0.0.0 -m "${pkg4}@0.0.0"`); + await runCommandAsync(`git tag -a ${pkg5}@0.0.0 -m "${pkg5}@0.0.0"`); + }, 60000); + + afterEach(() => cleanupProject()); + + it('should pick new versions based on version plans', async () => { + updateJson('nx.json', (nxJson) => { + nxJson.release = { + groups: { + 'fixed-group': { + projects: [pkg1, pkg2], + releaseTagPattern: 'v{version}', + }, + 'independent-group': { + projects: [pkg3, pkg4, pkg5], + projectsRelationship: 'independent', + releaseTagPattern: '{projectName}@{version}', + }, + }, + version: { + generatorOptions: { + specifierSource: 'version-plans', + }, + }, + changelog: { + projectChangelogs: true, + }, + versionPlans: true, + }; + return nxJson; + }); + + const versionPlansDir = tmpProjPath('.nx/version-plans'); + await ensureDir(versionPlansDir); + + runCLI( + 'release plan minor -g fixed-group -m "feat: Update the fixed packages with a minor release." --verbose', + { + silenceError: true, + } + ); + + await writeFile( + join(versionPlansDir, 'bump-independent.md'), + `--- +${pkg3}: patch +${pkg4}: preminor +${pkg5}: prerelease +--- + +feat: Update the independent packages with a patch, preminor, and prerelease. +` + ); + + await runCommandAsync(`git add ${versionPlansDir}`); + await runCommandAsync( + `git commit -m "chore: add version plans for fixed and independent groups"` + ); + + const result = runCLI('release --verbose', { + silenceError: true, + }); + + expect(result).toContain( + `${pkg1} 📄 Resolved the specifier as "minor" using version plans.` + ); + // pkg2 uses the previously resolved specifier from pkg1 + expect(result).toContain( + `${pkg2} ✍️ New version 0.1.0 written to ${pkg2}/package.json` + ); + expect(result).toContain( + `${pkg3} 📄 Resolved the specifier as "patch" using version plans.` + ); + expect(result).toContain( + `${pkg4} 📄 Resolved the specifier as "preminor" using version plans.` + ); + expect(result).toContain( + `${pkg5} 📄 Resolved the specifier as "prerelease" using version plans.` + ); + + // replace the date with a placeholder to make the snapshot deterministic + const resultWithoutDate = result.replace( + /\(\d{4}-\d{2}-\d{2}\)/g, + '(YYYY-MM-DD)' + ); + + expect(resultWithoutDate).toContain( + `NX Generating an entry in ${pkg1}/CHANGELOG.md for v0.1.0 + + ++ ## 0.1.0 (YYYY-MM-DD) ++ ++ ++ ### 🚀 Features ++ ++ - Update the fixed packages with a minor release.` + ); + expect(resultWithoutDate).toContain( + `NX Generating an entry in ${pkg2}/CHANGELOG.md for v0.1.0 + + ++ ## 0.1.0 (YYYY-MM-DD) ++ ++ ++ ### 🚀 Features ++ ++ - Update the fixed packages with a minor release.` + ); + expect(resultWithoutDate).toContain( + `NX Generating an entry in ${pkg3}/CHANGELOG.md for ${pkg3}@0.0.1 + + ++ ## 0.0.1 (YYYY-MM-DD) ++ ++ ++ ### 🚀 Features ++ ++ - Update the independent packages with a patch, preminor, and prerelease.` + ); + + expect(resultWithoutDate).toContain( + `NX Generating an entry in ${pkg4}/CHANGELOG.md for ${pkg4}@0.1.0-0 + + ++ ## 0.1.0-0 (YYYY-MM-DD) ++ ++ ++ ### 🚀 Features ++ ++ - Update the independent packages with a patch, preminor, and prerelease.` + ); + + expect(resultWithoutDate).toContain( + `NX Generating an entry in ${pkg5}/CHANGELOG.md for ${pkg5}@0.0.1-0 + + ++ ## 0.0.1-0 (YYYY-MM-DD) ++ ++ ++ ### 🚀 Features ++ ++ - Update the independent packages with a patch, preminor, and prerelease.` + ); + + await writeFile( + join(versionPlansDir, 'bump-mixed1.md'), + `--- +${pkg1}: minor +${pkg3}: patch +--- + +fix: Update packages in both groups with a bug fix +` + ); + await writeFile( + join(versionPlansDir, 'bump-mixed2.md'), + `--- +fixed-group: patch +${pkg4}: preminor +${pkg5}: patch +--- + +feat: Update packages in both groups with a feat +` + ); + + await runCommandAsync(`git add ${join(versionPlansDir, 'bump-mixed1.md')}`); + await runCommandAsync(`git add ${join(versionPlansDir, 'bump-mixed2.md')}`); + await runCommandAsync( + `git commit -m "chore: add combined groups version plans"` + ); + + runCLI('release --dry-run'); + // dry-run should not remove the version plan + expect(exists(join(versionPlansDir, 'bump-mixed1.md'))).toBeTruthy(); + + const result2 = runCLI('release --verbose', { + silenceError: true, + }); + + expect(result2).toContain( + `${pkg1} 📄 Resolved the specifier as "minor" using version plans.` + ); + // pkg2 uses the previously resolved specifier from pkg1 + expect(result2).toContain( + `${pkg2} ✍️ New version 0.2.0 written to ${pkg2}/package.json` + ); + expect(result2).toContain( + `${pkg3} 📄 Resolved the specifier as "patch" using version plans.` + ); + expect(result2).toContain( + `${pkg4} 📄 Resolved the specifier as "preminor" using version plans.` + ); + expect(result2).toContain( + `${pkg5} 📄 Resolved the specifier as "patch" using version plans.` + ); + + // replace the date with a placeholder to make the snapshot deterministic + const result2WithoutDate = result2.replace( + /\(\d{4}-\d{2}-\d{2}\)/g, + '(YYYY-MM-DD)' + ); + + expect(result2WithoutDate).toContain( + `NX Generating an entry in ${pkg1}/CHANGELOG.md for v0.2.0 + + + ++ ## 0.2.0 (YYYY-MM-DD) ++ ++ ++ ### 🚀 Features ++ ++ - Update packages in both groups with a feat ++ ++ ++ ### 🩹 Fixes ++ ++ - Update packages in both groups with a bug fix` + ); + expect(result2WithoutDate).toContain( + `NX Generating an entry in ${pkg2}/CHANGELOG.md for v0.2.0 + + + ++ ## 0.2.0 (YYYY-MM-DD) ++ ++ ++ ### 🚀 Features ++ ++ - Update packages in both groups with a feat ++ ++ ++ ### 🩹 Fixes ++ ++ - Update packages in both groups with a bug fix +` + ); + expect(result2WithoutDate).toContain( + `NX Generating an entry in ${pkg3}/CHANGELOG.md for ${pkg3}@0.0.2 + + + ++ ## 0.0.2 (YYYY-MM-DD) ++ ++ ++ ### 🩹 Fixes ++ ++ - Update packages in both groups with a bug fix` + ); + + expect(result2WithoutDate).toContain( + `NX Generating an entry in ${pkg4}/CHANGELOG.md for ${pkg4}@0.2.0-0 + + + ++ ## 0.2.0-0 (YYYY-MM-DD) ++ ++ ++ ### 🚀 Features ++ ++ - Update packages in both groups with a feat` + ); + + expect(result2WithoutDate).toContain( + `NX Generating an entry in ${pkg5}/CHANGELOG.md for ${pkg5}@0.0.1 + + + ++ ## 0.0.1 (YYYY-MM-DD) ++ ++ ++ ### 🚀 Features ++ ++ - Update packages in both groups with a feat` + ); + + expect(exists(join(versionPlansDir, 'bump-mixed1.md'))).toBeFalsy(); + }); + + it('should pick new versions based on version plans using programmatic api', async () => { + updateJson('nx.json', (nxJson) => { + nxJson.release = { + groups: { + 'fixed-group': { + projects: [pkg1, pkg2], + releaseTagPattern: 'v{version}', + }, + 'independent-group': { + projects: [pkg3, pkg4, pkg5], + projectsRelationship: 'independent', + releaseTagPattern: '{projectName}@{version}', + }, + }, + version: { + generatorOptions: { + specifierSource: 'version-plans', + }, + }, + changelog: { + projectChangelogs: true, + }, + versionPlans: true, + }; + return nxJson; + }); + + const versionPlansDir = tmpProjPath('.nx/version-plans'); + await ensureDir(versionPlansDir); + + await writeFile( + join(versionPlansDir, 'bump-fixed.md'), + `--- +fixed-group: minor +--- + +feat: Update the fixed packages with a minor release. +` + ); + + await writeFile( + join(versionPlansDir, 'bump-independent.md'), + `--- +${pkg3}: patch +${pkg4}: preminor +${pkg5}: prerelease +--- + +feat: Update the independent packages with a patch, preminor, and prerelease. +` + ); + + expect(exists(join(versionPlansDir, 'bump-fixed.md'))).toBe(true); + expect(exists(join(versionPlansDir, 'bump-independent.md'))).toBe(true); + + packageInstall('yargs', null, 'latest', 'dev'); + + await writeFile( + tmpProjPath('release.js'), + ` +const { releaseChangelog, releasePublish, releaseVersion } = require('nx/release'); +const yargs = require('yargs'); + +(async () => { + const options = await yargs + .version(false) // don't use the default meaning of version in yargs + .option('version', { + description: + 'Explicit version specifier to use, if overriding conventional commits', + type: 'string', + }) + .option('dryRun', { + alias: 'd', + description: + 'Whether or not to perform a dry-run of the release process, defaults to true', + type: 'boolean', + }) + .option('verbose', { + description: + 'Whether or not to enable verbose logging, defaults to false', + type: 'boolean', + default: false, + }) + .parseAsync(); + + const { workspaceVersion, projectsVersionData } = await releaseVersion({ + specifier: options.version, + dryRun: options.dryRun, + verbose: options.verbose, + }); + + await releaseChangelog({ + versionData: projectsVersionData, + version: workspaceVersion, + dryRun: options.dryRun, + verbose: options.verbose, + }); + + // The returned number value from releasePublish will be zero if all projects are published successfully, non-zero if not + const publishStatus = await releasePublish({ + dryRun: options.dryRun, + verbose: options.verbose, + }); + process.exit(publishStatus); +})(); +` + ); + + await runCommandAsync(`git add ${join(versionPlansDir, 'bump-fixed.md')}`); + await runCommandAsync( + `git add ${join(versionPlansDir, 'bump-independent.md')}` + ); + await runCommandAsync( + `git commit -m "chore: add version plans for fixed and independent groups"` + ); + + const result = runCommand('node release.js', { + failOnError: false, + }); + + expect(result).toContain( + `${pkg1} 📄 Resolved the specifier as "minor" using version plans.` + ); + // pkg2 uses the previously resolved specifier from pkg1 + expect(result).toContain( + `${pkg2} ✍️ New version 0.1.0 written to ${pkg2}/package.json` + ); + expect(result).toContain( + `${pkg3} 📄 Resolved the specifier as "patch" using version plans.` + ); + expect(result).toContain( + `${pkg4} 📄 Resolved the specifier as "preminor" using version plans.` + ); + expect(result).toContain( + `${pkg5} 📄 Resolved the specifier as "prerelease" using version plans.` + ); + + // replace the date with a placeholder to make the snapshot deterministic + const resultWithoutDate = result.replace( + /\(\d{4}-\d{2}-\d{2}\)/g, + '(YYYY-MM-DD)' + ); + + expect(resultWithoutDate).toContain( + `NX Generating an entry in ${pkg1}/CHANGELOG.md for v0.1.0 + + ++ ## 0.1.0 (YYYY-MM-DD) ++ ++ ++ ### 🚀 Features ++ ++ - Update the fixed packages with a minor release.` + ); + expect(resultWithoutDate).toContain( + `NX Generating an entry in ${pkg2}/CHANGELOG.md for v0.1.0 + + ++ ## 0.1.0 (YYYY-MM-DD) ++ ++ ++ ### 🚀 Features ++ ++ - Update the fixed packages with a minor release.` + ); + expect(resultWithoutDate).toContain( + `NX Generating an entry in ${pkg3}/CHANGELOG.md for ${pkg3}@0.0.1 + + ++ ## 0.0.1 (YYYY-MM-DD) ++ ++ ++ ### 🚀 Features ++ ++ - Update the independent packages with a patch, preminor, and prerelease.` + ); + + expect(resultWithoutDate).toContain( + `NX Generating an entry in ${pkg4}/CHANGELOG.md for ${pkg4}@0.1.0-0 + + ++ ## 0.1.0-0 (YYYY-MM-DD) ++ ++ ++ ### 🚀 Features ++ ++ - Update the independent packages with a patch, preminor, and prerelease.` + ); + + expect(resultWithoutDate).toContain( + `NX Generating an entry in ${pkg5}/CHANGELOG.md for ${pkg5}@0.0.1-0 + + ++ ## 0.0.1-0 (YYYY-MM-DD) ++ ++ ++ ### 🚀 Features ++ ++ - Update the independent packages with a patch, preminor, and prerelease.` + ); + + expect(exists(join(versionPlansDir, 'bump-fixed.md'))).toBeFalsy(); + expect(exists(join(versionPlansDir, 'bump-independent.md'))).toBeFalsy(); + + await writeFile( + join(versionPlansDir, 'bump-mixed1.md'), + `--- +${pkg1}: minor +${pkg3}: patch +--- + +fix: Update packages in both groups with a bug fix +` + ); + await writeFile( + join(versionPlansDir, 'bump-mixed2.md'), + `--- +fixed-group: patch +${pkg4}: preminor +${pkg5}: patch +--- + +feat: Update packages in both groups with a feat +` + ); + + await runCommandAsync(`git add ${join(versionPlansDir, 'bump-mixed1.md')}`); + await runCommandAsync(`git add ${join(versionPlansDir, 'bump-mixed2.md')}`); + await runCommandAsync( + `git commit -m "chore: add combined groups version plans"` + ); + + runCLI('release --dry-run'); + // dry-run should not remove the version plan + expect(exists(join(versionPlansDir, 'bump-mixed1.md'))).toBeTruthy(); + + const result2 = runCLI('release --verbose', { + silenceError: true, + }); + + expect(result2).toContain( + `${pkg1} 📄 Resolved the specifier as "minor" using version plans.` + ); + // pkg2 uses the previously resolved specifier from pkg1 + expect(result2).toContain( + `${pkg2} ✍️ New version 0.2.0 written to ${pkg2}/package.json` + ); + expect(result2).toContain( + `${pkg3} 📄 Resolved the specifier as "patch" using version plans.` + ); + expect(result2).toContain( + `${pkg4} 📄 Resolved the specifier as "preminor" using version plans.` + ); + expect(result2).toContain( + `${pkg5} 📄 Resolved the specifier as "patch" using version plans.` + ); + + // replace the date with a placeholder to make the snapshot deterministic + const result2WithoutDate = result2.replace( + /\(\d{4}-\d{2}-\d{2}\)/g, + '(YYYY-MM-DD)' + ); + + expect(result2WithoutDate).toContain( + `NX Generating an entry in ${pkg1}/CHANGELOG.md for v0.2.0 + + + ++ ## 0.2.0 (YYYY-MM-DD) ++ ++ ++ ### 🚀 Features ++ ++ - Update packages in both groups with a feat ++ ++ ++ ### 🩹 Fixes ++ ++ - Update packages in both groups with a bug fix` + ); + expect(result2WithoutDate).toContain( + `NX Generating an entry in ${pkg2}/CHANGELOG.md for v0.2.0 + + + ++ ## 0.2.0 (YYYY-MM-DD) ++ ++ ++ ### 🚀 Features ++ ++ - Update packages in both groups with a feat ++ ++ ++ ### 🩹 Fixes ++ ++ - Update packages in both groups with a bug fix +` + ); + expect(result2WithoutDate).toContain( + `NX Generating an entry in ${pkg3}/CHANGELOG.md for ${pkg3}@0.0.2 + + + ++ ## 0.0.2 (YYYY-MM-DD) ++ ++ ++ ### 🩹 Fixes ++ ++ - Update packages in both groups with a bug fix` + ); + + expect(result2WithoutDate).toContain( + `NX Generating an entry in ${pkg4}/CHANGELOG.md for ${pkg4}@0.2.0-0 + + + ++ ## 0.2.0-0 (YYYY-MM-DD) ++ ++ ++ ### 🚀 Features ++ ++ - Update packages in both groups with a feat` + ); + + expect(result2WithoutDate).toContain( + `NX Generating an entry in ${pkg5}/CHANGELOG.md for ${pkg5}@0.0.1 + + + ++ ## 0.0.1 (YYYY-MM-DD) ++ ++ ++ ### 🚀 Features ++ ++ - Update packages in both groups with a feat` + ); + + expect(exists(join(versionPlansDir, 'bump-mixed1.md'))).toBeFalsy(); + }); + + it('should pick new versions based on version plans using subcommands', async () => { + updateJson('nx.json', (nxJson) => { + nxJson.release = { + projects: [pkg1, pkg2], + releaseTagPattern: 'v{version}', + changelog: { + projectChangelogs: true, + }, + versionPlans: true, + }; + return nxJson; + }); + + const versionPlansDir = tmpProjPath('.nx/version-plans'); + await ensureDir(versionPlansDir); + + runCLI( + 'release plan minor -m "feat: Update the fixed packages with a minor release." --verbose', + { + silenceError: true, + } + ); + + // don't commit the new version plan file - it should still be picked up by the release command and deleted appropriately + + const versionResult = runCLI('release version --verbose', { + silenceError: true, + }); + + expect(versionResult).toContain( + `${pkg1} 📄 Resolved the specifier as "minor" using version plans.` + ); + // pkg2 uses the previously resolved specifier from pkg1 + expect(versionResult).toContain( + `${pkg2} ✍️ New version 0.1.0 written to ${pkg2}/package.json` + ); + + const changelogResult = runCLI('release changelog 0.1.0 --verbose', { + silenceError: true, + }); + + const changelogResultWithoutDate = changelogResult.replace( + /\(\d{4}-\d{2}-\d{2}\)/g, + '(YYYY-MM-DD)' + ); + + expect(changelogResultWithoutDate).toContain( + `NX Generating an entry in ${pkg1}/CHANGELOG.md for v0.1.0 + + ++ ## 0.1.0 (YYYY-MM-DD) ++ ++ ++ ### 🚀 Features ++ ++ - Update the fixed packages with a minor release.` + ); + expect(changelogResultWithoutDate).toContain( + `NX Generating an entry in ${pkg2}/CHANGELOG.md for v0.1.0 + + ++ ## 0.1.0 (YYYY-MM-DD) ++ ++ ++ ### 🚀 Features ++ ++ - Update the fixed packages with a minor release.` + ); + + expect(readdirSync(versionPlansDir)).toEqual([]); + }); +}); diff --git a/package.json b/package.json index 1bbd3cfe4e..f8f96f8bc1 100644 --- a/package.json +++ b/package.json @@ -327,6 +327,7 @@ "enquirer": "~2.3.6", "fast-glob": "3.2.7", "framer-motion": "^11.1.7", + "front-matter": "^4.0.2", "glob": "7.1.4", "history": "^5.3.0", "json-schema-to-typescript": "^10.1.5", diff --git a/packages/js/src/generators/release-version/release-version.ts b/packages/js/src/generators/release-version/release-version.ts index f1b4d87a24..475bb53fc9 100644 --- a/packages/js/src/generators/release-version/release-version.ts +++ b/packages/js/src/generators/release-version/release-version.ts @@ -1,6 +1,4 @@ import { - ProjectGraph, - ProjectGraphDependency, ProjectGraphProjectNode, Tree, formatFiles, @@ -12,9 +10,14 @@ import { writeJson, } from '@nx/devkit'; import * as chalk from 'chalk'; +import { remove } from 'fs-extra'; import { exec } from 'node:child_process'; import { join } from 'node:path'; import { IMPLICIT_DEFAULT_RELEASE_GROUP } from 'nx/src/command-line/release/config/config'; +import { + GroupVersionPlan, + ProjectsVersionPlan, +} from 'nx/src/command-line/release/config/version-plans'; import { getFirstGitCommit, getLatestGitTagForPattern, @@ -32,15 +35,15 @@ import { } from 'nx/src/command-line/release/version'; import { interpolate } from 'nx/src/tasks-runner/utils'; import * as ora from 'ora'; -import { prerelease } from 'semver'; +import { ReleaseType, gt, inc, prerelease } from 'semver'; import { parseRegistryOptions } from '../../utils/npm-config'; import { ReleaseVersionGeneratorSchema } from './schema'; import { LocalPackageDependency, resolveLocalPackageDependencies, } from './utils/resolve-local-package-dependencies'; -import { updateLockFile } from './utils/update-lock-file'; import { sortProjectsTopologically } from './utils/sort-projects-topologically'; +import { updateLockFile } from './utils/update-lock-file'; export async function releaseVersionGenerator( tree: Tree, @@ -119,6 +122,10 @@ Valid values are: ${validReleaseVersionPrefixes ? options.specifier : undefined; + const deleteVersionPlanCallbacks: (( + dryRun?: boolean + ) => Promise)[] = []; + for (const project of projects) { const projectName = project.name; const packageRoot = projectNameToPackageRootMap.get(projectName); @@ -317,6 +324,8 @@ To fix this you will either need to add a package.json file at that location, or if (options.specifier) { log(`📄 Using the provided version specifier "${options.specifier}".`); + // The user is forcibly overriding whatever specifierSource they had otherwise set by imperatively providing a specifier + options.specifierSource = 'prompt'; } /** @@ -434,9 +443,99 @@ To fix this you will either need to add a package.json file at that location, or } break; } + case 'version-plans': { + if (!options.releaseGroup.versionPlans) { + if ( + options.releaseGroup.name === IMPLICIT_DEFAULT_RELEASE_GROUP + ) { + throw new Error( + `Invalid specifierSource "version-plans" provided. To enable version plans, set the "release.versionPlans" configuration option to "true" in nx.json.` + ); + } else { + throw new Error( + `Invalid specifierSource "version-plans" provided. To enable version plans for release group "${options.releaseGroup.name}", set the "versionPlans" configuration option to "true" within the release group configuration in nx.json.` + ); + } + } + + if (options.releaseGroup.projectsRelationship === 'independent') { + specifier = ( + options.releaseGroup.versionPlans as ProjectsVersionPlan[] + ).reduce((spec: ReleaseType, plan: ProjectsVersionPlan) => { + if (!spec) { + return plan.projectVersionBumps[projectName]; + } + if (plan.projectVersionBumps[projectName]) { + const prevNewVersion = inc(currentVersion, spec); + const nextNewVersion = inc( + currentVersion, + plan.projectVersionBumps[projectName] + ); + return gt(nextNewVersion, prevNewVersion) + ? plan.projectVersionBumps[projectName] + : spec; + } + return spec; + }, null); + } else { + specifier = ( + options.releaseGroup.versionPlans as GroupVersionPlan[] + ).reduce((spec: ReleaseType, plan: GroupVersionPlan) => { + if (!spec) { + return plan.groupVersionBump; + } + + const prevNewVersion = inc(currentVersion, spec); + const nextNewVersion = inc( + currentVersion, + plan.groupVersionBump + ); + return gt(nextNewVersion, prevNewVersion) + ? plan.groupVersionBump + : spec; + }, null); + } + + if (!specifier) { + if ( + updateDependents !== 'never' && + projectToDependencyBumps.has(projectName) + ) { + // No applicable changes to the project directly by the user, but one or more dependencies have been bumped and updateDependents is enabled + specifier = updateDependentsBump; + log( + `📄 Resolved the specifier as "${specifier}" because "release.version.generatorOptions.updateDependents" is enabled` + ); + } else { + specifier = null; + log(`🚫 No changes were detected within version plans.`); + } + } else { + log( + `📄 Resolved the specifier as "${specifier}" using version plans.` + ); + } + + if (options.deleteVersionPlans) { + options.releaseGroup.versionPlans.forEach((p) => { + deleteVersionPlanCallbacks.push(async (dryRun?: boolean) => { + if (!dryRun) { + await remove(p.absolutePath); + // the relative path is easier to digest, so use that for + // git operations and logging + return [p.relativePath]; + } else { + return []; + } + }); + }); + } + + break; + } default: throw new Error( - `Invalid specifierSource "${specifierSource}" provided. Must be one of "prompt" or "conventional-commits"` + `Invalid specifierSource "${specifierSource}" provided. Must be one of "prompt", "conventional-commits" or "version-plans".` ); } } @@ -489,9 +588,22 @@ To fix this you will either need to add a package.json file at that location, or ); } - const isInCurrentBatch = options.projects.some( + let isInCurrentBatch = options.projects.some( (project) => project.name === dependentProject.source ); + + // For version-plans, we don't just need to consider the current batch of projects, but also the ones that are actually being updated as part of the plan file(s) + if (isInCurrentBatch && options.specifierSource === 'version-plans') { + isInCurrentBatch = (options.releaseGroup.versionPlans || []).some( + (plan) => { + if ('projectVersionBumps' in plan) { + return plan.projectVersionBumps[dependentProject.source]; + } + return true; + } + ); + } + if (!isInCurrentBatch) { dependentProjectsOutsideCurrentBatch.push(dependentProject); } else { @@ -503,10 +615,14 @@ To fix this you will either need to add a package.json file at that location, or if (updateDependents === 'never') { if (dependentProjectsOutsideCurrentBatch.length > 0) { let logMsg = `⚠️ Warning, the following packages depend on "${project.name}"`; + const reason = + options.specifierSource === 'version-plans' + ? 'because they are not referenced in any version plans' + : 'via --projects'; if (options.releaseGroup.name === IMPLICIT_DEFAULT_RELEASE_GROUP) { - logMsg += ` but have been filtered out via --projects, and therefore will not be updated:`; + logMsg += ` but have been filtered out ${reason}, and therefore will not be updated:`; } else { - logMsg += ` but are either not part of the current release group "${options.releaseGroup.name}", or have been filtered out via --projects, and therefore will not be updated:`; + logMsg += ` but are either not part of the current release group "${options.releaseGroup.name}", or have been filtered out ${reason}, and therefore will not be updated:`; } const indent = Array.from(new Array(projectName.length + 4)) .map(() => ' ') @@ -664,12 +780,27 @@ To fix this you will either need to add a package.json file at that location, or if (updateDependents === 'auto') { for (const dependentProject of dependentProjectsOutsideCurrentBatch) { + if ( + options.specifierSource === 'version-plans' && + !projectToDependencyBumps.has(dependentProject.source) + ) { + projectToDependencyBumps.set( + dependentProject.source, + new Set([projectName]) + ); + } + updateDependentProjectAndAddToVersionData({ dependentProject, dependencyPackageName: packageName, newDependencyVersion: newVersion, // For these additional dependents, we need to update their package.json version as well because we know they will not come later in the topologically sorted projects loop - forceVersionBump: updateDependentsBump, + // (Unless using version plans and the dependent is not filtered out by --projects) + forceVersionBump: + options.specifierSource === 'version-plans' && + projects.find((p) => p.name === dependentProject.source) + ? false + : updateDependentsBump, }); } } @@ -719,9 +850,16 @@ To fix this you will either need to add a package.json file at that location, or return { data: versionData, callback: async (tree, opts) => { + const changedFiles: string[] = []; + const deletedFiles: string[] = []; + + for (const cb of deleteVersionPlanCallbacks) { + deletedFiles.push(...(await cb(opts.dryRun))); + } + const cwd = tree.root; - const updatedFiles = await updateLockFile(cwd, opts); - return updatedFiles; + changedFiles.push(...(await updateLockFile(cwd, opts))); + return { changedFiles, deletedFiles }; }, }; } catch (e: any) { diff --git a/packages/js/src/generators/release-version/schema.json b/packages/js/src/generators/release-version/schema.json index aece275344..df303fe607 100644 --- a/packages/js/src/generators/release-version/schema.json +++ b/packages/js/src/generators/release-version/schema.json @@ -29,7 +29,7 @@ "type": "string", "default": "prompt", "description": "Which approach to use to determine the semver specifier used to bump the version of the project.", - "enum": ["prompt", "conventional-commits"] + "enum": ["prompt", "conventional-commits", "version-plans"] }, "preid": { "type": "string", diff --git a/packages/nx/package.json b/packages/nx/package.json index 89cc9efb10..c6d0f179af 100644 --- a/packages/nx/package.json +++ b/packages/nx/package.json @@ -50,6 +50,7 @@ "enquirer": "~2.3.6", "figures": "3.2.0", "flat": "^5.0.2", + "front-matter": "^4.0.2", "fs-extra": "^11.1.0", "ignore": "^5.0.4", "jest-diff": "^29.4.1", diff --git a/packages/nx/release/changelog-renderer/index.spec.ts b/packages/nx/release/changelog-renderer/index.spec.ts index 3cc321578e..50f9d98147 100644 --- a/packages/nx/release/changelog-renderer/index.spec.ts +++ b/packages/nx/release/changelog-renderer/index.spec.ts @@ -1,5 +1,5 @@ +import type { ChangelogChange } from '../../src/command-line/release/changelog'; import { DEFAULT_CONVENTIONAL_COMMITS_CONFIG } from '../../src/command-line/release/config/conventional-commits'; -import type { GitCommit } from '../../src/command-line/release/utils/git'; import defaultChangelogRenderer from './index'; jest.mock('../../src/project-graph/file-map-utils', () => ({ @@ -29,25 +29,18 @@ describe('defaultChangelogRenderer()', () => { const projectGraph = { nodes: {}, } as any; - const commits: GitCommit[] = [ + const changes: ChangelogChange[] = [ { - 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: [ + githubReferences: [ { value: '4130f65', type: 'hash', @@ -55,29 +48,19 @@ describe('defaultChangelogRenderer()', () => { ], isBreaking: false, revertedHashes: [], - affectedFiles: [ - 'packages/pkg-a/src/index.ts', - 'packages/pkg-b/src/index.ts', - ], + affectedProjects: ['pkg-a', 'pkg-b'], }, { - 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: [ + githubReferences: [ { value: '7dc5ec3', type: 'hash', @@ -85,26 +68,19 @@ describe('defaultChangelogRenderer()', () => { ], isBreaking: false, revertedHashes: [], - affectedFiles: ['packages/pkg-b/src/index.ts'], + affectedProjects: ['pkg-b'], }, { - 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: [ + githubReferences: [ { value: 'd7a58a2', type: 'hash', @@ -112,26 +88,19 @@ describe('defaultChangelogRenderer()', () => { ], isBreaking: false, revertedHashes: [], - affectedFiles: ['packages/pkg-a/src/index.ts'], + affectedProjects: ['pkg-a'], }, { - 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: [ + githubReferences: [ { value: 'feace4a', type: 'hash', @@ -139,26 +108,19 @@ describe('defaultChangelogRenderer()', () => { ], isBreaking: false, revertedHashes: [], - affectedFiles: ['packages/pkg-b/src/index.ts'], + affectedProjects: ['pkg-b'], }, { - 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: [ + githubReferences: [ { value: '6301405', type: 'hash', @@ -166,7 +128,7 @@ describe('defaultChangelogRenderer()', () => { ], isBreaking: false, revertedHashes: [], - affectedFiles: ['packages/pkg-a/src/index.ts'], + affectedProjects: ['pkg-a'], }, ]; @@ -174,7 +136,7 @@ describe('defaultChangelogRenderer()', () => { 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({ projectGraph, - commits, + changes, releaseVersion: 'v1.1.0', project: null, entryWhenNoChanges: false, @@ -207,7 +169,7 @@ describe('defaultChangelogRenderer()', () => { it('should not generate a Thank You section when changelogRenderOptions.authors is false', async () => { const markdown = await defaultChangelogRenderer({ projectGraph, - commits, + changes, // Major version, should use single # for generated heading releaseVersion: 'v1.0.0', project: null, @@ -239,7 +201,7 @@ describe('defaultChangelogRenderer()', () => { it('should generate markdown for the given project by organizing commits by type, then chronologically', async () => { const otherOpts = { projectGraph, - commits, + changes, releaseVersion: 'v1.1.0', entryWhenNoChanges: false as const, changelogRenderOptions: { @@ -331,7 +293,7 @@ describe('defaultChangelogRenderer()', () => { it('should respect the entryWhenNoChanges option for the workspace changelog', async () => { const otherOpts = { projectGraph, - commits: [], + changes: [], releaseVersion: 'v1.1.0', project: null, // workspace changelog changelogRenderOptions: { @@ -362,7 +324,7 @@ describe('defaultChangelogRenderer()', () => { it('should respect the entryWhenNoChanges option for project changelogs', async () => { const otherOpts = { projectGraph, - commits: [], + changes: [], releaseVersion: 'v1.1.0', project: 'pkg-a', changelogRenderOptions: { @@ -393,27 +355,19 @@ describe('defaultChangelogRenderer()', () => { describe('revert commits', () => { it('should generate a Revert section for the changelog if the reverted commit is not part of the same release', async () => { - const commitsWithOnlyRevert: GitCommit[] = [ + const changesWithOnlyRevert: ChangelogChange[] = [ { - message: - 'Revert "fix(release): do not update dependents when they already use "*" (#20607)"', shortHash: '6528e88aa', author: { name: 'James Henry', email: 'jh@example.com', }, body: 'This reverts commit 6d68236d467812aba4557a2bc7f667157de80fdb.\n"\n\nM\tpackages/js/src/generators/release-version/release-version.spec.ts\nM\tpackages/js/src/generators/release-version/release-version.ts\n', - authors: [ - { - name: 'James Henry', - email: 'jh@example.com', - }, - ], description: 'Revert "fix(release): do not update dependents when they already use "*" (#20607)"', type: 'revert', scope: 'release', - references: [ + githubReferences: [ { type: 'pull-request', value: '#20607', @@ -425,16 +379,13 @@ describe('defaultChangelogRenderer()', () => { ], isBreaking: false, revertedHashes: ['6d68236d467812aba4557a2bc7f667157de80fdb'], - affectedFiles: [ - 'packages/js/src/generators/release-version/release-version.spec.ts', - 'packages/js/src/generators/release-version/release-version.ts', - ], + affectedProjects: ['js'], }, ]; const markdown = await defaultChangelogRenderer({ projectGraph, - commits: commitsWithOnlyRevert, + changes: changesWithOnlyRevert, releaseVersion: 'v1.1.0', project: null, entryWhenNoChanges: false, @@ -459,27 +410,19 @@ describe('defaultChangelogRenderer()', () => { }); it('should strip both the original commit and its revert if they are both included in the current range of commits', async () => { - const commitsWithRevertAndOriginal: GitCommit[] = [ + const changesWithRevertAndOriginal: ChangelogChange[] = [ { - message: - 'Revert "fix(release): do not update dependents when they already use "*" (#20607)"', shortHash: '6528e88aa', author: { name: 'James Henry', email: 'jh@example.com', }, body: 'This reverts commit 6d68236d467812aba4557a2bc7f667157de80fdb.\n"\n\nM\tpackages/js/src/generators/release-version/release-version.spec.ts\nM\tpackages/js/src/generators/release-version/release-version.ts\n', - authors: [ - { - name: 'James Henry', - email: 'jh@example.com', - }, - ], description: 'Revert "fix(release): do not update dependents when they already use "*" (#20607)"', type: 'revert', scope: 'release', - references: [ + githubReferences: [ { type: 'pull-request', value: '#20607', @@ -491,30 +434,19 @@ describe('defaultChangelogRenderer()', () => { ], isBreaking: false, revertedHashes: ['6d68236d467812aba4557a2bc7f667157de80fdb'], - affectedFiles: [ - 'packages/js/src/generators/release-version/release-version.spec.ts', - 'packages/js/src/generators/release-version/release-version.ts', - ], + affectedProjects: ['js'], }, { - message: - 'fix(release): do not update dependents when they already use "*" (#20607)', shortHash: '6d68236d4', author: { name: 'James Henry', email: 'jh@example.com', }, body: '"\n\nM\tpackages/js/src/generators/release-version/release-version.spec.ts\nM\tpackages/js/src/generators/release-version/release-version.ts\n', - authors: [ - { - name: 'James Henry', - email: 'jh@example.com', - }, - ], description: 'do not update dependents when they already use "*"', type: 'fix', scope: 'release', - references: [ + githubReferences: [ { type: 'pull-request', value: '#20607', @@ -526,16 +458,13 @@ describe('defaultChangelogRenderer()', () => { ], isBreaking: false, revertedHashes: [], - affectedFiles: [ - 'packages/js/src/generators/release-version/release-version.spec.ts', - 'packages/js/src/generators/release-version/release-version.ts', - ], + affectedProjects: ['js'], }, ]; const markdown = await defaultChangelogRenderer({ projectGraph, - commits: commitsWithRevertAndOriginal, + changes: changesWithRevertAndOriginal, releaseVersion: 'v1.1.0', project: null, entryWhenNoChanges: false, @@ -551,9 +480,7 @@ describe('defaultChangelogRenderer()', () => { describe('breaking changes', () => { it('should work for breaking changes with just the ! and no explanation', async () => { - const breakingChangeCommitWithExplanation: GitCommit = { - // ! after the type, no BREAKING CHANGE: in the body - message: 'feat(WebSocketSubject)!: no longer extends `Subject`.', + const breakingChangeWithExplanation: ChangelogChange = { shortHash: '54f2f6ed1', author: { name: 'James Henry', @@ -562,26 +489,18 @@ describe('defaultChangelogRenderer()', () => { body: 'M\tpackages/rxjs/src/internal/observable/dom/WebSocketSubject.ts\n' + '"', - authors: [ - { - name: 'James Henry', - email: 'jh@example.com', - }, - ], description: 'no longer extends `Subject`.', type: 'feat', scope: 'WebSocketSubject', - references: [{ value: '54f2f6ed1', type: 'hash' }], + githubReferences: [{ value: '54f2f6ed1', type: 'hash' }], isBreaking: true, revertedHashes: [], - affectedFiles: [ - 'packages/rxjs/src/internal/observable/dom/WebSocketSubject.ts', - ], + affectedProjects: ['rxjs'], }; const markdown = await defaultChangelogRenderer({ projectGraph, - commits: [breakingChangeCommitWithExplanation], + changes: [breakingChangeWithExplanation], releaseVersion: 'v1.1.0', project: null, entryWhenNoChanges: false, @@ -610,9 +529,7 @@ describe('defaultChangelogRenderer()', () => { }); it('should extract the explanation of a breaking change and render it preferentially', async () => { - const breakingChangeCommitWithExplanation: GitCommit = { - // No ! after the type, but BREAKING CHANGE: in the body - message: 'feat(WebSocketSubject): no longer extends `Subject`.', + const breakingChangeWithExplanation: ChangelogChange = { shortHash: '54f2f6ed1', author: { name: 'James Henry', @@ -624,26 +541,18 @@ describe('defaultChangelogRenderer()', () => { '\n' + 'M\tpackages/rxjs/src/internal/observable/dom/WebSocketSubject.ts\n' + '"', - authors: [ - { - name: 'James Henry', - email: 'jh@example.com', - }, - ], description: 'no longer extends `Subject`.', type: 'feat', scope: 'WebSocketSubject', - references: [{ value: '54f2f6ed1', type: 'hash' }], + githubReferences: [{ value: '54f2f6ed1', type: 'hash' }], isBreaking: true, revertedHashes: [], - affectedFiles: [ - 'packages/rxjs/src/internal/observable/dom/WebSocketSubject.ts', - ], + affectedProjects: ['rxjs'], }; const markdown = await defaultChangelogRenderer({ projectGraph, - commits: [breakingChangeCommitWithExplanation], + changes: [breakingChangeWithExplanation], releaseVersion: 'v1.1.0', project: null, entryWhenNoChanges: false, @@ -673,11 +582,11 @@ describe('defaultChangelogRenderer()', () => { }); describe('dependency bumps', () => { - it('should render the dependency bumps in addition to the commits', async () => { + it('should render the dependency bumps in addition to the changes', async () => { expect( await defaultChangelogRenderer({ projectGraph, - commits, + changes, releaseVersion: 'v1.1.0', entryWhenNoChanges: false as const, changelogRenderOptions: { @@ -719,11 +628,11 @@ describe('defaultChangelogRenderer()', () => { `); }); - it('should render the dependency bumps and release version title even when there are no commits', async () => { + it('should render the dependency bumps and release version title even when there are no changes', async () => { expect( await defaultChangelogRenderer({ projectGraph, - commits: [], + changes: [], releaseVersion: 'v3.1.0', entryWhenNoChanges: 'should not be printed because we have dependency bumps', diff --git a/packages/nx/release/changelog-renderer/index.ts b/packages/nx/release/changelog-renderer/index.ts index 783c40711c..5d7038d042 100644 --- a/packages/nx/release/changelog-renderer/index.ts +++ b/packages/nx/release/changelog-renderer/index.ts @@ -1,11 +1,11 @@ import { major } from 'semver'; +import { ChangelogChange } from '../../src/command-line/release/changelog'; import { NxReleaseConfig } from '../../src/command-line/release/config/config'; -import type { GitCommit } from '../../src/command-line/release/utils/git'; +import { GitCommit } from '../../src/command-line/release/utils/git'; import { RepoSlug, formatReferences, } from '../../src/command-line/release/utils/github'; -import { getCommitsRelevantToProjects } from '../../src/command-line/release/utils/shared'; import type { ProjectGraph } from '../../src/config/project-graph'; // axios types and values don't seem to match @@ -34,7 +34,8 @@ export type DependencyBump = { * * @param {Object} config The configuration object for the ChangelogRenderer * @param {ProjectGraph} config.projectGraph The project graph for the workspace - * @param {GitCommit[]} config.commits The collection of extracted commits to generate a changelog for + * @param {GitCommit[]} config.commits DEPRECATED [Use 'config.changes' instead] - The collection of extracted commits to generate a changelog for + * @param {ChangelogChange[]} config.changes The collection of changes to show in the changelog * @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 @@ -43,7 +44,9 @@ export type DependencyBump = { */ export type ChangelogRenderer = (config: { projectGraph: ProjectGraph; - commits: GitCommit[]; + // TODO: remove 'commits' and make 'changes' whenever we make the next breaking change to this API + commits?: GitCommit[]; + changes?: ChangelogChange[]; releaseVersion: string; project: string | null; entryWhenNoChanges: string | false; @@ -81,7 +84,7 @@ export interface DefaultChangelogRenderOptions extends ChangelogRenderOptions { */ const defaultChangelogRenderer: ChangelogRenderer = async ({ projectGraph, - commits, + changes, releaseVersion, project, entryWhenNoChanges, @@ -90,20 +93,20 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({ repoSlug, conventionalCommitsConfig, }): Promise => { - const commitTypes = conventionalCommitsConfig.types; + const changeTypes = conventionalCommitsConfig.types; const markdownLines: string[] = []; const breakingChanges = []; - // If the current range of commits contains both a commit and its revert, we strip them both from the final list - for (const commit of commits) { - if (commit.type === 'revert') { - for (const revertedHash of commit.revertedHashes) { - const revertedCommit = commits.find((c) => - revertedHash.startsWith(c.shortHash) + // If the current range of changes contains both a commit and its revert, we strip them both from the final list. Changes from version plans are unaffected, as they have no hashes. + for (const change of changes) { + if (change.type === 'revert' && change.revertedHashes) { + for (const revertedHash of change.revertedHashes) { + const revertedCommit = changes.find( + (c) => c.shortHash && revertedHash.startsWith(c.shortHash) ); if (revertedCommit) { - commits.splice(commits.indexOf(revertedCommit), 1); - commits.splice(commits.indexOf(commit), 1); + changes.splice(changes.indexOf(revertedCommit), 1); + changes.splice(changes.indexOf(change), 1); } } } @@ -112,7 +115,7 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({ // workspace root level changelog if (project === null) { // No changes for the workspace - if (commits.length === 0) { + if (changes.length === 0) { if (dependencyBumps?.length) { applyAdditionalDependencyBumps({ markdownLines, @@ -133,7 +136,10 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({ return markdownLines.join('\n').trim(); } - const typeGroups = groupBy(commits, 'type'); + const typeGroups: Record = groupBy( + changes, + 'type' + ); markdownLines.push( '', @@ -141,41 +147,41 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({ '' ); - for (const type of Object.keys(commitTypes)) { + for (const type of Object.keys(changeTypes)) { const group = typeGroups[type]; if (!group || group.length === 0) { continue; } - markdownLines.push('', '### ' + commitTypes[type].changelog.title, ''); + markdownLines.push('', '### ' + changeTypes[type].changelog.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 + * In order to make the final changelog most readable, we organize changes as follows: + * - By scope, where scopes are in alphabetical order (changes with no scope are listed first) + * - Within a particular scope grouping, we list changes in chronological order */ - const commitsInChronologicalOrder = group.reverse(); - const commitsGroupedByScope = groupBy( - commitsInChronologicalOrder, + const changesInChronologicalOrder = group.reverse(); + const changesGroupedByScope: Record = groupBy( + changesInChronologicalOrder, 'scope' ); const scopesSortedAlphabetically = Object.keys( - commitsGroupedByScope + changesGroupedByScope ).sort(); for (const scope of scopesSortedAlphabetically) { - const commits = commitsGroupedByScope[scope]; - for (const commit of commits) { - const line = formatCommit(commit, changelogRenderOptions, repoSlug); + const changes = changesGroupedByScope[scope]; + for (const change of changes) { + const line = formatChange(change, changelogRenderOptions, repoSlug); markdownLines.push(line); - if (commit.isBreaking) { + if (change.isBreaking) { const breakingChangeExplanation = extractBreakingChangeExplanation( - commit.body + change.body ); breakingChanges.push( breakingChangeExplanation ? `- ${ - commit.scope ? `**${commit.scope.trim()}:** ` : '' + change.scope ? `**${change.scope.trim()}:** ` : '' }${breakingChangeExplanation}` : line ); @@ -185,14 +191,14 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({ } } else { // project level changelog - const relevantCommits = await getCommitsRelevantToProjects( - projectGraph, - commits, - [project] + const relevantChanges = changes.filter( + (c) => + c.affectedProjects && + (c.affectedProjects === '*' || c.affectedProjects.includes(project)) ); // Generating for a named project, but that project has no relevant changes in the current set of commits, exit early - if (relevantCommits.length === 0) { + if (relevantChanges.length === 0) { if (dependencyBumps?.length) { applyAdditionalDependencyBumps({ markdownLines, @@ -219,31 +225,31 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({ '' ); - const typeGroups = groupBy( - // Sort the relevant commits to have the unscoped commits first, before grouping by type - relevantCommits.sort((a, b) => (b.scope ? 1 : 0) - (a.scope ? 1 : 0)), + const typeGroups: Record = groupBy( + // Sort the relevant changes to have the unscoped changes first, before grouping by type + relevantChanges.sort((a, b) => (b.scope ? 1 : 0) - (a.scope ? 1 : 0)), 'type' ); - for (const type of Object.keys(commitTypes)) { + for (const type of Object.keys(changeTypes)) { const group = typeGroups[type]; if (!group || group.length === 0) { continue; } - markdownLines.push('', `### ${commitTypes[type].changelog.title}`, ''); + markdownLines.push('', `### ${changeTypes[type].changelog.title}`, ''); - const commitsInChronologicalOrder = group.reverse(); - for (const commit of commitsInChronologicalOrder) { - const line = formatCommit(commit, changelogRenderOptions, repoSlug); + const changesInChronologicalOrder = group.reverse(); + for (const change of changesInChronologicalOrder) { + const line = formatChange(change, changelogRenderOptions, repoSlug); markdownLines.push(line + '\n'); - if (commit.isBreaking) { + if (change.isBreaking) { const breakingChangeExplanation = extractBreakingChangeExplanation( - commit.body + change.body ); breakingChanges.push( breakingChangeExplanation ? `- ${ - commit.scope ? `**${commit.scope.trim()}:** ` : '' + change.scope ? `**${change.scope.trim()}:** ` : '' }${breakingChangeExplanation}` : line ); @@ -267,19 +273,19 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({ if (changelogRenderOptions.authors) { const _authors = new Map; github?: string }>(); - for (const commit of commits) { - if (!commit.author) { + for (const change of changes) { + if (!change.author) { continue; } - const name = formatName(commit.author.name); + const name = formatName(change.author.name); if (!name || name.includes('[bot]')) { continue; } if (_authors.has(name)) { const entry = _authors.get(name); - entry.email.add(commit.author.email); + entry.email.add(change.author.email); } else { - _authors.set(name, { email: new Set([commit.author.email]) }); + _authors.set(name, { email: new Set([change.author.email]) }); } } @@ -385,20 +391,20 @@ function groupBy(items: any[], key: string) { return groups; } -function formatCommit( - commit: GitCommit, +function formatChange( + change: ChangelogChange, changelogRenderOptions: DefaultChangelogRenderOptions, repoSlug?: RepoSlug ): string { - let commitLine = + let changeLine = '- ' + - (commit.isBreaking ? '⚠️ ' : '') + - (commit.scope ? `**${commit.scope.trim()}:** ` : '') + - commit.description; + (change.isBreaking ? '⚠️ ' : '') + + (change.scope ? `**${change.scope.trim()}:** ` : '') + + change.description; if (repoSlug && changelogRenderOptions.commitReferences) { - commitLine += formatReferences(commit.references, repoSlug); + changeLine += formatReferences(change.githubReferences, repoSlug); } - return commitLine; + return changeLine; } /** @@ -407,6 +413,10 @@ function formatCommit( * section of changelog, rather than repeating the commit title/description. */ function extractBreakingChangeExplanation(message: string): string | null { + if (!message) { + return null; + } + const breakingChangeIdentifier = 'BREAKING CHANGE:'; const startIndex = message.indexOf(breakingChangeIdentifier); diff --git a/packages/nx/schemas/nx-schema.json b/packages/nx/schemas/nx-schema.json index f48060e133..8f83f775a9 100644 --- a/packages/nx/schemas/nx-schema.json +++ b/packages/nx/schemas/nx-schema.json @@ -184,6 +184,10 @@ }, "releaseTagPattern": { "type": "string" + }, + "versionPlans": { + "type": "boolean", + "description": "Enables using version plans as a specifier source for versioning and to determine changes for changelog generation." } }, "required": ["projects"] @@ -234,6 +238,10 @@ "version": { "$ref": "#/definitions/NxReleaseVersionConfiguration" }, + "versionPlans": { + "type": "boolean", + "description": "Enables using version plans as a specifier source for versioning and to determine changes for changelog generation." + }, "releaseTagPattern": { "type": "string" } diff --git a/packages/nx/src/command-line/release/changelog.ts b/packages/nx/src/command-line/release/changelog.ts index cfa01bbaa1..ea076efe2c 100644 --- a/packages/nx/src/command-line/release/changelog.ts +++ b/packages/nx/src/command-line/release/changelog.ts @@ -1,5 +1,6 @@ import * as chalk from 'chalk'; import { prompt } from 'enquirer'; +import { removeSync } from 'fs-extra'; import { readFileSync, writeFileSync } from 'node:fs'; import { valid } from 'semver'; import { dirSync } from 'tmp'; @@ -9,11 +10,16 @@ import { readNxJson, } from '../../config/nx-json'; import { + FileData, + ProjectFileMap, ProjectGraph, ProjectGraphProjectNode, } from '../../config/project-graph'; import { FsTree, Tree } from '../../generators/tree'; -import { createProjectFileMapUsingProjectGraph } from '../../project-graph/file-map-utils'; +import { + createFileMapUsingProjectGraph, + createProjectFileMapUsingProjectGraph, +} from '../../project-graph/file-map-utils'; import { createProjectGraphAsync } from '../../project-graph/project-graph'; import { interpolate } from '../../tasks-runner/utils'; import { isCI } from '../../utils/is-ci'; @@ -31,8 +37,15 @@ import { ReleaseGroupWithName, filterReleaseGroups, } from './config/filter-release-groups'; +import { + GroupVersionPlan, + ProjectsVersionPlan, + readRawVersionPlans, + setVersionPlansOnGroups, +} from './config/version-plans'; import { GitCommit, + Reference, getCommitHash, getFirstGitCommit, getGitDiff, @@ -41,6 +54,7 @@ import { gitPush, gitTag, parseCommits, + parseConventionalCommitsMessage, } from './utils/git'; import { createOrUpdateGithubRelease, getGitHubRepoSlug } from './utils/github'; import { launchEditor } from './utils/launch-editor'; @@ -71,6 +85,19 @@ export interface NxReleaseChangelogResult { }; } +export interface ChangelogChange { + type: string; + scope: string; + description: string; + affectedProjects: string[] | '*'; + body?: string; + isBreaking?: boolean; + githubReferences?: Reference[]; + author?: { name: string; email: string }; + shortHash?: string; + revertedHashes?: string[]; +} + type PostGitTask = (latestCommit: string) => Promise; export const releaseChangelogCLIHandler = (args: ChangelogOptions) => @@ -136,6 +163,17 @@ export async function releaseChangelog( output.error(filterError); process.exit(1); } + const rawVersionPlans = await readRawVersionPlans(); + setVersionPlansOnGroups( + rawVersionPlans, + releaseGroups, + Object.keys(projectGraph.nodes) + ); + + if (args.deleteVersionPlans === undefined) { + // default to deleting version plans in this command instead of after versioning + args.deleteVersionPlans = true; + } const changelogGenerationEnabled = !!nxReleaseConfig.changelog.workspaceChangelog || @@ -150,6 +188,8 @@ export async function releaseChangelog( return {}; } + const tree = new FsTree(workspaceRoot, args.verbose); + const useAutomaticFromRef = nxReleaseConfig.changelog?.automaticFromRef || args.firstRelease; @@ -190,8 +230,6 @@ export async function releaseChangelog( ); } - const tree = new FsTree(workspaceRoot, args.verbose); - const commitMessage: string | undefined = args.gitCommitMessage || nxReleaseConfig.changelog.git.commitMessage; @@ -215,43 +253,103 @@ export async function releaseChangelog( const postGitTasks: PostGitTask[] = []; - let workspaceChangelogFromRef = - args.from || - (await getLatestGitTagForPattern(nxReleaseConfig.releaseTagPattern))?.tag; - if (!workspaceChangelogFromRef) { - if (useAutomaticFromRef) { - workspaceChangelogFromRef = await getFirstGitCommit(); - if (args.verbose) { - console.log( - `Determined workspace --from ref from the first commit in the workspace: ${workspaceChangelogFromRef}` + let workspaceChangelogChanges: ChangelogChange[] = []; + // TODO: remove this after the changelog renderer is refactored to remove coupling with git commits + let workspaceChangelogCommits: GitCommit[] = []; + + // If there are multiple release groups, we'll just skip the workspace changelog anyway. + const versionPlansEnabledForWorkspaceChangelog = + releaseGroups[0].versionPlans; + if (versionPlansEnabledForWorkspaceChangelog) { + if (releaseGroups.length === 1) { + const releaseGroup = releaseGroups[0]; + if (releaseGroup.projectsRelationship === 'fixed') { + const versionPlans = releaseGroup.versionPlans as GroupVersionPlan[]; + workspaceChangelogChanges = filterHiddenChanges( + versionPlans + .map((vp) => { + const parsedMessage = parseConventionalCommitsMessage(vp.message); + + // only properly formatted conventional commits messages will be included in the changelog + if (!parsedMessage) { + return null; + } + + return { + type: parsedMessage.type, + scope: parsedMessage.scope, + description: parsedMessage.description, + body: '', + isBreaking: parsedMessage.breaking, + githubReferences: [], + }; + }) + .filter(Boolean), + nxReleaseConfig.conventionalCommits ); } - } else { - throw new Error( - `Unable to determine the previous git tag. If this is the first release of your workspace, use the --first-release option or set the "release.changelog.automaticFromRef" config property in nx.json to generate a changelog from the first commit. Otherwise, be sure to configure the "release.releaseTagPattern" property in nx.json to match the structure of your repository's git tags.` - ); } + } else { + let workspaceChangelogFromRef = + args.from || + (await getLatestGitTagForPattern(nxReleaseConfig.releaseTagPattern))?.tag; + if (!workspaceChangelogFromRef) { + if (useAutomaticFromRef) { + workspaceChangelogFromRef = await getFirstGitCommit(); + if (args.verbose) { + console.log( + `Determined workspace --from ref from the first commit in the workspace: ${workspaceChangelogFromRef}` + ); + } + } else { + throw new Error( + `Unable to determine the previous git tag. If this is the first release of your workspace, use the --first-release option or set the "release.changelog.automaticFromRef" config property in nx.json to generate a changelog from the first commit. Otherwise, be sure to configure the "release.releaseTagPattern" property in nx.json to match the structure of your repository's git tags.` + ); + } + } + + // Make sure that the fromRef is actually resolvable + const workspaceChangelogFromSHA = await getCommitHash( + workspaceChangelogFromRef + ); + + workspaceChangelogCommits = await getCommits( + workspaceChangelogFromSHA, + toSHA + ); + + workspaceChangelogChanges = filterHiddenChanges( + workspaceChangelogCommits.map((c) => { + return { + type: c.type, + scope: c.scope, + description: c.description, + body: c.body, + isBreaking: c.isBreaking, + githubReferences: c.references, + author: c.author, + shortHash: c.shortHash, + revertedHashes: c.revertedHashes, + affectedProjects: '*', + }; + }), + nxReleaseConfig.conventionalCommits + ); } - // Make sure that the fromRef is actually resolvable - const workspaceChangelogFromSHA = await getCommitHash( - workspaceChangelogFromRef - ); - - const workspaceChangelogCommits = await getCommits( - workspaceChangelogFromSHA, - toSHA, - nxReleaseConfig.conventionalCommits - ); - - const workspaceChangelog = await generateChangelogForWorkspace( + const workspaceChangelog = await generateChangelogForWorkspace({ tree, args, projectGraph, nxReleaseConfig, workspaceChangelogVersion, - workspaceChangelogCommits - ); + changes: workspaceChangelogChanges, + // TODO: remove this after the changelog renderer is refactored to remove coupling with git commits + commits: filterHiddenCommits( + workspaceChangelogCommits, + nxReleaseConfig.conventionalCommits + ), + }); if ( workspaceChangelog && @@ -299,9 +397,6 @@ export async function releaseChangelog( continue; } for (const project of releaseGroup.projects) { - if (projectToAdditionalDependencyBumps.has(project)) { - continue; - } const dependentProjects = ( projectsVersionData[project]?.dependentProjects || [] ) @@ -312,7 +407,21 @@ export async function releaseChangelog( }; }) .filter((b) => b.newVersion !== null); - projectToAdditionalDependencyBumps.set(project, dependentProjects); + + for (const dependent of dependentProjects) { + const additionalDependencyBumpsForProject = + projectToAdditionalDependencyBumps.has(dependent.dependencyName) + ? projectToAdditionalDependencyBumps.get(dependent.dependencyName) + : []; + additionalDependencyBumpsForProject.push({ + dependencyName: project, + newVersion: projectsVersionData[project].newVersion, + }); + projectToAdditionalDependencyBumps.set( + dependent.dependencyName, + additionalDependencyBumpsForProject + ); + } } } @@ -332,9 +441,9 @@ export async function releaseChangelog( (project) => { return [ project, - ...(projectToAdditionalDependencyBumps.get(project) || []).map( - (d) => d.dependencyName - ), + ...(projectsVersionData[project]?.dependentProjects.map( + (dep) => dep.source + ) || []), ]; } ) @@ -344,63 +453,116 @@ export async function releaseChangelog( if (releaseGroup.projectsRelationship === 'independent') { for (const project of projectNodes) { - let fromRef = - args.from || - ( - await getLatestGitTagForPattern(releaseGroup.releaseTagPattern, { - projectName: project.name, - }) - )?.tag; + let changes: ChangelogChange[] | null = null; + // TODO: remove this after the changelog renderer is refactored to remove coupling with git commits + let commits: GitCommit[]; - let commits: GitCommit[] | null = null; + if (releaseGroup.versionPlans) { + changes = filterHiddenChanges( + (releaseGroup.versionPlans as ProjectsVersionPlan[]) + .map((vp) => { + const parsedMessage = parseConventionalCommitsMessage( + vp.message + ); - if (!fromRef && useAutomaticFromRef) { - const firstCommit = await getFirstGitCommit(); - const allRawCommits = await getGitDiff(firstCommit, toSHA); - const allParsedCommits = parseCommits(allRawCommits); - const commitsForProject = allParsedCommits.filter((c) => - c.affectedFiles.find((f) => f.startsWith(project.data.root)) + // only properly formatted conventional commits messages will be included in the changelog + if (!parsedMessage) { + return null; + } + + return { + type: parsedMessage.type, + scope: parsedMessage.scope, + description: parsedMessage.description, + body: '', + isBreaking: parsedMessage.breaking, + affectedProjects: Object.keys(vp.projectVersionBumps), + githubReferences: [], + }; + }) + .filter(Boolean), + nxReleaseConfig.conventionalCommits ); + } else { + let fromRef = + args.from || + ( + await getLatestGitTagForPattern(releaseGroup.releaseTagPattern, { + projectName: project.name, + }) + )?.tag; - fromRef = commitsForProject[0]?.shortHash; - if (args.verbose) { - console.log( - `Determined --from ref for ${project.name} from the first commit in which it exists: ${fromRef}` + if (!fromRef && useAutomaticFromRef) { + const firstCommit = await getFirstGitCommit(); + const allCommits = await getCommits(firstCommit, toSHA); + const commitsForProject = allCommits.filter((c) => + c.affectedFiles.find((f) => f.startsWith(project.data.root)) + ); + + fromRef = commitsForProject[0]?.shortHash; + if (args.verbose) { + console.log( + `Determined --from ref for ${project.name} from the first commit in which it exists: ${fromRef}` + ); + } + commits = commitsForProject; + } + + if (!fromRef && !commits) { + throw new Error( + `Unable to determine the previous git tag. If this is the first release of your workspace, use the --first-release option or set the "release.changelog.automaticFromRef" config property in nx.json to generate a changelog from the first commit. Otherwise, be sure to configure the "release.releaseTagPattern" property in nx.json to match the structure of your repository's git tags.` ); } - commits = commitsForProject.filter((c) => - applyConventionalCommitsConfigFilter( - c, - nxReleaseConfig.conventionalCommits - ) - ); - } - if (!fromRef && !commits) { - throw new Error( - `Unable to determine the previous git tag. If this is the first release of your workspace, use the --first-release option or set the "release.changelog.automaticFromRef" config property in nx.json to generate a changelog from the first commit. Otherwise, be sure to configure the "release.releaseTagPattern" property in nx.json to match the structure of your repository's git tags.` - ); - } + if (!commits) { + commits = await getCommits(fromRef, toSHA); + } - if (!commits) { - commits = await getCommits( - fromRef, - toSHA, + const { fileMap } = await createFileMapUsingProjectGraph( + projectGraph + ); + const fileToProjectMap = createFileToProjectMap( + fileMap.projectFileMap + ); + + changes = filterHiddenChanges( + commits.map((c) => ({ + type: c.type, + scope: c.scope, + description: c.description, + body: c.body, + isBreaking: c.isBreaking, + githubReferences: c.references, + author: c.author, + shortHash: c.shortHash, + revertedHashes: c.revertedHashes, + affectedProjects: commitChangesNonProjectFiles( + c, + fileMap.nonProjectFiles + ) + ? '*' + : getProjectsAffectedByCommit(c, fileToProjectMap), + })), nxReleaseConfig.conventionalCommits ); } - const projectChangelogs = await generateChangelogForProjects( + const projectChangelogs = await generateChangelogForProjects({ tree, args, projectGraph, - commits, + changes, projectsVersionData, releaseGroup, - [project], + projects: [project], nxReleaseConfig, - projectToAdditionalDependencyBumps - ); + projectToAdditionalDependencyBumps, + // TODO: remove this after the changelog renderer is refactored to remove coupling with git commits + commits: filterHiddenCommits( + commits, + nxReleaseConfig.conventionalCommits + ), + }); let hasPushed = false; for (const [projectName, projectChangelog] of Object.entries( @@ -440,44 +602,98 @@ export async function releaseChangelog( } } } else { - let fromRef = - args.from || - (await getLatestGitTagForPattern(releaseGroup.releaseTagPattern))?.tag; - if (!fromRef) { - if (useAutomaticFromRef) { - fromRef = await getFirstGitCommit(); - if (args.verbose) { - console.log( - `Determined release group --from ref from the first commit in the workspace: ${fromRef}` + let changes: ChangelogChange[] = []; + // TODO: remove this after the changelog renderer is refactored to remove coupling with git commits + let commits: GitCommit[] = []; + if (releaseGroup.versionPlans) { + changes = filterHiddenChanges( + (releaseGroup.versionPlans as GroupVersionPlan[]) + .map((vp) => { + const parsedMessage = parseConventionalCommitsMessage(vp.message); + + // only properly formatted conventional commits messages will be included in the changelog + if (!parsedMessage) { + return null; + } + + return { + type: parsedMessage.type, + scope: parsedMessage.scope, + description: parsedMessage.description, + body: '', + isBreaking: parsedMessage.breaking, + githubReferences: [], + affectedProjects: '*', + }; + }) + .filter(Boolean), + nxReleaseConfig.conventionalCommits + ); + } else { + let fromRef = + args.from || + (await getLatestGitTagForPattern(releaseGroup.releaseTagPattern)) + ?.tag; + if (!fromRef) { + if (useAutomaticFromRef) { + fromRef = await getFirstGitCommit(); + if (args.verbose) { + console.log( + `Determined release group --from ref from the first commit in the workspace: ${fromRef}` + ); + } + } else { + throw new Error( + `Unable to determine the previous git tag. If this is the first release of your release group, use the --first-release option or set the "release.changelog.automaticFromRef" config property in nx.json to generate a changelog from the first commit. Otherwise, be sure to configure the "release.releaseTagPattern" property in nx.json to match the structure of your repository's git tags.` ); } - } else { - throw new Error( - `Unable to determine the previous git tag. If this is the first release of your release group, use the --first-release option or set the "release.changelog.automaticFromRef" config property in nx.json to generate a changelog from the first commit. Otherwise, be sure to configure the "release.releaseTagPattern" property in nx.json to match the structure of your repository's git tags.` - ); } + + // Make sure that the fromRef is actually resolvable + const fromSHA = await getCommitHash(fromRef); + + const { fileMap } = await createFileMapUsingProjectGraph(projectGraph); + const fileToProjectMap = createFileToProjectMap(fileMap.projectFileMap); + + commits = await getCommits(fromSHA, toSHA); + changes = filterHiddenChanges( + commits.map((c) => ({ + type: c.type, + scope: c.scope, + description: c.description, + body: c.body, + isBreaking: c.isBreaking, + githubReferences: c.references, + author: c.author, + shortHash: c.shortHash, + revertedHashes: c.revertedHashes, + affectedProjects: commitChangesNonProjectFiles( + c, + fileMap.nonProjectFiles + ) + ? '*' + : getProjectsAffectedByCommit(c, fileToProjectMap), + })), + nxReleaseConfig.conventionalCommits + ); } - // Make sure that the fromRef is actually resolvable - const fromSHA = await getCommitHash(fromRef); - - const commits = await getCommits( - fromSHA, - toSHA, - nxReleaseConfig.conventionalCommits - ); - - const projectChangelogs = await generateChangelogForProjects( + const projectChangelogs = await generateChangelogForProjects({ tree, args, projectGraph, - commits, + changes, projectsVersionData, releaseGroup, - projectNodes, + projects: projectNodes, nxReleaseConfig, - projectToAdditionalDependencyBumps - ); + projectToAdditionalDependencyBumps, + // TODO: remove this after the changelog renderer is refactored to remove coupling with git commits + commits: filterHiddenCommits( + commits, + nxReleaseConfig.conventionalCommits + ), + }); let hasPushed = false; for (const [projectName, projectChangelog] of Object.entries( @@ -522,7 +738,8 @@ export async function releaseChangelog( toSHA, postGitTasks, commitMessageValues, - gitTagValues + gitTagValues, + releaseGroups ); return { @@ -599,7 +816,8 @@ async function applyChangesAndExit( toSHA: string, postGitTasks: PostGitTask[], commitMessageValues: string[], - gitTagValues: string[] + gitTagValues: string[], + releaseGroups: ReleaseGroupWithName[] ) { let latestCommit = toSHA; @@ -646,15 +864,33 @@ async function applyChangesAndExit( return; } + const changedFiles: string[] = changes.map((f) => f.path); + + let deletedFiles: string[] = []; + if (args.deleteVersionPlans && !args.dryRun) { + const planFiles = new Set(); + releaseGroups.forEach((group) => { + if (group.versionPlans) { + group.versionPlans.forEach((plan) => { + removeSync(plan.absolutePath); + planFiles.add(plan.relativePath); + }); + } + }); + deletedFiles = Array.from(planFiles); + } + // Generate a new commit for the changes, if configured to do so if (args.gitCommit ?? nxReleaseConfig.changelog.git.commit) { - await commitChanges( - changes.map((f) => f.path), - !!args.dryRun, - !!args.verbose, - commitMessageValues, - args.gitCommitArgs || nxReleaseConfig.changelog.git.commitArgs - ); + await commitChanges({ + changedFiles, + deletedFiles, + isDryRun: !!args.dryRun, + isVerbose: !!args.verbose, + gitCommitMessages: commitMessageValues, + gitCommitArgs: + args.gitCommitArgs || nxReleaseConfig.changelog.git.commitArgs, + }); // Resolve the commit we just made latestCommit = await getCommitHash('HEAD'); } else if ( @@ -663,7 +899,8 @@ async function applyChangesAndExit( ) { output.logSingleLine(`Staging changed files with git`); await gitAdd({ - changedFiles: changes.map((f) => f.path), + changedFiles, + deletedFiles, dryRun: args.dryRun, verbose: args.verbose, }); @@ -692,14 +929,23 @@ async function applyChangesAndExit( return; } -async function generateChangelogForWorkspace( - tree: Tree, - args: ChangelogOptions, - projectGraph: ProjectGraph, - nxReleaseConfig: NxReleaseConfig, - workspaceChangelogVersion: (string | null) | undefined, - commits: GitCommit[] -): Promise { +async function generateChangelogForWorkspace({ + tree, + args, + projectGraph, + nxReleaseConfig, + workspaceChangelogVersion, + changes, + commits, +}: { + tree: Tree; + args: ChangelogOptions; + projectGraph: ProjectGraph; + nxReleaseConfig: NxReleaseConfig; + workspaceChangelogVersion: (string | null) | undefined; + changes: ChangelogChange[]; + commits: GitCommit[]; +}): Promise { const config = nxReleaseConfig.changelog.workspaceChangelog; // The entire feature is disabled at the workspace level, exit early if (config === false) { @@ -778,6 +1024,7 @@ async function generateChangelogForWorkspace( let contents = await changelogRenderer({ projectGraph, + changes, commits, releaseVersion: releaseVersion.rawVersion, project: null, @@ -841,17 +1088,29 @@ async function generateChangelogForWorkspace( }; } -async function generateChangelogForProjects( - tree: Tree, - args: ChangelogOptions, - projectGraph: ProjectGraph, - commits: GitCommit[], - projectsVersionData: VersionData, - releaseGroup: ReleaseGroupWithName, - projects: ProjectGraphProjectNode[], - nxReleaseConfig: NxReleaseConfig, - projectToAdditionalDependencyBumps: Map -): Promise { +async function generateChangelogForProjects({ + tree, + args, + projectGraph, + changes, + commits, + projectsVersionData, + releaseGroup, + projects, + nxReleaseConfig, + projectToAdditionalDependencyBumps, +}: { + tree: Tree; + args: ChangelogOptions; + projectGraph: ProjectGraph; + changes: ChangelogChange[]; + commits: GitCommit[]; + projectsVersionData: VersionData; + releaseGroup: ReleaseGroupWithName; + projects: ProjectGraphProjectNode[]; + nxReleaseConfig: NxReleaseConfig; + projectToAdditionalDependencyBumps: Map; +}): Promise { const config = releaseGroup.changelog; // The entire feature is disabled at the release group level, exit early if (config === false) { @@ -908,6 +1167,7 @@ async function generateChangelogForProjects( let contents = await changelogRenderer({ projectGraph, + changes, commits, releaseVersion: releaseVersion.rawVersion, project: project.name, @@ -1006,32 +1266,47 @@ function checkChangelogFilesEnabled(nxReleaseConfig: NxReleaseConfig): boolean { async function getCommits( fromSHA: string, - toSHA: string, - conventionalCommitsConfig: NxReleaseConfig['conventionalCommits'] + toSHA: string ): Promise { const rawCommits = await getGitDiff(fromSHA, toSHA); // Parse as conventional commits - const parsedCommits = parseCommits(rawCommits); - if (conventionalCommitsConfig === null) { - return parsedCommits; - } - // Apply filtering based on the conventional commits configuration - return parsedCommits.filter((c) => - applyConventionalCommitsConfigFilter(c, conventionalCommitsConfig) - ); + return parseCommits(rawCommits); } -function applyConventionalCommitsConfigFilter( - commit: GitCommit, +function filterHiddenChanges( + changes: ChangelogChange[], conventionalCommitsConfig: NxReleaseConfig['conventionalCommits'] -): boolean { - const type = commit.type; - const typeConfig = conventionalCommitsConfig.types[type]; - if (!typeConfig) { - // don't include commits with unknown types - return false; +): ChangelogChange[] { + return changes.filter((change) => { + const type = change.type; + + const typeConfig = conventionalCommitsConfig.types[type]; + if (!typeConfig) { + // don't include changes with unknown types + return false; + } + return !typeConfig.changelog.hidden; + }); +} + +// TODO: remove this after the changelog renderer is refactored to remove coupling with git commits +function filterHiddenCommits( + commits: GitCommit[], + conventionalCommitsConfig: NxReleaseConfig['conventionalCommits'] +): GitCommit[] { + if (!commits) { + return []; } - return !typeConfig.changelog.hidden; + return commits.filter((commit) => { + const type = commit.type; + + const typeConfig = conventionalCommitsConfig.types[type]; + if (!typeConfig) { + // don't include commits with unknown types + return false; + } + return !typeConfig.changelog.hidden; + }); } export function shouldCreateGitHubRelease( @@ -1060,3 +1335,35 @@ async function promptForGitHubRelease(): Promise { return false; } } + +function getProjectsAffectedByCommit( + commit: GitCommit, + fileToProjectMap: Record +): string[] { + const affectedProjects = new Set(); + for (const file of commit.affectedFiles) { + affectedProjects.add(fileToProjectMap[file]); + } + return Array.from(affectedProjects); +} + +function commitChangesNonProjectFiles( + commit: GitCommit, + nonProjectFiles: FileData[] +): boolean { + return nonProjectFiles.some((fileData) => + commit.affectedFiles.includes(fileData.file) + ); +} + +function createFileToProjectMap( + projectFileMap: ProjectFileMap +): Record { + const fileToProjectMap = {}; + for (const [projectName, projectFiles] of Object.entries(projectFileMap)) { + for (const file of projectFiles) { + fileToProjectMap[file.file] = projectName; + } + } + return fileToProjectMap; +} diff --git a/packages/nx/src/command-line/release/command-object.ts b/packages/nx/src/command-line/release/command-object.ts index d4ef659654..5704dd36e1 100644 --- a/packages/nx/src/command-line/release/command-object.ts +++ b/packages/nx/src/command-line/release/command-object.ts @@ -1,4 +1,5 @@ import { Argv, CommandModule, showHelp } from 'yargs'; +import { readNxJson } from '../../config/nx-json'; import { logger } from '../../utils/logger'; import { OutputStyle, @@ -9,14 +10,12 @@ import { withRunManyOptions, } from '../yargs-utils/shared-options'; import { VersionData } from './utils/shared'; -import { readNxJson } from '../../config/nx-json'; export interface NxReleaseArgs { groups?: string[]; projects?: string[]; dryRun?: boolean; verbose?: boolean; - firstRelease?: boolean; } interface GitCommitAndTagOptions { @@ -30,7 +29,9 @@ interface GitCommitAndTagOptions { } export type VersionOptions = NxReleaseArgs & - GitCommitAndTagOptions & { + GitCommitAndTagOptions & + VersionPlanArgs & + FirstReleaseArgs & { specifier?: string; preid?: string; stageChanges?: boolean; @@ -38,7 +39,9 @@ export type VersionOptions = NxReleaseArgs & }; export type ChangelogOptions = NxReleaseArgs & - GitCommitAndTagOptions & { + GitCommitAndTagOptions & + VersionPlanArgs & + FirstReleaseArgs & { // version and/or versionData must be set version?: string | null; versionData?: VersionData; @@ -50,15 +53,29 @@ export type ChangelogOptions = NxReleaseArgs & }; export type PublishOptions = NxReleaseArgs & - Partial & { outputStyle?: OutputStyle } & { + Partial & { outputStyle?: OutputStyle } & FirstReleaseArgs & { registry?: string; tag?: string; otp?: number; }; -export type ReleaseOptions = NxReleaseArgs & { - yes?: boolean; - skipPublish?: boolean; +export type PlanOptions = NxReleaseArgs & { + bump?: string; + message?: string; +}; + +export type ReleaseOptions = NxReleaseArgs & + FirstReleaseArgs & { + yes?: boolean; + skipPublish?: boolean; + }; + +export type VersionPlanArgs = { + deleteVersionPlans?: boolean; +}; + +export type FirstReleaseArgs = { + firstRelease?: boolean; }; export const yargsReleaseCommand: CommandModule< @@ -74,6 +91,7 @@ export const yargsReleaseCommand: CommandModule< .command(versionCommand) .command(changelogCommand) .command(publishCommand) + .command(planCommand) .demandCommand() // Error on typos/mistyped CLI args, there is no reason to support arbitrary unknown args for these commands .strictOptions() @@ -103,11 +121,6 @@ export const yargsReleaseCommand: CommandModule< describe: 'Prints additional information about the commands (e.g., stack traces)', }) - .option('first-release', { - type: 'boolean', - description: - 'Indicates that this is the first release for the selected release group. If the current version cannot be determined as usual, the version on disk will be used as a fallback. This is useful when using git or the registry to determine the current version of packages, since those sources are only available after the first release. Also indicates that changelog generation should not assume a previous git tag exists and that publishing should not check for the existence of the package before running.', - }) .check((argv) => { if (argv.groups && argv.projects) { throw new Error( @@ -137,7 +150,7 @@ const releaseCommand: CommandModule = { describe: 'Create a version and release for the workspace, generate a changelog, and optionally publish the packages', builder: (yargs) => - yargs + withFirstReleaseOptions(yargs) .positional('specifier', { type: 'string', describe: @@ -182,24 +195,26 @@ const versionCommand: CommandModule = { describe: 'Create a version and release for one or more applications and libraries', builder: (yargs) => - withGitCommitAndGitTagOptions( - yargs - .positional('specifier', { - type: 'string', - describe: - 'Exact version or semver keyword to apply to the selected release group.', - }) - .option('preid', { - type: 'string', - describe: - 'The optional prerelease identifier to apply to the version, in the case that the specifier argument has been set to `prerelease`.', - default: '', - }) - .option('stage-changes', { - type: 'boolean', - describe: - 'Whether or not to stage the changes made by this command. Useful when combining this command with changelog generation.', - }) + withFirstReleaseOptions( + withGitCommitAndGitTagOptions( + yargs + .positional('specifier', { + type: 'string', + describe: + 'Exact version or semver keyword to apply to the selected release group.', + }) + .option('preid', { + type: 'string', + describe: + 'The optional prerelease identifier to apply to the version, in the case that the specifier argument has been set to `prerelease`.', + default: '', + }) + .option('stage-changes', { + type: 'boolean', + describe: + 'Whether or not to stage the changes made by this command. Useful when combining this command with changelog generation.', + }) + ) ), handler: async (args) => { const release = await import('./version'); @@ -221,46 +236,48 @@ const changelogCommand: CommandModule = { describe: 'Generate a changelog for one or more projects, and optionally push to Github', builder: (yargs) => - withGitCommitAndGitTagOptions( - yargs - // Disable default meaning of yargs version for this command - .version(false) - .positional('version', { - type: 'string', - description: - 'The version to create a Github release and changelog for', - }) - .option('from', { - type: 'string', - description: - 'The git reference to use as the start of the changelog. If not set it will attempt to resolve the latest tag and use that', - }) - .option('to', { - type: 'string', - description: 'The git reference to use as the end of the changelog', - default: 'HEAD', - }) - .option('interactive', { - alias: 'i', - 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('git-remote', { - type: 'string', - description: - 'Alternate git remote in the form {user}/{repo} on which to create the Github release (useful for testing)', - default: 'origin', - }) - .check((argv) => { - if (!argv.version) { - throw new Error( - 'An explicit target version must be specified when using the changelog command directly' - ); - } - return true; - }) + withFirstReleaseOptions( + withGitCommitAndGitTagOptions( + yargs + // Disable default meaning of yargs version for this command + .version(false) + .positional('version', { + type: 'string', + description: + 'The version to create a Github release and changelog for', + }) + .option('from', { + type: 'string', + description: + 'The git reference to use as the start of the changelog. If not set it will attempt to resolve the latest tag and use that', + }) + .option('to', { + type: 'string', + description: 'The git reference to use as the end of the changelog', + default: 'HEAD', + }) + .option('interactive', { + alias: 'i', + 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('git-remote', { + type: 'string', + description: + 'Alternate git remote in the form {user}/{repo} on which to create the Github release (useful for testing)', + default: 'origin', + }) + .check((argv) => { + if (!argv.version) { + throw new Error( + 'An explicit target version must be specified when using the changelog command directly' + ); + } + return true; + }) + ) ), handler: async (args) => { const release = await import('./changelog'); @@ -281,20 +298,22 @@ const publishCommand: CommandModule = { aliases: ['p'], describe: 'Publish a versioned project to a registry', builder: (yargs) => - withRunManyOptions(withOutputStyleOption(yargs)) - .option('registry', { - type: 'string', - description: 'The registry to publish to', - }) - .option('tag', { - type: 'string', - description: 'The distribution tag to apply to the published package', - }) - .option('otp', { - type: 'number', - description: - 'A one-time password for publishing to a registry that requires 2FA', - }), + withFirstReleaseOptions( + withRunManyOptions(withOutputStyleOption(yargs)) + .option('registry', { + type: 'string', + description: 'The registry to publish to', + }) + .option('tag', { + type: 'string', + description: 'The distribution tag to apply to the published package', + }) + .option('otp', { + type: 'number', + description: + 'A one-time password for publishing to a registry that requires 2FA', + }) + ), handler: async (args) => { const status = await ( await import('./publish') @@ -307,6 +326,46 @@ const publishCommand: CommandModule = { }, }; +const planCommand: CommandModule = { + command: 'plan [bump]', + aliases: ['pl'], + // Create a plan to pick a new version and generate a changelog entry. + // Hidden for now until the feature is more stable + describe: false, + builder: (yargs) => + yargs + .positional('bump', { + type: 'string', + describe: 'Semver keyword to use for the selected release group.', + choices: [ + 'major', + 'premajor', + 'minor', + 'preminor', + 'patch', + 'prepatch', + 'prerelease', + ], + }) + .option('message', { + type: 'string', + alias: 'm', + describe: 'Custom message to use for the changelog entry', + }), + handler: async (args) => { + const release = await import('./plan'); + const result = await release.releasePlanCLIHandler(args); + if (args.dryRun) { + logger.warn(`\nNOTE: The "dryRun" flag means no changes were made.`); + } + + if (typeof result === 'number') { + process.exit(result); + } + process.exit(0); + }, +}; + function coerceParallelOption(args: any) { if (args['parallel'] === 'false' || args['parallel'] === false) { return { @@ -371,3 +430,13 @@ function withGitCommitAndGitTagOptions( type: 'boolean', }); } + +function withFirstReleaseOptions( + yargs: Argv +): Argv { + return yargs.option('first-release', { + type: 'boolean', + description: + 'Indicates that this is the first release for the selected release group. If the current version cannot be determined as usual, the version on disk will be used as a fallback. This is useful when using git or the registry to determine the current version of packages, since those sources are only available after the first release. Also indicates that changelog generation should not assume a previous git tag exists and that publishing should not check for the existence of the package before running.', + }); +} 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 1999b86837..a421260240 100644 --- a/packages/nx/src/command-line/release/config/config.spec.ts +++ b/packages/nx/src/command-line/release/config/config.spec.ts @@ -1,7 +1,7 @@ +import { join } from 'path'; import { ProjectFileMap, ProjectGraph } from '../../../config/project-graph'; import { TempFs } from '../../../internal-testing-utils/temp-fs'; import { createNxReleaseConfig } from './config'; -import { join } from 'path'; expect.addSnapshotSerializer({ serialize: (str: string) => { @@ -273,6 +273,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -292,6 +293,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -445,6 +447,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -464,6 +467,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -620,6 +624,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -639,6 +644,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -826,6 +832,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -845,6 +852,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -1016,6 +1024,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -1035,6 +1044,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -1214,6 +1224,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -1233,6 +1244,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -1413,6 +1425,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -1432,6 +1445,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -1592,6 +1606,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -1611,6 +1626,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -1770,6 +1786,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -1789,6 +1806,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -1951,6 +1969,7 @@ describe('createNxReleaseConfig()', () => { "optionsOverride": "something", }, }, + "versionPlans": false, }, "group-2": { "changelog": false, @@ -1964,6 +1983,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@custom/generator-alternative", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -1983,6 +2003,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -2152,6 +2173,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -2171,6 +2193,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -2335,6 +2358,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, "group-2": { "changelog": { @@ -2358,6 +2382,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -2377,6 +2402,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -2539,6 +2565,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -2558,6 +2585,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -2723,6 +2751,7 @@ describe('createNxReleaseConfig()', () => { "foo": "bar", }, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -2744,6 +2773,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -2903,6 +2933,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -2922,6 +2953,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -3084,6 +3116,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -3103,6 +3136,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -3261,6 +3295,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -3280,6 +3315,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "nx run-many -t build", }, + "versionPlans": false, }, } `); @@ -3456,6 +3492,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -3475,6 +3512,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -3633,6 +3671,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -3652,6 +3691,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -3831,6 +3871,7 @@ describe('createNxReleaseConfig()', () => { "specifierSource": "conventional-commits", }, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -3850,6 +3891,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -4001,6 +4043,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -4020,6 +4063,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -4209,6 +4253,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -4228,6 +4273,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -4407,6 +4453,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -4426,6 +4473,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -4585,6 +4633,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -4604,6 +4653,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -4763,6 +4813,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -4782,6 +4833,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -4939,6 +4991,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -4958,6 +5011,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -5151,6 +5205,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -5170,6 +5225,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -5339,6 +5395,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -5358,6 +5415,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -5377,181 +5435,183 @@ describe('createNxReleaseConfig()', () => { }); expect(res).toMatchInlineSnapshot(` - { - "error": null, - "nxReleaseConfig": { - "changelog": { - "automaticFromRef": false, - "git": { - "commit": true, - "commitArgs": "", - "commitMessage": "chore(release): publish {version}", - "stageChanges": false, - "tag": true, - "tagArgs": "", - "tagMessage": "", + { + "error": null, + "nxReleaseConfig": { + "changelog": { + "automaticFromRef": false, + "git": { + "commit": true, + "commitArgs": "", + "commitMessage": "chore(release): publish {version}", + "stageChanges": false, + "tag": true, + "tagArgs": "", + "tagMessage": "", + }, + "projectChangelogs": false, + "workspaceChangelog": { + "createRelease": false, + "entryWhenNoChanges": "This was a version bump only, there were no code changes.", + "file": "{workspaceRoot}/CHANGELOG.md", + "renderOptions": { + "authors": true, + "commitReferences": true, + "versionTitleDate": true, + }, + "renderer": "/release/changelog-renderer", + }, }, - "projectChangelogs": false, - "workspaceChangelog": { - "createRelease": false, - "entryWhenNoChanges": "This was a version bump only, there were no code changes.", - "file": "{workspaceRoot}/CHANGELOG.md", - "renderOptions": { - "authors": true, - "commitReferences": true, - "versionTitleDate": true, - }, - "renderer": "/release/changelog-renderer", - }, - }, - "conventionalCommits": { - "types": { - "build": { - "changelog": { - "hidden": true, - "title": "📦 Build", - }, - "semverBump": "none", - }, - "chore": { - "changelog": { - "hidden": true, - "title": "🏡 Chore", - }, - "semverBump": "none", - }, - "ci": { - "changelog": { - "hidden": true, - "title": "🤖 CI", - }, - "semverBump": "none", - }, - "customType": { - "changelog": { - "hidden": false, - "title": "customType", - }, - "semverBump": "patch", - }, - "docs": { - "changelog": { - "hidden": false, - "title": "📖 Documentation", - }, - "semverBump": "patch", - }, - "examples": { - "changelog": { - "hidden": true, - "title": "🏀 Examples", - }, - "semverBump": "none", - }, - "feat": { - "changelog": { - "hidden": false, - "title": "🚀 Features", - }, - "semverBump": "minor", - }, - "fix": { - "changelog": { - "hidden": false, - "title": "🩹 Fixes", - }, - "semverBump": "patch", - }, - "perf": { - "changelog": { - "hidden": false, - "title": "🔥 Performance", - }, - "semverBump": "patch", - }, - "refactor": { - "changelog": { - "hidden": true, - "title": "💅 Refactors", - }, - "semverBump": "none", - }, - "revert": { - "changelog": { - "hidden": true, - "title": "⏪ Revert", - }, - "semverBump": "none", - }, - "style": { - "changelog": { - "hidden": true, - "title": "🎨 Styles", - }, - "semverBump": "none", - }, - "test": { - "changelog": { - "hidden": true, - "title": "✅ Tests", - }, - "semverBump": "none", - }, + "conventionalCommits": { "types": { - "changelog": { - "hidden": true, - "title": "🌊 Types", + "build": { + "changelog": { + "hidden": true, + "title": "📦 Build", + }, + "semverBump": "none", + }, + "chore": { + "changelog": { + "hidden": true, + "title": "🏡 Chore", + }, + "semverBump": "none", + }, + "ci": { + "changelog": { + "hidden": true, + "title": "🤖 CI", + }, + "semverBump": "none", + }, + "customType": { + "changelog": { + "hidden": false, + "title": "customType", + }, + "semverBump": "patch", + }, + "docs": { + "changelog": { + "hidden": false, + "title": "📖 Documentation", + }, + "semverBump": "patch", + }, + "examples": { + "changelog": { + "hidden": true, + "title": "🏀 Examples", + }, + "semverBump": "none", + }, + "feat": { + "changelog": { + "hidden": false, + "title": "🚀 Features", + }, + "semverBump": "minor", + }, + "fix": { + "changelog": { + "hidden": false, + "title": "🩹 Fixes", + }, + "semverBump": "patch", + }, + "perf": { + "changelog": { + "hidden": false, + "title": "🔥 Performance", + }, + "semverBump": "patch", + }, + "refactor": { + "changelog": { + "hidden": true, + "title": "💅 Refactors", + }, + "semverBump": "none", + }, + "revert": { + "changelog": { + "hidden": true, + "title": "⏪ Revert", + }, + "semverBump": "none", + }, + "style": { + "changelog": { + "hidden": true, + "title": "🎨 Styles", + }, + "semverBump": "none", + }, + "test": { + "changelog": { + "hidden": true, + "title": "✅ Tests", + }, + "semverBump": "none", + }, + "types": { + "changelog": { + "hidden": true, + "title": "🌊 Types", + }, + "semverBump": "none", }, - "semverBump": "none", }, }, - }, - "git": { - "commit": false, - "commitArgs": "", - "commitMessage": "chore(release): publish {version}", - "stageChanges": false, - "tag": false, - "tagArgs": "", - "tagMessage": "", - }, - "groups": { - "__default__": { - "changelog": false, - "projects": [ - "lib-a", - "lib-b", - "nx", - ], - "projectsRelationship": "fixed", - "releaseTagPattern": "v{version}", - "version": { - "conventionalCommits": false, - "generator": "@nx/js:release-version", - "generatorOptions": {}, - }, - }, - }, - "projectsRelationship": "fixed", - "releaseTagPattern": "v{version}", - "version": { - "conventionalCommits": false, - "generator": "@nx/js:release-version", - "generatorOptions": {}, "git": { "commit": false, "commitArgs": "", "commitMessage": "chore(release): publish {version}", - "stageChanges": true, + "stageChanges": false, "tag": false, "tagArgs": "", "tagMessage": "", }, - "preVersionCommand": "", + "groups": { + "__default__": { + "changelog": false, + "projects": [ + "lib-a", + "lib-b", + "nx", + ], + "projectsRelationship": "fixed", + "releaseTagPattern": "v{version}", + "version": { + "conventionalCommits": false, + "generator": "@nx/js:release-version", + "generatorOptions": {}, + }, + "versionPlans": false, + }, + }, + "projectsRelationship": "fixed", + "releaseTagPattern": "v{version}", + "version": { + "conventionalCommits": false, + "generator": "@nx/js:release-version", + "generatorOptions": {}, + "git": { + "commit": false, + "commitArgs": "", + "commitMessage": "chore(release): publish {version}", + "stageChanges": true, + "tag": false, + "tagArgs": "", + "tagMessage": "", + }, + "preVersionCommand": "", + }, + "versionPlans": false, }, - }, - } - `); + } + `); }); it('should parse shorthand for disabling changelog appearance for a commit type', async () => { @@ -5722,6 +5782,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -5741,6 +5802,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -5764,180 +5826,182 @@ describe('createNxReleaseConfig()', () => { }); expect(res).toMatchInlineSnapshot(` - { - "error": null, - "nxReleaseConfig": { - "changelog": { - "automaticFromRef": false, - "git": { - "commit": true, - "commitArgs": "", - "commitMessage": "chore(release): publish {version}", - "stageChanges": false, - "tag": true, - "tagArgs": "", - "tagMessage": "", + { + "error": null, + "nxReleaseConfig": { + "changelog": { + "automaticFromRef": false, + "git": { + "commit": true, + "commitArgs": "", + "commitMessage": "chore(release): publish {version}", + "stageChanges": false, + "tag": true, + "tagArgs": "", + "tagMessage": "", + }, + "projectChangelogs": false, + "workspaceChangelog": { + "createRelease": false, + "entryWhenNoChanges": "This was a version bump only, there were no code changes.", + "file": "{workspaceRoot}/CHANGELOG.md", + "renderOptions": { + "authors": true, + "commitReferences": true, + "versionTitleDate": true, + }, + "renderer": "/release/changelog-renderer", + }, }, - "projectChangelogs": false, - "workspaceChangelog": { - "createRelease": false, - "entryWhenNoChanges": "This was a version bump only, there were no code changes.", - "file": "{workspaceRoot}/CHANGELOG.md", - "renderOptions": { - "authors": true, - "commitReferences": true, - "versionTitleDate": true, - }, - "renderer": "/release/changelog-renderer", - }, - }, - "conventionalCommits": { - "types": { - "build": { - "changelog": { - "hidden": true, - "title": "📦 Build", - }, - "semverBump": "none", - }, - "chore": { - "changelog": { - "hidden": true, - "title": "🏡 Chore", - }, - "semverBump": "none", - }, - "ci": { - "changelog": { - "hidden": true, - "title": "🤖 CI", - }, - "semverBump": "none", - }, - "customType": { - "changelog": { - "hidden": false, - "title": "customType", - }, - "semverBump": "patch", - }, - "docs": { - "changelog": { - "hidden": false, - "title": "📖 Documentation", - }, - "semverBump": "patch", - }, - "examples": { - "changelog": { - "hidden": true, - "title": "🏀 Examples", - }, - "semverBump": "none", - }, - "feat": { - "changelog": { - "hidden": false, - "title": "🚀 Features", - }, - "semverBump": "minor", - }, - "fix": { - "changelog": { - "hidden": false, - "title": "🩹 Fixes", - }, - "semverBump": "patch", - }, - "perf": { - "changelog": { - "hidden": false, - "title": "🔥 Performance", - }, - "semverBump": "none", - }, - "refactor": { - "changelog": { - "hidden": true, - "title": "💅 Refactors", - }, - "semverBump": "none", - }, - "revert": { - "changelog": { - "hidden": true, - "title": "⏪ Revert", - }, - "semverBump": "none", - }, - "style": { - "changelog": { - "hidden": true, - "title": "🎨 Styles", - }, - "semverBump": "none", - }, - "test": { - "changelog": { - "hidden": true, - "title": "✅ Tests", - }, - "semverBump": "none", - }, + "conventionalCommits": { "types": { - "changelog": { - "hidden": true, - "title": "🌊 Types", + "build": { + "changelog": { + "hidden": true, + "title": "📦 Build", + }, + "semverBump": "none", + }, + "chore": { + "changelog": { + "hidden": true, + "title": "🏡 Chore", + }, + "semverBump": "none", + }, + "ci": { + "changelog": { + "hidden": true, + "title": "🤖 CI", + }, + "semverBump": "none", + }, + "customType": { + "changelog": { + "hidden": false, + "title": "customType", + }, + "semverBump": "patch", + }, + "docs": { + "changelog": { + "hidden": false, + "title": "📖 Documentation", + }, + "semverBump": "patch", + }, + "examples": { + "changelog": { + "hidden": true, + "title": "🏀 Examples", + }, + "semverBump": "none", + }, + "feat": { + "changelog": { + "hidden": false, + "title": "🚀 Features", + }, + "semverBump": "minor", + }, + "fix": { + "changelog": { + "hidden": false, + "title": "🩹 Fixes", + }, + "semverBump": "patch", + }, + "perf": { + "changelog": { + "hidden": false, + "title": "🔥 Performance", + }, + "semverBump": "none", + }, + "refactor": { + "changelog": { + "hidden": true, + "title": "💅 Refactors", + }, + "semverBump": "none", + }, + "revert": { + "changelog": { + "hidden": true, + "title": "⏪ Revert", + }, + "semverBump": "none", + }, + "style": { + "changelog": { + "hidden": true, + "title": "🎨 Styles", + }, + "semverBump": "none", + }, + "test": { + "changelog": { + "hidden": true, + "title": "✅ Tests", + }, + "semverBump": "none", + }, + "types": { + "changelog": { + "hidden": true, + "title": "🌊 Types", + }, + "semverBump": "none", }, - "semverBump": "none", }, }, - }, - "git": { - "commit": false, - "commitArgs": "", - "commitMessage": "chore(release): publish {version}", - "stageChanges": false, - "tag": false, - "tagArgs": "", - "tagMessage": "", - }, - "groups": { - "__default__": { - "changelog": false, - "projects": [ - "lib-a", - "lib-b", - "nx", - ], - "projectsRelationship": "fixed", - "releaseTagPattern": "v{version}", - "version": { - "conventionalCommits": false, - "generator": "@nx/js:release-version", - "generatorOptions": {}, - }, - }, - }, - "projectsRelationship": "fixed", - "releaseTagPattern": "v{version}", - "version": { - "conventionalCommits": false, - "generator": "@nx/js:release-version", - "generatorOptions": {}, "git": { "commit": false, "commitArgs": "", "commitMessage": "chore(release): publish {version}", - "stageChanges": true, + "stageChanges": false, "tag": false, "tagArgs": "", "tagMessage": "", }, - "preVersionCommand": "", + "groups": { + "__default__": { + "changelog": false, + "projects": [ + "lib-a", + "lib-b", + "nx", + ], + "projectsRelationship": "fixed", + "releaseTagPattern": "v{version}", + "version": { + "conventionalCommits": false, + "generator": "@nx/js:release-version", + "generatorOptions": {}, + }, + "versionPlans": false, + }, + }, + "projectsRelationship": "fixed", + "releaseTagPattern": "v{version}", + "version": { + "conventionalCommits": false, + "generator": "@nx/js:release-version", + "generatorOptions": {}, + "git": { + "commit": false, + "commitArgs": "", + "commitMessage": "chore(release): publish {version}", + "stageChanges": true, + "tag": false, + "tagArgs": "", + "tagMessage": "", + }, + "preVersionCommand": "", + }, + "versionPlans": false, }, - }, - } + } `); }); }); @@ -6131,6 +6195,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, "group-2": { "changelog": false, @@ -6144,6 +6209,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, "group-3": { "changelog": { @@ -6167,6 +6233,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -6186,6 +6253,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -6375,6 +6443,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -6394,6 +6463,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -6745,6 +6815,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, "group-2": { "changelog": false, @@ -6759,6 +6830,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "independent", @@ -6778,6 +6850,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -6925,6 +6998,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "independent", @@ -6944,6 +7018,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -7110,6 +7185,7 @@ describe('createNxReleaseConfig()', () => { "specifierSource": "conventional-commits", }, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -7132,6 +7208,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -7294,6 +7371,7 @@ describe('createNxReleaseConfig()', () => { "specifierSource": "prompt", }, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -7316,6 +7394,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -7477,6 +7556,7 @@ describe('createNxReleaseConfig()', () => { "specifierSource": "conventional-commits", }, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -7499,6 +7579,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -7663,6 +7744,7 @@ describe('createNxReleaseConfig()', () => { "generator": "@nx/js:release-version", "generatorOptions": {}, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -7685,6 +7767,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -7850,6 +7933,7 @@ describe('createNxReleaseConfig()', () => { "specifierSource": "conventional-commits", }, }, + "versionPlans": false, }, }, "projectsRelationship": "fixed", @@ -7873,6 +7957,7 @@ describe('createNxReleaseConfig()', () => { }, "preVersionCommand": "", }, + "versionPlans": false, }, } `); @@ -7916,4 +8001,572 @@ describe('createNxReleaseConfig()', () => { `); }); }); + + describe('versionPlans shorthand', () => { + it('should respect user "versionPlans" set at root level', async () => { + const res = await createNxReleaseConfig(projectGraph, projectFileMap, { + versionPlans: true, + }); + + expect(res).toMatchInlineSnapshot(` + { + "error": null, + "nxReleaseConfig": { + "changelog": { + "automaticFromRef": false, + "git": { + "commit": true, + "commitArgs": "", + "commitMessage": "chore(release): publish {version}", + "stageChanges": false, + "tag": true, + "tagArgs": "", + "tagMessage": "", + }, + "projectChangelogs": false, + "workspaceChangelog": { + "createRelease": false, + "entryWhenNoChanges": "This was a version bump only, there were no code changes.", + "file": "{workspaceRoot}/CHANGELOG.md", + "renderOptions": { + "authors": true, + "commitReferences": true, + "versionTitleDate": true, + }, + "renderer": "/release/changelog-renderer", + }, + }, + "conventionalCommits": { + "types": { + "build": { + "changelog": { + "hidden": true, + "title": "📦 Build", + }, + "semverBump": "none", + }, + "chore": { + "changelog": { + "hidden": true, + "title": "🏡 Chore", + }, + "semverBump": "none", + }, + "ci": { + "changelog": { + "hidden": true, + "title": "🤖 CI", + }, + "semverBump": "none", + }, + "docs": { + "changelog": { + "hidden": true, + "title": "📖 Documentation", + }, + "semverBump": "none", + }, + "examples": { + "changelog": { + "hidden": true, + "title": "🏀 Examples", + }, + "semverBump": "none", + }, + "feat": { + "changelog": { + "hidden": false, + "title": "🚀 Features", + }, + "semverBump": "minor", + }, + "fix": { + "changelog": { + "hidden": false, + "title": "🩹 Fixes", + }, + "semverBump": "patch", + }, + "perf": { + "changelog": { + "hidden": false, + "title": "🔥 Performance", + }, + "semverBump": "none", + }, + "refactor": { + "changelog": { + "hidden": true, + "title": "💅 Refactors", + }, + "semverBump": "none", + }, + "revert": { + "changelog": { + "hidden": true, + "title": "⏪ Revert", + }, + "semverBump": "none", + }, + "style": { + "changelog": { + "hidden": true, + "title": "🎨 Styles", + }, + "semverBump": "none", + }, + "test": { + "changelog": { + "hidden": true, + "title": "✅ Tests", + }, + "semverBump": "none", + }, + "types": { + "changelog": { + "hidden": true, + "title": "🌊 Types", + }, + "semverBump": "none", + }, + }, + }, + "git": { + "commit": false, + "commitArgs": "", + "commitMessage": "chore(release): publish {version}", + "stageChanges": false, + "tag": false, + "tagArgs": "", + "tagMessage": "", + }, + "groups": { + "__default__": { + "changelog": false, + "projects": [ + "lib-a", + "lib-b", + "nx", + ], + "projectsRelationship": "fixed", + "releaseTagPattern": "v{version}", + "version": { + "conventionalCommits": false, + "generator": "@nx/js:release-version", + "generatorOptions": { + "specifierSource": "version-plans", + }, + }, + "versionPlans": true, + }, + }, + "projectsRelationship": "fixed", + "releaseTagPattern": "v{version}", + "version": { + "conventionalCommits": false, + "generator": "@nx/js:release-version", + "generatorOptions": { + "specifierSource": "version-plans", + }, + "git": { + "commit": false, + "commitArgs": "", + "commitMessage": "chore(release): publish {version}", + "stageChanges": true, + "tag": false, + "tagArgs": "", + "tagMessage": "", + }, + "preVersionCommand": "", + }, + "versionPlans": true, + }, + } + `); + }); + + it('should respect user "versionPlans" set at group level', async () => { + const res = await createNxReleaseConfig(projectGraph, projectFileMap, { + groups: { + 'group-1': { + projects: 'nx', + versionPlans: true, + }, + 'group-2': { + projects: 'lib-a', + versionPlans: false, + }, + }, + }); + + expect(res).toMatchInlineSnapshot(` + { + "error": null, + "nxReleaseConfig": { + "changelog": { + "automaticFromRef": false, + "git": { + "commit": true, + "commitArgs": "", + "commitMessage": "chore(release): publish {version}", + "stageChanges": false, + "tag": true, + "tagArgs": "", + "tagMessage": "", + }, + "projectChangelogs": false, + "workspaceChangelog": false, + }, + "conventionalCommits": { + "types": { + "build": { + "changelog": { + "hidden": true, + "title": "📦 Build", + }, + "semverBump": "none", + }, + "chore": { + "changelog": { + "hidden": true, + "title": "🏡 Chore", + }, + "semverBump": "none", + }, + "ci": { + "changelog": { + "hidden": true, + "title": "🤖 CI", + }, + "semverBump": "none", + }, + "docs": { + "changelog": { + "hidden": true, + "title": "📖 Documentation", + }, + "semverBump": "none", + }, + "examples": { + "changelog": { + "hidden": true, + "title": "🏀 Examples", + }, + "semverBump": "none", + }, + "feat": { + "changelog": { + "hidden": false, + "title": "🚀 Features", + }, + "semverBump": "minor", + }, + "fix": { + "changelog": { + "hidden": false, + "title": "🩹 Fixes", + }, + "semverBump": "patch", + }, + "perf": { + "changelog": { + "hidden": false, + "title": "🔥 Performance", + }, + "semverBump": "none", + }, + "refactor": { + "changelog": { + "hidden": true, + "title": "💅 Refactors", + }, + "semverBump": "none", + }, + "revert": { + "changelog": { + "hidden": true, + "title": "⏪ Revert", + }, + "semverBump": "none", + }, + "style": { + "changelog": { + "hidden": true, + "title": "🎨 Styles", + }, + "semverBump": "none", + }, + "test": { + "changelog": { + "hidden": true, + "title": "✅ Tests", + }, + "semverBump": "none", + }, + "types": { + "changelog": { + "hidden": true, + "title": "🌊 Types", + }, + "semverBump": "none", + }, + }, + }, + "git": { + "commit": false, + "commitArgs": "", + "commitMessage": "chore(release): publish {version}", + "stageChanges": false, + "tag": false, + "tagArgs": "", + "tagMessage": "", + }, + "groups": { + "group-1": { + "changelog": false, + "projects": [ + "nx", + ], + "projectsRelationship": "fixed", + "releaseTagPattern": "v{version}", + "version": { + "conventionalCommits": false, + "generator": "@nx/js:release-version", + "generatorOptions": { + "specifierSource": "version-plans", + }, + }, + "versionPlans": true, + }, + "group-2": { + "changelog": false, + "projects": [ + "lib-a", + ], + "projectsRelationship": "fixed", + "releaseTagPattern": "v{version}", + "version": { + "conventionalCommits": false, + "generator": "@nx/js:release-version", + "generatorOptions": {}, + }, + "versionPlans": false, + }, + }, + "projectsRelationship": "fixed", + "releaseTagPattern": "v{version}", + "version": { + "conventionalCommits": false, + "generator": "@nx/js:release-version", + "generatorOptions": {}, + "git": { + "commit": false, + "commitArgs": "", + "commitMessage": "chore(release): publish {version}", + "stageChanges": true, + "tag": false, + "tagArgs": "", + "tagMessage": "", + }, + "preVersionCommand": "", + }, + "versionPlans": false, + }, + } + `); + }); + + it('should override "versionPlans" with false when set at the group level', async () => { + const res = await createNxReleaseConfig(projectGraph, projectFileMap, { + versionPlans: true, + groups: { + 'group-1': { + projects: 'nx', + versionPlans: false, + }, + 'group-2': { + projects: 'lib-a', + }, + }, + }); + + expect(res).toMatchInlineSnapshot(` + { + "error": null, + "nxReleaseConfig": { + "changelog": { + "automaticFromRef": false, + "git": { + "commit": true, + "commitArgs": "", + "commitMessage": "chore(release): publish {version}", + "stageChanges": false, + "tag": true, + "tagArgs": "", + "tagMessage": "", + }, + "projectChangelogs": false, + "workspaceChangelog": false, + }, + "conventionalCommits": { + "types": { + "build": { + "changelog": { + "hidden": true, + "title": "📦 Build", + }, + "semverBump": "none", + }, + "chore": { + "changelog": { + "hidden": true, + "title": "🏡 Chore", + }, + "semverBump": "none", + }, + "ci": { + "changelog": { + "hidden": true, + "title": "🤖 CI", + }, + "semverBump": "none", + }, + "docs": { + "changelog": { + "hidden": true, + "title": "📖 Documentation", + }, + "semverBump": "none", + }, + "examples": { + "changelog": { + "hidden": true, + "title": "🏀 Examples", + }, + "semverBump": "none", + }, + "feat": { + "changelog": { + "hidden": false, + "title": "🚀 Features", + }, + "semverBump": "minor", + }, + "fix": { + "changelog": { + "hidden": false, + "title": "🩹 Fixes", + }, + "semverBump": "patch", + }, + "perf": { + "changelog": { + "hidden": false, + "title": "🔥 Performance", + }, + "semverBump": "none", + }, + "refactor": { + "changelog": { + "hidden": true, + "title": "💅 Refactors", + }, + "semverBump": "none", + }, + "revert": { + "changelog": { + "hidden": true, + "title": "⏪ Revert", + }, + "semverBump": "none", + }, + "style": { + "changelog": { + "hidden": true, + "title": "🎨 Styles", + }, + "semverBump": "none", + }, + "test": { + "changelog": { + "hidden": true, + "title": "✅ Tests", + }, + "semverBump": "none", + }, + "types": { + "changelog": { + "hidden": true, + "title": "🌊 Types", + }, + "semverBump": "none", + }, + }, + }, + "git": { + "commit": false, + "commitArgs": "", + "commitMessage": "chore(release): publish {version}", + "stageChanges": false, + "tag": false, + "tagArgs": "", + "tagMessage": "", + }, + "groups": { + "group-1": { + "changelog": false, + "projects": [ + "nx", + ], + "projectsRelationship": "fixed", + "releaseTagPattern": "v{version}", + "version": { + "conventionalCommits": false, + "generator": "@nx/js:release-version", + "generatorOptions": {}, + }, + "versionPlans": false, + }, + "group-2": { + "changelog": false, + "projects": [ + "lib-a", + ], + "projectsRelationship": "fixed", + "releaseTagPattern": "v{version}", + "version": { + "conventionalCommits": false, + "generator": "@nx/js:release-version", + "generatorOptions": { + "specifierSource": "version-plans", + }, + }, + "versionPlans": true, + }, + }, + "projectsRelationship": "fixed", + "releaseTagPattern": "v{version}", + "version": { + "conventionalCommits": false, + "generator": "@nx/js:release-version", + "generatorOptions": { + "specifierSource": "version-plans", + }, + "git": { + "commit": false, + "commitArgs": "", + "commitMessage": "chore(release): publish {version}", + "stageChanges": true, + "tag": false, + "tagArgs": "", + "tagMessage": "", + }, + "preVersionCommand": "", + }, + "versionPlans": true, + }, + } + `); + }); + }); }); diff --git a/packages/nx/src/command-line/release/config/config.ts b/packages/nx/src/command-line/release/config/config.ts index 6ec7ebd362..a4ca54e149 100644 --- a/packages/nx/src/command-line/release/config/config.ts +++ b/packages/nx/src/command-line/release/config/config.ts @@ -178,12 +178,18 @@ export async function createNxReleaseConfig( const workspaceProjectsRelationship = userConfig.projectsRelationship || 'fixed'; - const defaultGeneratorOptions = userConfig.version?.conventionalCommits - ? { - currentVersionResolver: 'git-tag', - specifierSource: 'conventional-commits', - } - : {}; + const defaultGeneratorOptions: { + currentVersionResolver?: string; + specifierSource?: string; + } = {}; + + if (userConfig.version?.conventionalCommits) { + defaultGeneratorOptions.currentVersionResolver = 'git-tag'; + defaultGeneratorOptions.specifierSource = 'conventional-commits'; + } + if (userConfig.versionPlans) { + defaultGeneratorOptions.specifierSource = 'version-plans'; + } const userGroups = Object.values(userConfig.groups ?? {}); const disableWorkspaceChangelog = @@ -248,6 +254,7 @@ export async function createNxReleaseConfig( ? defaultIndependentReleaseTagPattern : defaultFixedReleaseTagPattern), conventionalCommits: DEFAULT_CONVENTIONAL_COMMITS_CONFIG, + versionPlans: false, }; const groupProjectsRelationship = @@ -277,6 +284,7 @@ export async function createNxReleaseConfig( groupProjectsRelationship === 'independent' ? defaultIndependentReleaseTagPattern : WORKSPACE_DEFAULTS.releaseTagPattern, + versionPlans: false, }; /** @@ -324,6 +332,9 @@ export async function createNxReleaseConfig( > ); + const rootVersionPlansConfig: NxReleaseConfig['versionPlans'] = + userConfig.versionPlans ?? WORKSPACE_DEFAULTS.versionPlans; + const rootConventionalCommitsConfig: NxReleaseConfig['conventionalCommits'] = deepMergeDefaults( [WORKSPACE_DEFAULTS.conventionalCommits], @@ -353,6 +364,17 @@ export async function createNxReleaseConfig( delete rootVersionWithoutGlobalOptions.generatorOptions.specifierSource; } + // Apply versionPlans shorthand to the final group defaults if explicitly configured in the original user config + if (userConfig.versionPlans) { + rootVersionWithoutGlobalOptions.generatorOptions = { + ...rootVersionWithoutGlobalOptions.generatorOptions, + specifierSource: 'version-plans', + }; + } + if (userConfig.versionPlans === false) { + delete rootVersionWithoutGlobalOptions.generatorOptions.specifierSource; + } + const groups: NxReleaseConfig['groups'] = userConfig.groups && Object.keys(userConfig.groups).length ? ensureProjectsConfigIsArray(userConfig.groups) @@ -361,7 +383,7 @@ export async function createNxReleaseConfig( * as being in one release group together in which the projects are released in lock step. */ { - [IMPLICIT_DEFAULT_RELEASE_GROUP]: { + [IMPLICIT_DEFAULT_RELEASE_GROUP]: { projectsRelationship: GROUP_DEFAULTS.projectsRelationship, projects: userConfig.projects ? // user-defined top level "projects" config takes priority if set @@ -385,6 +407,7 @@ export async function createNxReleaseConfig( userConfig.releaseTagPattern || GROUP_DEFAULTS.releaseTagPattern, // Directly inherit the root level config for projectChangelogs, if set changelog: rootChangelogConfig.projectChangelogs || false, + versionPlans: rootVersionPlansConfig || GROUP_DEFAULTS.versionPlans, }, }; @@ -482,6 +505,7 @@ export async function createNxReleaseConfig( (projectsRelationship === 'independent' ? defaultIndependentReleaseTagPattern : userConfig.releaseTagPattern || defaultFixedReleaseTagPattern), + versionPlans: releaseGroup.versionPlans ?? rootVersionPlansConfig, }; const finalReleaseGroup = deepMergeDefaults([groupDefaults], { @@ -506,6 +530,23 @@ export async function createNxReleaseConfig( delete finalReleaseGroup.version.generatorOptions.specifierSource; } + // Apply versionPlans shorthand to the final group if explicitly configured in the original group + if (releaseGroup.versionPlans) { + finalReleaseGroup.version = { + ...finalReleaseGroup.version, + generatorOptions: { + ...finalReleaseGroup.version?.generatorOptions, + specifierSource: 'version-plans', + }, + }; + } + if ( + releaseGroup.versionPlans === false && + releaseGroupName !== IMPLICIT_DEFAULT_RELEASE_GROUP + ) { + delete finalReleaseGroup.version.generatorOptions.specifierSource; + } + releaseGroups[releaseGroupName] = finalReleaseGroup; } @@ -521,6 +562,7 @@ export async function createNxReleaseConfig( changelog: rootChangelogConfig, groups: releaseGroups, conventionalCommits: rootConventionalCommitsConfig, + versionPlans: rootVersionPlansConfig, }, }; } 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 741ec308a0..26d238b6da 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 @@ -51,6 +51,7 @@ describe('filterReleaseGroups()', () => { stageChanges: false, }, conventionalCommits: DEFAULT_CONVENTIONAL_COMMITS_CONFIG, + versionPlans: false, }; projectGraph = { nodes: { @@ -103,6 +104,7 @@ describe('filterReleaseGroups()', () => { generatorOptions: {}, }, releaseTagPattern: '', + versionPlans: false, }, }; const { error } = filterReleaseGroups(projectGraph, nxReleaseConfig, [ @@ -130,6 +132,7 @@ describe('filterReleaseGroups()', () => { generatorOptions: {}, }, releaseTagPattern: '', + versionPlans: false, }, bar: { projectsRelationship: 'fixed', @@ -141,6 +144,7 @@ describe('filterReleaseGroups()', () => { generatorOptions: {}, }, releaseTagPattern: '', + versionPlans: false, }, }; const { error, releaseGroups, releaseGroupToFilteredProjects } = @@ -161,6 +165,7 @@ describe('filterReleaseGroups()', () => { "generator": "", "generatorOptions": {}, }, + "versionPlans": false, }, { "changelog": false, @@ -175,6 +180,7 @@ describe('filterReleaseGroups()', () => { "generator": "", "generatorOptions": {}, }, + "versionPlans": false, }, ] `); @@ -193,6 +199,7 @@ describe('filterReleaseGroups()', () => { "generator": "", "generatorOptions": {}, }, + "versionPlans": false, } => Set { "lib-a", }, @@ -209,6 +216,7 @@ describe('filterReleaseGroups()', () => { "generator": "", "generatorOptions": {}, }, + "versionPlans": false, } => Set { "lib-b", }, @@ -228,6 +236,7 @@ describe('filterReleaseGroups()', () => { generatorOptions: {}, }, releaseTagPattern: '', + versionPlans: false, }, }; const { error } = filterReleaseGroups(projectGraph, nxReleaseConfig, [ @@ -254,6 +263,7 @@ describe('filterReleaseGroups()', () => { generatorOptions: {}, }, releaseTagPattern: '', + versionPlans: false, }, bar: { projectsRelationship: 'independent', @@ -265,6 +275,7 @@ describe('filterReleaseGroups()', () => { generatorOptions: {}, }, releaseTagPattern: '', + versionPlans: false, }, }; const { error } = filterReleaseGroups(projectGraph, nxReleaseConfig, [ @@ -292,6 +303,7 @@ describe('filterReleaseGroups()', () => { generatorOptions: {}, }, releaseTagPattern: '', + versionPlans: false, }, bar: { projectsRelationship: 'fixed', @@ -303,6 +315,7 @@ describe('filterReleaseGroups()', () => { generatorOptions: {}, }, releaseTagPattern: '', + versionPlans: false, }, }; const { error, releaseGroups, releaseGroupToFilteredProjects } = @@ -323,6 +336,7 @@ describe('filterReleaseGroups()', () => { "generator": "", "generatorOptions": {}, }, + "versionPlans": false, }, ] `); @@ -341,6 +355,7 @@ describe('filterReleaseGroups()', () => { "generator": "", "generatorOptions": {}, }, + "versionPlans": false, } => Set { "lib-a", }, @@ -376,6 +391,7 @@ describe('filterReleaseGroups()', () => { generatorOptions: {}, }, releaseTagPattern: '', + versionPlans: false, }, bar: { projectsRelationship: 'fixed', @@ -387,6 +403,7 @@ describe('filterReleaseGroups()', () => { generatorOptions: {}, }, releaseTagPattern: '', + versionPlans: false, }, }; const { error, releaseGroups, releaseGroupToFilteredProjects } = @@ -407,6 +424,7 @@ describe('filterReleaseGroups()', () => { "generator": "", "generatorOptions": {}, }, + "versionPlans": false, }, ] `); @@ -425,6 +443,7 @@ describe('filterReleaseGroups()', () => { "generator": "", "generatorOptions": {}, }, + "versionPlans": false, } => Set { "lib-a", }, diff --git a/packages/nx/src/command-line/release/config/filter-release-groups.ts b/packages/nx/src/command-line/release/config/filter-release-groups.ts index 4cb92b6667..d7eca95f6b 100644 --- a/packages/nx/src/command-line/release/config/filter-release-groups.ts +++ b/packages/nx/src/command-line/release/config/filter-release-groups.ts @@ -2,9 +2,14 @@ import { ProjectGraph } from '../../../config/project-graph'; import { findMatchingProjects } from '../../../utils/find-matching-projects'; import { output } from '../../../utils/output'; import { IMPLICIT_DEFAULT_RELEASE_GROUP, NxReleaseConfig } from './config'; +import { GroupVersionPlan, ProjectsVersionPlan } from './version-plans'; -export type ReleaseGroupWithName = NxReleaseConfig['groups'][string] & { +export type ReleaseGroupWithName = Omit< + NxReleaseConfig['groups'][string], + 'versionPlans' +> & { name: string; + versionPlans: (ProjectsVersionPlan | GroupVersionPlan)[] | false; }; export function filterReleaseGroups( @@ -23,6 +28,7 @@ export function filterReleaseGroups( return { ...group, name, + versionPlans: group.versionPlans ? [] : false, }; }); diff --git a/packages/nx/src/command-line/release/config/test-files/version-plan-1.md b/packages/nx/src/command-line/release/config/test-files/version-plan-1.md new file mode 100644 index 0000000000..76d4b68ef1 --- /dev/null +++ b/packages/nx/src/command-line/release/config/test-files/version-plan-1.md @@ -0,0 +1,5 @@ +--- +pkg1: patch +--- + +This is a change to just package 1 diff --git a/packages/nx/src/command-line/release/config/test-files/version-plan-2.md b/packages/nx/src/command-line/release/config/test-files/version-plan-2.md new file mode 100644 index 0000000000..8b5a933f62 --- /dev/null +++ b/packages/nx/src/command-line/release/config/test-files/version-plan-2.md @@ -0,0 +1,6 @@ +--- +pkg1: minor +pkg2: patch +--- + +This is a change to package 1 and package 2 diff --git a/packages/nx/src/command-line/release/config/test-files/version-plan-3.md b/packages/nx/src/command-line/release/config/test-files/version-plan-3.md new file mode 100644 index 0000000000..4d5a4c9ed4 --- /dev/null +++ b/packages/nx/src/command-line/release/config/test-files/version-plan-3.md @@ -0,0 +1,6 @@ +--- +pkg3: major +pkg4: minor +--- + +This is a change to packages 3 and 4 diff --git a/packages/nx/src/command-line/release/config/test-files/version-plan-4.md b/packages/nx/src/command-line/release/config/test-files/version-plan-4.md new file mode 100644 index 0000000000..6458dd09c6 --- /dev/null +++ b/packages/nx/src/command-line/release/config/test-files/version-plan-4.md @@ -0,0 +1,8 @@ +--- +pkg3: patch +pkg4: minor +pkg5: prerelease +pkg6: preminor +--- + +This is a change to packages 3, 4, 5, and 6 diff --git a/packages/nx/src/command-line/release/config/test-files/version-plan-5.md b/packages/nx/src/command-line/release/config/test-files/version-plan-5.md new file mode 100644 index 0000000000..80f883d214 --- /dev/null +++ b/packages/nx/src/command-line/release/config/test-files/version-plan-5.md @@ -0,0 +1,5 @@ +--- +fixed-group-1: minor +--- + +This is a change to fixed-group-1 diff --git a/packages/nx/src/command-line/release/config/test-files/version-plan-6.md b/packages/nx/src/command-line/release/config/test-files/version-plan-6.md new file mode 100644 index 0000000000..d1cf77c494 --- /dev/null +++ b/packages/nx/src/command-line/release/config/test-files/version-plan-6.md @@ -0,0 +1,7 @@ +--- +fixed-group-1: major +fixed-group-2: minor +pkg3: major +--- + +This is a major change to fixed-group-1 and pkg3 and a minor change to fixed-group-2 diff --git a/packages/nx/src/command-line/release/config/version-plans.spec.ts b/packages/nx/src/command-line/release/config/version-plans.spec.ts new file mode 100644 index 0000000000..684dd53219 --- /dev/null +++ b/packages/nx/src/command-line/release/config/version-plans.spec.ts @@ -0,0 +1,1127 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { TempFs } from '../../../internal-testing-utils/temp-fs'; +import { IMPLICIT_DEFAULT_RELEASE_GROUP } from './config'; +import { ReleaseGroupWithName } from './filter-release-groups'; +import { + RawVersionPlan, + readRawVersionPlans, + setVersionPlansOnGroups, +} from './version-plans'; + +expect.addSnapshotSerializer({ + serialize(str: string) { + // regex to replace all but the relative path of + return str.replaceAll( + /(\/.*\/\.nx\/)(version-plans\/.*\.md)/g, + '/$2' + ); + }, + test(val: string) { + return val != null && typeof val === 'string'; + }, +}); + +describe('version-plans', () => { + let tempFs: TempFs; + + beforeAll(async () => { + tempFs = new TempFs('parse-version-plans'); + + await tempFs.createFiles({ + '.nx/version-plans/plan1.md': readFileSync( + join(__dirname, 'test-files/version-plan-1.md'), + 'utf-8' + ), + '.nx/version-plans/plan2.md': readFileSync( + join(__dirname, 'test-files/version-plan-2.md'), + 'utf-8' + ), + '.nx/version-plans/plan3.md': readFileSync( + join(__dirname, 'test-files/version-plan-3.md'), + 'utf-8' + ), + '.nx/version-plans/plan4.md': readFileSync( + join(__dirname, 'test-files/version-plan-4.md'), + 'utf-8' + ), + '.nx/version-plans/plan5.md': readFileSync( + join(__dirname, 'test-files/version-plan-5.md'), + 'utf-8' + ), + '.nx/version-plans/plan6.md': readFileSync( + join(__dirname, 'test-files/version-plan-6.md'), + 'utf-8' + ), + }); + }); + + afterAll(() => { + tempFs.cleanup(); + }); + + describe('readRawVersionPlans', () => { + it('should parse all version plan files into raw version plan objects', async () => { + const result = await readRawVersionPlans(); + result.forEach((r) => { + expect(typeof r.createdOnMs).toBe('number'); + delete r.createdOnMs; + }); + expect(result).toMatchInlineSnapshot(` + [ + { + absolutePath: /version-plans/plan1.md, + content: { + pkg1: patch, + }, + fileName: plan1.md, + message: This is a change to just package 1, + relativePath: .nx/version-plans/plan1.md, + }, + { + absolutePath: /version-plans/plan2.md, + content: { + pkg1: minor, + pkg2: patch, + }, + fileName: plan2.md, + message: This is a change to package 1 and package 2, + relativePath: .nx/version-plans/plan2.md, + }, + { + absolutePath: /version-plans/plan3.md, + content: { + pkg3: major, + pkg4: minor, + }, + fileName: plan3.md, + message: This is a change to packages 3 and 4, + relativePath: .nx/version-plans/plan3.md, + }, + { + absolutePath: /version-plans/plan4.md, + content: { + pkg3: patch, + pkg4: minor, + pkg5: prerelease, + pkg6: preminor, + }, + fileName: plan4.md, + message: This is a change to packages 3, 4, 5, and 6, + relativePath: .nx/version-plans/plan4.md, + }, + { + absolutePath: /version-plans/plan5.md, + content: { + fixed-group-1: minor, + }, + fileName: plan5.md, + message: This is a change to fixed-group-1, + relativePath: .nx/version-plans/plan5.md, + }, + { + absolutePath: /version-plans/plan6.md, + content: { + fixed-group-1: major, + fixed-group-2: minor, + pkg3: major, + }, + fileName: plan6.md, + message: This is a major change to fixed-group-1 and pkg3 and a minor change to fixed-group-2, + relativePath: .nx/version-plans/plan6.md, + }, + ] + `); + }); + }); + + describe('setVersionPlansOnGroups', () => { + describe('error cases', () => { + describe('for default group', () => { + describe('when bump "key" is a group name', () => { + it('should error if version plans are not enabled', () => { + const rawVersionPlans: RawVersionPlan[] = [ + versionPlan({ + name: 'plan1.md', + content: { + // This shouldn't really ever happen, + // but if it does, we're ready for it + [IMPLICIT_DEFAULT_RELEASE_GROUP]: 'patch', + }, + message: 'plan1 message', + }), + ]; + const releaseGroups: ReleaseGroupWithName[] = [ + releaseGroup({ + name: IMPLICIT_DEFAULT_RELEASE_GROUP, + projects: [], + versionPlans: false, + projectsRelationship: 'fixed', + }), + ]; + const allProjectNamesInWorkspace: string[] = ['pkg1']; + + expect(() => + setVersionPlansOnGroups( + rawVersionPlans, + releaseGroups, + allProjectNamesInWorkspace + ) + ).toThrowErrorMatchingInlineSnapshot( + `Found a version bump in 'plan1.md' but version plans are not enabled.` + ); + }); + + it('should error if group is independently versioned', () => { + const rawVersionPlans: RawVersionPlan[] = [ + versionPlan({ + name: 'plan1.md', + content: { + [IMPLICIT_DEFAULT_RELEASE_GROUP]: 'patch', + }, + message: 'plan1 message', + }), + ]; + const releaseGroups: ReleaseGroupWithName[] = [ + releaseGroup({ + name: IMPLICIT_DEFAULT_RELEASE_GROUP, + projects: ['pkg1'], + versionPlans: [], + projectsRelationship: 'independent', + }), + ]; + const allProjectNamesInWorkspace: string[] = ['pkg1']; + + expect(() => + setVersionPlansOnGroups( + rawVersionPlans, + releaseGroups, + allProjectNamesInWorkspace + ) + ).toThrowErrorMatchingInlineSnapshot( + `Found a version bump in 'plan1.md' but projects are configured to be independently versioned. Individual projects should be bumped instead.` + ); + }); + + it('should error if bump "value" is not a release type', () => { + const rawVersionPlans: RawVersionPlan[] = [ + versionPlan({ + name: 'plan1.md', + content: { + [IMPLICIT_DEFAULT_RELEASE_GROUP]: 'not-a-release-type', + }, + message: 'plan1 message', + }), + ]; + const releaseGroups: ReleaseGroupWithName[] = [ + releaseGroup({ + name: IMPLICIT_DEFAULT_RELEASE_GROUP, + projects: ['pkg1'], + versionPlans: [], + projectsRelationship: 'fixed', + }), + ]; + const allProjectNamesInWorkspace: string[] = ['pkg1']; + + expect(() => + setVersionPlansOnGroups( + rawVersionPlans, + releaseGroups, + allProjectNamesInWorkspace + ) + ).toThrowErrorMatchingInlineSnapshot( + `Found a version bump in 'plan1.md' with an invalid release type. Please specify one of major, premajor, minor, preminor, patch, prepatch, prerelease.` + ); + }); + + it('should error if fixed default group has two different entries with different bump types', () => { + const rawVersionPlans: RawVersionPlan[] = [ + versionPlan({ + name: 'plan1.md', + content: { + pkg1: 'minor', + [IMPLICIT_DEFAULT_RELEASE_GROUP]: 'patch', + }, + message: 'plan1 message', + }), + ]; + const releaseGroups: ReleaseGroupWithName[] = [ + releaseGroup({ + name: IMPLICIT_DEFAULT_RELEASE_GROUP, + projects: ['pkg1'], + versionPlans: [], + projectsRelationship: 'fixed', + }), + ]; + const allProjectNamesInWorkspace: string[] = ['pkg1']; + + expect(() => + setVersionPlansOnGroups( + rawVersionPlans, + releaseGroups, + allProjectNamesInWorkspace + ) + ).toThrowErrorMatchingInlineSnapshot( + `Found a version bump in 'plan1.md' that conflicts with another version bump. When in fixed versioning mode, all version bumps must match.` + ); + }); + }); + + describe('when bump "key" is a project name', () => { + it('should error if project does not exist in the workspace', () => { + const rawVersionPlans: RawVersionPlan[] = [ + versionPlan({ + name: 'plan1.md', + content: { + nonExistentPkg: 'patch', + }, + message: 'plan1 message', + }), + ]; + const releaseGroups: ReleaseGroupWithName[] = [ + releaseGroup({ + name: IMPLICIT_DEFAULT_RELEASE_GROUP, + projects: ['pkg1'], + versionPlans: [], + projectsRelationship: 'fixed', + }), + ]; + const allProjectNamesInWorkspace: string[] = []; + + expect(() => + setVersionPlansOnGroups( + rawVersionPlans, + releaseGroups, + allProjectNamesInWorkspace + ) + ).toThrowErrorMatchingInlineSnapshot( + `Found a version bump for project 'nonExistentPkg' in 'plan1.md' but the project does not exist in the workspace.` + ); + }); + + it('should error if version plans are not enabled', () => { + const rawVersionPlans: RawVersionPlan[] = [ + versionPlan({ + name: 'plan1.md', + content: { + pkg1: 'patch', + }, + message: 'plan1 message', + }), + ]; + const releaseGroups: ReleaseGroupWithName[] = [ + releaseGroup({ + name: IMPLICIT_DEFAULT_RELEASE_GROUP, + projects: ['pkg1'], + versionPlans: false, + projectsRelationship: 'fixed', + }), + ]; + const allProjectNamesInWorkspace: string[] = ['pkg1']; + + expect(() => + setVersionPlansOnGroups( + rawVersionPlans, + releaseGroups, + allProjectNamesInWorkspace + ) + ).toThrowErrorMatchingInlineSnapshot( + `Found a version bump for project 'pkg1' in 'plan1.md' but version plans are not enabled.` + ); + }); + + it('should error if project is not included in the default release group', () => { + const rawVersionPlans: RawVersionPlan[] = [ + versionPlan({ + name: 'plan1.md', + content: { + pkg2: 'patch', + }, + message: 'plan1 message', + }), + ]; + const releaseGroups: ReleaseGroupWithName[] = [ + releaseGroup({ + name: IMPLICIT_DEFAULT_RELEASE_GROUP, + projects: ['pkg1'], + versionPlans: [], + projectsRelationship: 'fixed', + }), + ]; + const allProjectNamesInWorkspace: string[] = ['pkg1', 'pkg2']; + + expect(() => + setVersionPlansOnGroups( + rawVersionPlans, + releaseGroups, + allProjectNamesInWorkspace + ) + ).toThrowErrorMatchingInlineSnapshot( + `Found a version bump for project 'pkg2' in 'plan1.md' but the project is not configured for release. Ensure it is included by the 'release.projects' globs in nx.json.` + ); + }); + + it(`should error if project's bump "value" is not a release type`, () => { + const rawVersionPlans: RawVersionPlan[] = [ + versionPlan({ + name: 'plan1.md', + content: { + pkg1: 'not-a-release-type', + }, + message: 'plan1 message', + }), + ]; + const releaseGroups: ReleaseGroupWithName[] = [ + releaseGroup({ + name: IMPLICIT_DEFAULT_RELEASE_GROUP, + projects: ['pkg1'], + versionPlans: [], + projectsRelationship: 'fixed', + }), + ]; + const allProjectNamesInWorkspace: string[] = ['pkg1']; + + expect(() => + setVersionPlansOnGroups( + rawVersionPlans, + releaseGroups, + allProjectNamesInWorkspace + ) + ).toThrowErrorMatchingInlineSnapshot( + `Found a version bump for project 'pkg1' in 'plan1.md' with an invalid release type. Please specify one of major, premajor, minor, preminor, patch, prepatch, prerelease.` + ); + }); + + it('should error if the fixed default group has two different projects with different bump types', () => { + const rawVersionPlans: RawVersionPlan[] = [ + versionPlan({ + name: 'plan1.md', + content: { + pkg1: 'patch', + pkg2: 'minor', + }, + message: 'plan1 message', + }), + ]; + const releaseGroups: ReleaseGroupWithName[] = [ + releaseGroup({ + name: IMPLICIT_DEFAULT_RELEASE_GROUP, + projects: ['pkg1', 'pkg2'], + versionPlans: [], + projectsRelationship: 'fixed', + }), + ]; + const allProjectNamesInWorkspace: string[] = ['pkg1', 'pkg2']; + + expect(() => + setVersionPlansOnGroups( + rawVersionPlans, + releaseGroups, + allProjectNamesInWorkspace + ) + ).toThrowErrorMatchingInlineSnapshot( + `Found a version bump for project 'pkg2' in 'plan1.md' that conflicts with another version bump. When in fixed versioning mode, all version bumps must match.` + ); + }); + }); + }); + + describe('for explicit groups', () => { + describe('when bump "key" is a group name', () => { + it('should error if version plans are not enabled', () => { + const rawVersionPlans: RawVersionPlan[] = [ + versionPlan({ + name: 'plan1.md', + content: { + group1: 'patch', + }, + message: 'plan1 message', + }), + ]; + const releaseGroups: ReleaseGroupWithName[] = [ + releaseGroup({ + name: 'group1', + projects: ['pkg1'], + versionPlans: false, + projectsRelationship: 'fixed', + }), + ]; + const allProjectNamesInWorkspace: string[] = ['pkg1']; + + expect(() => + setVersionPlansOnGroups( + rawVersionPlans, + releaseGroups, + allProjectNamesInWorkspace + ) + ).toThrowErrorMatchingInlineSnapshot( + `Found a version bump for group 'group1' in 'plan1.md' but the group does not have version plans enabled.` + ); + }); + + it('should error if group is independently versioned', () => { + const rawVersionPlans: RawVersionPlan[] = [ + versionPlan({ + name: 'plan1.md', + content: { + group1: 'patch', + }, + message: 'plan1 message', + }), + ]; + const releaseGroups: ReleaseGroupWithName[] = [ + releaseGroup({ + name: 'group1', + projects: ['pkg1'], + versionPlans: [], + projectsRelationship: 'independent', + }), + ]; + const allProjectNamesInWorkspace: string[] = ['pkg1']; + + expect(() => + setVersionPlansOnGroups( + rawVersionPlans, + releaseGroups, + allProjectNamesInWorkspace + ) + ).toThrowErrorMatchingInlineSnapshot( + `Found a version bump for group 'group1' in 'plan1.md' but the group's projects are independently versioned. Individual projects of 'group1' should be bumped instead.` + ); + }); + + it('should error if bump "value" is not a release type', () => { + const rawVersionPlans: RawVersionPlan[] = [ + versionPlan({ + name: 'plan1.md', + content: { + group1: 'not-a-release-type', + }, + message: 'plan1 message', + }), + ]; + const releaseGroups: ReleaseGroupWithName[] = [ + releaseGroup({ + name: 'group1', + projects: ['pkg1'], + versionPlans: [], + projectsRelationship: 'fixed', + }), + ]; + const allProjectNamesInWorkspace: string[] = ['pkg1']; + + expect(() => + setVersionPlansOnGroups( + rawVersionPlans, + releaseGroups, + allProjectNamesInWorkspace + ) + ).toThrowErrorMatchingInlineSnapshot( + `Found a version bump for group 'group1' in 'plan1.md' with an invalid release type. Please specify one of major, premajor, minor, preminor, patch, prepatch, prerelease.` + ); + }); + + it('should error if fixed group has two different entries with different bump types', () => { + const rawVersionPlans: RawVersionPlan[] = [ + versionPlan({ + name: 'plan1.md', + content: { + pkg1: 'minor', + group1: 'patch', + }, + message: 'plan1 message', + }), + ]; + const releaseGroups: ReleaseGroupWithName[] = [ + releaseGroup({ + name: 'group1', + projects: ['pkg1'], + versionPlans: [], + projectsRelationship: 'fixed', + }), + ]; + const allProjectNamesInWorkspace: string[] = ['pkg1']; + + expect(() => + setVersionPlansOnGroups( + rawVersionPlans, + releaseGroups, + allProjectNamesInWorkspace + ) + ).toThrowErrorMatchingInlineSnapshot( + `Found a version bump for group 'group1' in 'plan1.md' that conflicts with another version bump for this group. When the group is in fixed versioning mode, all groups' version bumps within the same version plan must match.` + ); + }); + }); + describe('when bump "key" is a project name', () => { + it('should error if version plans are not enabled', () => { + const rawVersionPlans: RawVersionPlan[] = [ + versionPlan({ + name: 'plan1.md', + content: { + pkg1: 'patch', + pkg2: 'minor', + }, + message: 'plan1 message', + }), + ]; + const releaseGroups: ReleaseGroupWithName[] = [ + releaseGroup({ + name: 'group1', + projects: ['pkg1'], + versionPlans: [], + projectsRelationship: 'fixed', + }), + releaseGroup({ + name: 'group2', + projects: ['pkg2'], + versionPlans: false, + projectsRelationship: 'fixed', + }), + ]; + const allProjectNamesInWorkspace: string[] = ['pkg1', 'pkg2']; + + expect(() => + setVersionPlansOnGroups( + rawVersionPlans, + releaseGroups, + allProjectNamesInWorkspace + ) + ).toThrowErrorMatchingInlineSnapshot( + `Found a version bump for project 'pkg2' in 'plan1.md' but the project's group 'group2' does not have version plans enabled.` + ); + }); + + it('should error if project does not exist in the workspace', () => { + const rawVersionPlans: RawVersionPlan[] = [ + versionPlan({ + name: 'plan1.md', + content: { + nonExistentPkg: 'patch', + }, + message: 'plan1 message', + }), + ]; + const releaseGroups: ReleaseGroupWithName[] = [ + releaseGroup({ + name: 'group1', + projects: ['pkg1'], + versionPlans: [], + projectsRelationship: 'fixed', + }), + ]; + const allProjectNamesInWorkspace: string[] = []; + + expect(() => + setVersionPlansOnGroups( + rawVersionPlans, + releaseGroups, + allProjectNamesInWorkspace + ) + ).toThrowErrorMatchingInlineSnapshot( + `Found a version bump for project 'nonExistentPkg' in 'plan1.md' but the project does not exist in the workspace.` + ); + }); + + it('should error if project is not included in any release groups', () => { + const rawVersionPlans: RawVersionPlan[] = [ + versionPlan({ + name: 'plan1.md', + content: { + pkg3: 'patch', + }, + message: 'plan1 message', + }), + ]; + const releaseGroups: ReleaseGroupWithName[] = [ + releaseGroup({ + name: 'group1', + projects: ['pkg1'], + versionPlans: [], + projectsRelationship: 'fixed', + }), + releaseGroup({ + name: 'group2', + projects: ['pkg2'], + versionPlans: [], + projectsRelationship: 'fixed', + }), + ]; + const allProjectNamesInWorkspace: string[] = [ + 'pkg1', + 'pkg2', + 'pkg3', + ]; + + expect(() => + setVersionPlansOnGroups( + rawVersionPlans, + releaseGroups, + allProjectNamesInWorkspace + ) + ).toThrowErrorMatchingInlineSnapshot( + `Found a version bump for project 'pkg3' in 'plan1.md' but the project is not in any configured release groups.` + ); + }); + + it(`should error if project's bump "value" is not a release type`, () => { + const rawVersionPlans: RawVersionPlan[] = [ + versionPlan({ + name: 'plan1.md', + content: { + pkg1: 'not-a-release-type', + }, + message: 'plan1 message', + }), + ]; + const releaseGroups: ReleaseGroupWithName[] = [ + releaseGroup({ + name: 'group1', + projects: ['pkg1'], + versionPlans: [], + projectsRelationship: 'fixed', + }), + ]; + const allProjectNamesInWorkspace: string[] = ['pkg1']; + + expect(() => + setVersionPlansOnGroups( + rawVersionPlans, + releaseGroups, + allProjectNamesInWorkspace + ) + ).toThrowErrorMatchingInlineSnapshot( + `Found a version bump for project 'pkg1' in 'plan1.md' with an invalid release type. Please specify one of major, premajor, minor, preminor, patch, prepatch, prerelease.` + ); + }); + + it('should error if a fixed group has two different projects with different bump types', () => { + const rawVersionPlans: RawVersionPlan[] = [ + versionPlan({ + name: 'plan1.md', + content: { + pkg1: 'patch', + pkg2: 'minor', + }, + message: 'plan1 message', + }), + ]; + const releaseGroups: ReleaseGroupWithName[] = [ + releaseGroup({ + name: 'group1', + projects: ['pkg1', 'pkg2'], + versionPlans: [], + projectsRelationship: 'fixed', + }), + ]; + const allProjectNamesInWorkspace: string[] = ['pkg1', 'pkg2']; + + expect(() => + setVersionPlansOnGroups( + rawVersionPlans, + releaseGroups, + allProjectNamesInWorkspace + ) + ).toThrowErrorMatchingInlineSnapshot( + `Found a version bump for project 'pkg2' in 'plan1.md' that conflicts with another project's version bump in the same release group 'group1'. When the group is in fixed versioning mode, all projects' version bumps within the same group must match.` + ); + }); + }); + }); + }); + + describe('success cases', () => { + describe('for default group', () => { + it('should correctly handle fixed default group', () => { + const rawVersionPlans: RawVersionPlan[] = [ + versionPlan({ + name: 'plan2.md', + content: { + pkg1: 'minor', + pkg2: 'minor', + pkg3: 'minor', + }, + message: 'plan2 message', + }), + versionPlan({ + name: 'plan1.md', + content: { + // for the default group, in fixed mode, we'll show individual + // entries for each project because there isn't a group name + pkg1: 'patch', + pkg2: 'patch', + pkg3: 'patch', + }, + message: 'plan1 message', + }), + ]; + const releaseGroups: ReleaseGroupWithName[] = [ + releaseGroup({ + name: IMPLICIT_DEFAULT_RELEASE_GROUP, + projects: ['pkg1', 'pkg2', 'pkg3'], + versionPlans: [], + projectsRelationship: 'fixed', + }), + ]; + const allProjectNamesInWorkspace: string[] = ['pkg1', 'pkg2', 'pkg3']; + + expect( + peelResultFromGroups( + setVersionPlansOnGroups( + rawVersionPlans, + releaseGroups, + allProjectNamesInWorkspace + ) + ) + // plan 1 should be first in the list because it was created after plan 2 + ).toMatchInlineSnapshot(` + [ + { + name: __default__, + versionPlans: [ + { + absolutePath: /version-plans/plan1.md, + createdOnMs: 20, + fileName: plan1.md, + groupVersionBump: patch, + message: plan1 message, + relativePath: .nx/version-plans/plan1.md, + }, + { + absolutePath: /version-plans/plan2.md, + createdOnMs: 19, + fileName: plan2.md, + groupVersionBump: minor, + message: plan2 message, + relativePath: .nx/version-plans/plan2.md, + }, + ], + }, + ] + `); + }); + + it('should correctly handle independent default group', () => { + const rawVersionPlans: RawVersionPlan[] = [ + versionPlan({ + name: 'plan2.md', + content: { + pkg1: 'minor', + pkg2: 'minor', + pkg3: 'minor', + }, + message: 'plan2 message', + }), + versionPlan({ + name: 'plan1.md', + content: { + pkg1: 'patch', + pkg2: 'minor', + pkg3: 'major', + }, + message: 'plan1 message', + }), + versionPlan({ + name: 'plan3.md', + content: { + pkg1: 'minor', + pkg2: 'patch', + pkg3: 'patch', + }, + message: 'plan3 message', + }), + ]; + const releaseGroups: ReleaseGroupWithName[] = [ + releaseGroup({ + name: IMPLICIT_DEFAULT_RELEASE_GROUP, + projects: ['pkg1', 'pkg2', 'pkg3'], + versionPlans: [], + projectsRelationship: 'independent', + }), + ]; + const allProjectNamesInWorkspace: string[] = ['pkg1', 'pkg2', 'pkg3']; + + expect( + peelResultFromGroups( + setVersionPlansOnGroups( + rawVersionPlans, + releaseGroups, + allProjectNamesInWorkspace + ) + ) + ).toMatchInlineSnapshot(` + [ + { + name: __default__, + versionPlans: [ + { + absolutePath: /version-plans/plan3.md, + createdOnMs: 23, + fileName: plan3.md, + message: plan3 message, + projectVersionBumps: { + pkg1: minor, + pkg2: patch, + pkg3: patch, + }, + relativePath: .nx/version-plans/plan3.md, + }, + { + absolutePath: /version-plans/plan1.md, + createdOnMs: 22, + fileName: plan1.md, + message: plan1 message, + projectVersionBumps: { + pkg1: patch, + pkg2: minor, + pkg3: major, + }, + relativePath: .nx/version-plans/plan1.md, + }, + { + absolutePath: /version-plans/plan2.md, + createdOnMs: 21, + fileName: plan2.md, + message: plan2 message, + projectVersionBumps: { + pkg1: minor, + pkg2: minor, + pkg3: minor, + }, + relativePath: .nx/version-plans/plan2.md, + }, + ], + }, + ] + `); + }); + }); + + describe('for explicit groups', () => { + it('should correctly handle fixed and independent groups', () => { + const rawVersionPlans: RawVersionPlan[] = [ + versionPlan({ + name: 'plan2.md', + content: { + pkg1: 'minor', + pkg2: 'minor', + pkg3: 'minor', + }, + message: 'plan2 message', + }), + versionPlan({ + name: 'plan1.md', + content: { + group1: 'patch', + group3: 'major', + pkg4: 'preminor', + pkg5: 'preminor', + }, + message: 'plan1 message', + }), + versionPlan({ + name: 'plan3.md', + content: { + group1: 'major', + group2: 'patch', + pkg5: 'premajor', + }, + message: 'plan3 message', + }), + ]; + const releaseGroups: ReleaseGroupWithName[] = [ + releaseGroup({ + name: 'group1', + projects: ['pkg1'], + versionPlans: [], + projectsRelationship: 'fixed', + }), + releaseGroup({ + name: 'group2', + projects: ['pkg2'], + versionPlans: [], + projectsRelationship: 'fixed', + }), + releaseGroup({ + name: 'group3', + projects: ['pkg3'], + versionPlans: [], + projectsRelationship: 'fixed', + }), + releaseGroup({ + name: 'group4', + projects: ['pkg4', 'pkg5'], + versionPlans: [], + projectsRelationship: 'independent', + }), + releaseGroup({ + name: 'group5', + projects: ['pkg6'], + versionPlans: false, + projectsRelationship: 'fixed', + }), + ]; + const allProjectNamesInWorkspace: string[] = [ + 'pkg1', + 'pkg2', + 'pkg3', + 'pkg4', + 'pkg5', + ]; + + expect( + peelResultFromGroups( + setVersionPlansOnGroups( + rawVersionPlans, + releaseGroups, + allProjectNamesInWorkspace + ) + ) + ).toMatchInlineSnapshot(` + [ + { + name: group1, + versionPlans: [ + { + absolutePath: /version-plans/plan3.md, + createdOnMs: 26, + fileName: plan3.md, + groupVersionBump: major, + message: plan3 message, + relativePath: .nx/version-plans/plan3.md, + }, + { + absolutePath: /version-plans/plan1.md, + createdOnMs: 25, + fileName: plan1.md, + groupVersionBump: patch, + message: plan1 message, + relativePath: .nx/version-plans/plan1.md, + }, + { + absolutePath: /version-plans/plan2.md, + createdOnMs: 24, + fileName: plan2.md, + groupVersionBump: minor, + message: plan2 message, + relativePath: .nx/version-plans/plan2.md, + }, + ], + }, + { + name: group2, + versionPlans: [ + { + absolutePath: /version-plans/plan3.md, + createdOnMs: 26, + fileName: plan3.md, + groupVersionBump: patch, + message: plan3 message, + relativePath: .nx/version-plans/plan3.md, + }, + { + absolutePath: /version-plans/plan2.md, + createdOnMs: 24, + fileName: plan2.md, + groupVersionBump: minor, + message: plan2 message, + relativePath: .nx/version-plans/plan2.md, + }, + ], + }, + { + name: group3, + versionPlans: [ + { + absolutePath: /version-plans/plan1.md, + createdOnMs: 25, + fileName: plan1.md, + groupVersionBump: major, + message: plan1 message, + relativePath: .nx/version-plans/plan1.md, + }, + { + absolutePath: /version-plans/plan2.md, + createdOnMs: 24, + fileName: plan2.md, + groupVersionBump: minor, + message: plan2 message, + relativePath: .nx/version-plans/plan2.md, + }, + ], + }, + { + name: group4, + versionPlans: [ + { + absolutePath: /version-plans/plan3.md, + createdOnMs: 26, + fileName: plan3.md, + message: plan3 message, + projectVersionBumps: { + pkg5: premajor, + }, + relativePath: .nx/version-plans/plan3.md, + }, + { + absolutePath: /version-plans/plan1.md, + createdOnMs: 25, + fileName: plan1.md, + message: plan1 message, + projectVersionBumps: { + pkg4: preminor, + pkg5: preminor, + }, + relativePath: .nx/version-plans/plan1.md, + }, + ], + }, + { + name: group5, + versionPlans: false, + }, + ] + `); + }); + }); + }); + }); + + let createdOnAccumulator = 1; + function versionPlan({ + name, + content, + message, + }: { + name: string; + content: Record; + message: string; + }): RawVersionPlan { + return { + absolutePath: join(tempFs.tempDir, '.nx/version-plans', name), + relativePath: `.nx/version-plans/${name}`, + fileName: name, + createdOnMs: createdOnAccumulator++, + content, + message, + }; + } +}); + +function releaseGroup( + group: Partial +): ReleaseGroupWithName { + return { + ...group, + } as ReleaseGroupWithName; +} + +function peelResultFromGroups(releaseGroups: ReleaseGroupWithName[]): { + name: string; + versionPlans: ReleaseGroupWithName['versionPlans']; +}[] { + return releaseGroups.map((g) => ({ + name: g.name, + versionPlans: g.versionPlans, + })); +} diff --git a/packages/nx/src/command-line/release/config/version-plans.ts b/packages/nx/src/command-line/release/config/version-plans.ts new file mode 100644 index 0000000000..2aa1639bc5 --- /dev/null +++ b/packages/nx/src/command-line/release/config/version-plans.ts @@ -0,0 +1,280 @@ +import { readFileSync, readdirSync } from 'fs'; +import { pathExists, stat } from 'fs-extra'; +import { join } from 'path'; +import { RELEASE_TYPES, ReleaseType } from 'semver'; +import { workspaceRoot } from '../../../utils/workspace-root'; +import { IMPLICIT_DEFAULT_RELEASE_GROUP } from './config'; +import { ReleaseGroupWithName } from './filter-release-groups'; +const fm = require('front-matter'); + +export interface VersionPlanFile { + absolutePath: string; + relativePath: string; + fileName: string; + createdOnMs: number; +} + +export interface RawVersionPlan extends VersionPlanFile { + content: Record; + message: string; +} + +export interface VersionPlan extends VersionPlanFile { + message: string; +} + +export interface GroupVersionPlan extends VersionPlan { + groupVersionBump: ReleaseType; +} + +export interface ProjectsVersionPlan extends VersionPlan { + projectVersionBumps: Record; +} + +const versionPlansDirectory = join('.nx', 'version-plans'); + +export async function readRawVersionPlans(): Promise { + const versionPlansPath = getVersionPlansAbsolutePath(); + const versionPlansPathExists = await pathExists(versionPlansPath); + if (!versionPlansPathExists) { + return []; + } + + const versionPlans: RawVersionPlan[] = []; + + const versionPlanFiles = readdirSync(versionPlansPath); + for (const versionPlanFile of versionPlanFiles) { + const filePath = join(versionPlansPath, versionPlanFile); + const versionPlanContent = readFileSync(filePath).toString(); + const versionPlanStats = await stat(filePath); + + const parsedContent = fm(versionPlanContent); + versionPlans.push({ + absolutePath: filePath, + relativePath: join(versionPlansDirectory, versionPlanFile), + fileName: versionPlanFile, + content: parsedContent.attributes, + message: getSingleLineMessage(parsedContent.body), + createdOnMs: versionPlanStats.birthtimeMs, + }); + } + + return versionPlans; +} + +export function setVersionPlansOnGroups( + rawVersionPlans: RawVersionPlan[], + releaseGroups: ReleaseGroupWithName[], + allProjectNamesInWorkspace: string[] +): ReleaseGroupWithName[] { + const groupsByName = releaseGroups.reduce( + (acc, group) => acc.set(group.name, group), + new Map() + ); + const isDefaultGroup = isDefault(releaseGroups); + + for (const rawVersionPlan of rawVersionPlans) { + for (const [key, value] of Object.entries(rawVersionPlan.content)) { + if (groupsByName.has(key)) { + const group = groupsByName.get(key); + + if (!group.versionPlans) { + if (isDefaultGroup) { + throw new Error( + `Found a version bump in '${rawVersionPlan.fileName}' but version plans are not enabled.` + ); + } else { + throw new Error( + `Found a version bump for group '${key}' in '${rawVersionPlan.fileName}' but the group does not have version plans enabled.` + ); + } + } + + if (group.projectsRelationship === 'independent') { + if (isDefaultGroup) { + throw new Error( + `Found a version bump in '${rawVersionPlan.fileName}' but projects are configured to be independently versioned. Individual projects should be bumped instead.` + ); + } else { + throw new Error( + `Found a version bump for group '${key}' in '${rawVersionPlan.fileName}' but the group's projects are independently versioned. Individual projects of '${key}' should be bumped instead.` + ); + } + } + + if (!isReleaseType(value)) { + if (isDefaultGroup) { + throw new Error( + `Found a version bump in '${ + rawVersionPlan.fileName + }' with an invalid release type. Please specify one of ${RELEASE_TYPES.join( + ', ' + )}.` + ); + } else { + throw new Error( + `Found a version bump for group '${key}' in '${ + rawVersionPlan.fileName + }' with an invalid release type. Please specify one of ${RELEASE_TYPES.join( + ', ' + )}.` + ); + } + } + + const existingPlan = ( + group.versionPlans.find( + (plan) => plan.fileName === rawVersionPlan.fileName + ) + ); + if (existingPlan) { + if (existingPlan.groupVersionBump !== value) { + if (isDefaultGroup) { + throw new Error( + `Found a version bump in '${rawVersionPlan.fileName}' that conflicts with another version bump. When in fixed versioning mode, all version bumps must match.` + ); + } else { + throw new Error( + `Found a version bump for group '${key}' in '${rawVersionPlan.fileName}' that conflicts with another version bump for this group. When the group is in fixed versioning mode, all groups' version bumps within the same version plan must match.` + ); + } + } + } else { + group.versionPlans.push({ + absolutePath: rawVersionPlan.absolutePath, + relativePath: rawVersionPlan.relativePath, + fileName: rawVersionPlan.fileName, + createdOnMs: rawVersionPlan.createdOnMs, + message: rawVersionPlan.message, + groupVersionBump: value, + }); + } + } else { + const groupForProject = releaseGroups.find((group) => + group.projects.includes(key) + ); + if (!groupForProject) { + if (!allProjectNamesInWorkspace.includes(key)) { + throw new Error( + `Found a version bump for project '${key}' in '${rawVersionPlan.fileName}' but the project does not exist in the workspace.` + ); + } + + if (isDefaultGroup) { + throw new Error( + `Found a version bump for project '${key}' in '${rawVersionPlan.fileName}' but the project is not configured for release. Ensure it is included by the 'release.projects' globs in nx.json.` + ); + } else { + throw new Error( + `Found a version bump for project '${key}' in '${rawVersionPlan.fileName}' but the project is not in any configured release groups.` + ); + } + } + + if (!groupForProject.versionPlans) { + if (isDefaultGroup) { + throw new Error( + `Found a version bump for project '${key}' in '${rawVersionPlan.fileName}' but version plans are not enabled.` + ); + } else { + throw new Error( + `Found a version bump for project '${key}' in '${rawVersionPlan.fileName}' but the project's group '${groupForProject.name}' does not have version plans enabled.` + ); + } + } + + if (!isReleaseType(value)) { + throw new Error( + `Found a version bump for project '${key}' in '${ + rawVersionPlan.fileName + }' with an invalid release type. Please specify one of ${RELEASE_TYPES.join( + ', ' + )}.` + ); + } + + if (groupForProject.projectsRelationship === 'independent') { + const existingPlan = ( + groupForProject.versionPlans.find( + (plan) => plan.fileName === rawVersionPlan.fileName + ) + ); + if (existingPlan) { + existingPlan.projectVersionBumps[key] = value; + } else { + groupForProject.versionPlans.push({ + absolutePath: rawVersionPlan.absolutePath, + relativePath: rawVersionPlan.relativePath, + fileName: rawVersionPlan.fileName, + createdOnMs: rawVersionPlan.createdOnMs, + message: rawVersionPlan.message, + projectVersionBumps: { + [key]: value, + }, + }); + } + } else { + const existingPlan = ( + groupForProject.versionPlans.find( + (plan) => plan.fileName === rawVersionPlan.fileName + ) + ); + // This can occur if the same fixed release group has multiple entries for different projects within + // the same version plan file. This will be the case when users are using the default release group. + if (existingPlan) { + if (existingPlan.groupVersionBump !== value) { + if (isDefaultGroup) { + throw new Error( + `Found a version bump for project '${key}' in '${rawVersionPlan.fileName}' that conflicts with another version bump. When in fixed versioning mode, all version bumps must match.` + ); + } else { + throw new Error( + `Found a version bump for project '${key}' in '${rawVersionPlan.fileName}' that conflicts with another project's version bump in the same release group '${groupForProject.name}'. When the group is in fixed versioning mode, all projects' version bumps within the same group must match.` + ); + } + } + } else { + groupForProject.versionPlans.push({ + absolutePath: rawVersionPlan.absolutePath, + relativePath: rawVersionPlan.relativePath, + fileName: rawVersionPlan.fileName, + createdOnMs: rawVersionPlan.createdOnMs, + message: rawVersionPlan.message, + // This is a fixed group, so the version bump is for the group, even if a project within it was specified + groupVersionBump: value, + }); + } + } + } + } + } + + // Order the plans from newest to oldest + releaseGroups.forEach((group) => { + if (group.versionPlans) { + group.versionPlans.sort((a, b) => b.createdOnMs - a.createdOnMs); + } + }); + + return releaseGroups; +} + +function isDefault(releaseGroups: ReleaseGroupWithName[]) { + return ( + releaseGroups.length === 1 && + releaseGroups.some((group) => group.name === IMPLICIT_DEFAULT_RELEASE_GROUP) + ); +} + +export function getVersionPlansAbsolutePath() { + return join(workspaceRoot, versionPlansDirectory); +} + +function isReleaseType(value: string): value is ReleaseType { + return RELEASE_TYPES.includes(value as ReleaseType); +} + +// changelog messages may only be a single line long, so ignore anything else +function getSingleLineMessage(message: string) { + return message.trim().split('\n')[0]; +} diff --git a/packages/nx/src/command-line/release/plan.ts b/packages/nx/src/command-line/release/plan.ts new file mode 100644 index 0000000000..d7b489790d --- /dev/null +++ b/packages/nx/src/command-line/release/plan.ts @@ -0,0 +1,246 @@ +import { prompt } from 'enquirer'; +import { ensureDir, writeFile } from 'fs-extra'; +import { join } from 'path'; +import { RELEASE_TYPES } from 'semver'; +import { readNxJson } from '../../config/nx-json'; +import { createProjectFileMapUsingProjectGraph } from '../../project-graph/file-map-utils'; +import { createProjectGraphAsync } from '../../project-graph/project-graph'; +import { output } from '../../utils/output'; +import { handleErrors } from '../../utils/params'; +import { PlanOptions } from './command-object'; +import { + IMPLICIT_DEFAULT_RELEASE_GROUP, + createNxReleaseConfig, + handleNxReleaseConfigError, +} from './config/config'; +import { filterReleaseGroups } from './config/filter-release-groups'; +import { getVersionPlansAbsolutePath } from './config/version-plans'; +import { parseConventionalCommitsMessage } from './utils/git'; +import { printDiff } from './utils/print-changes'; + +export const releasePlanCLIHandler = (args: PlanOptions) => + handleErrors(args.verbose, () => releasePlan(args)); + +export async function releasePlan(args: PlanOptions): Promise { + const projectGraph = await createProjectGraphAsync({ exitOnError: true }); + const nxJson = readNxJson(); + + if (args.verbose) { + process.env.NX_VERBOSE_LOGGING = 'true'; + } + + // Apply default configuration to any optional user configuration + const { error: configError, nxReleaseConfig } = await createNxReleaseConfig( + projectGraph, + await createProjectFileMapUsingProjectGraph(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 versionPlanBumps: Record = {}; + const setBumpIfNotNone = (projectOrGroup: string, version: string) => { + if (version !== 'none') { + versionPlanBumps[projectOrGroup] = version; + } + }; + + if (args.message) { + const message = parseConventionalCommitsMessage(args.message); + if (!message) { + output.error({ + title: 'Changelog message is not in conventional commits format.', + bodyLines: [ + 'Please ensure your message is in the form of:', + ' type(optional scope): description', + '', + 'For example:', + ' feat(pkg-b): add new feature', + ' fix(pkg-a): correct a bug', + ' chore: update build process', + ' fix(core)!: breaking change in core package', + ], + }); + process.exit(1); + } + } + + if (releaseGroups[0].name === IMPLICIT_DEFAULT_RELEASE_GROUP) { + const group = releaseGroups[0]; + if (group.projectsRelationship === 'independent') { + for (const project of group.projects) { + setBumpIfNotNone( + project, + args.bump || + (await promptForVersion( + `How do you want to bump the version of the project "${project}"?` + )) + ); + } + } else { + // TODO: use project names instead of the implicit default release group name? (though this might be confusing, as users might think they can just delete one of the project bumps to change the behavior to independent versioning) + setBumpIfNotNone( + group.name, + args.bump || + (await promptForVersion( + `How do you want to bump the versions of all projects?` + )) + ); + } + } else { + for (const group of releaseGroups) { + if (group.projectsRelationship === 'independent') { + for (const project of releaseGroupToFilteredProjects.get(group)) { + setBumpIfNotNone( + project, + args.bump || + (await promptForVersion( + `How do you want to bump the version of the project "${project}" within group "${group.name}"?` + )) + ); + } + } else { + setBumpIfNotNone( + group.name, + args.bump || + (await promptForVersion( + `How do you want to bump the versions of the projects in the group "${group.name}"?` + )) + ); + } + } + } + + if (!Object.keys(versionPlanBumps).length) { + output.warn({ + title: + 'No version bumps were selected so no version plan file was created.', + }); + return 0; + } + + const versionPlanMessage = args.message || (await promptForMessage()); + const versionPlanFileContent = getVersionPlanFileContent( + versionPlanBumps, + versionPlanMessage + ); + const versionPlanFileName = `version-plan-${new Date().getTime()}.md`; + + if (args.dryRun) { + output.logSingleLine( + `Would create version plan file "${versionPlanFileName}", but --dry-run was set.` + ); + printDiff('', versionPlanFileContent, 1); + } else { + output.logSingleLine(`Creating version plan file "${versionPlanFileName}"`); + printDiff('', versionPlanFileContent, 1); + + const versionPlansAbsolutePath = getVersionPlansAbsolutePath(); + await ensureDir(versionPlansAbsolutePath); + await writeFile( + join(versionPlansAbsolutePath, versionPlanFileName), + versionPlanFileContent + ); + } + + return 0; +} + +async function promptForVersion(message: string): Promise { + try { + const reply = await prompt<{ version: string }>([ + { + name: 'version', + message, + type: 'select', + choices: [...RELEASE_TYPES, 'none'], + }, + ]); + return reply.version; + } catch (e) { + output.log({ + title: 'Cancelled version plan creation.', + }); + process.exit(0); + } +} + +async function promptForMessage(): Promise { + let message: string; + do { + message = await _promptForMessage(); + } while (!message); + return message; +} + +// TODO: support non-conventional commits messages (will require significant changelog renderer changes) +async function _promptForMessage(): Promise { + try { + const reply = await prompt<{ message: string }>([ + { + name: 'message', + message: + 'What changelog message would you like associated with this change?', + type: 'input', + }, + ]); + + const conventionalCommitsMessage = parseConventionalCommitsMessage( + reply.message + ); + if (!conventionalCommitsMessage) { + output.warn({ + title: 'Changelog message is not in conventional commits format.', + bodyLines: [ + 'Please ensure your message is in the form of:', + ' type(optional scope): description', + '', + 'For example:', + ' feat(pkg-b): add new feature', + ' fix(pkg-a): correct a bug', + ' chore: update build process', + ' fix(core)!: breaking change in core package', + ], + }); + return null; + } + + return reply.message; + } catch (e) { + output.log({ + title: 'Cancelled version plan creation.', + }); + process.exit(0); + } +} + +function getVersionPlanFileContent( + bumps: Record, + message: string +): string { + return `--- +${Object.entries(bumps) + .filter(([_, version]) => version !== 'none') + .map(([projectOrGroup, version]) => `${projectOrGroup}: ${version}`) + .join('\n')} +--- + +${message} +`; +} diff --git a/packages/nx/src/command-line/release/release.ts b/packages/nx/src/command-line/release/release.ts index 061781b549..7ec8c6f9f4 100644 --- a/packages/nx/src/command-line/release/release.ts +++ b/packages/nx/src/command-line/release/release.ts @@ -1,4 +1,5 @@ import { prompt } from 'enquirer'; +import { removeSync } from 'fs-extra'; import { readNxJson } from '../../config/nx-json'; import { createProjectFileMapUsingProjectGraph } from '../../project-graph/file-map-utils'; import { createProjectGraphAsync } from '../../project-graph/project-graph'; @@ -7,12 +8,17 @@ import { handleErrors } from '../../utils/params'; import { releaseChangelog, shouldCreateGitHubRelease } from './changelog'; import { ReleaseOptions, VersionOptions } from './command-object'; import { + IMPLICIT_DEFAULT_RELEASE_GROUP, createNxReleaseConfig, handleNxReleaseConfigError, } from './config/config'; import { filterReleaseGroups } from './config/filter-release-groups'; +import { + readRawVersionPlans, + setVersionPlansOnGroups, +} from './config/version-plans'; import { releasePublish } from './publish'; -import { getCommitHash, gitCommit, gitPush, gitTag } from './utils/git'; +import { getCommitHash, gitAdd, gitCommit, gitPush, gitTag } from './utils/git'; import { createOrUpdateGithubRelease } from './utils/github'; import { resolveNxJsonConfigErrorMessage } from './utils/resolve-nx-json-error-message'; import { @@ -75,6 +81,7 @@ export async function release( stageChanges: shouldStage, gitCommit: false, gitTag: false, + deleteVersionPlans: false, }); const changelogResult = await releaseChangelog({ @@ -85,6 +92,7 @@ export async function release( gitCommit: false, gitTag: false, createRelease: false, + deleteVersionPlans: false, }); const { @@ -101,6 +109,49 @@ export async function release( output.error(filterError); process.exit(1); } + const rawVersionPlans = await readRawVersionPlans(); + setVersionPlansOnGroups( + rawVersionPlans, + releaseGroups, + Object.keys(projectGraph.nodes) + ); + + const planFiles = new Set(); + releaseGroups.forEach((group) => { + if (group.versionPlans) { + if (group.name === IMPLICIT_DEFAULT_RELEASE_GROUP) { + output.logSingleLine(`Removing version plan files`); + } else { + output.logSingleLine( + `Removing version plan files for group ${group.name}` + ); + } + group.versionPlans.forEach((plan) => { + if (!args.dryRun) { + removeSync(plan.absolutePath); + if (args.verbose) { + console.log(`Removing ${plan.relativePath}`); + } + } else { + if (args.verbose) { + console.log( + `Would remove ${plan.relativePath}, but --dry-run was set` + ); + } + } + planFiles.add(plan.relativePath); + }); + } + }); + const deletedFiles = Array.from(planFiles); + if (deletedFiles.length > 0) { + await gitAdd({ + changedFiles: [], + deletedFiles, + dryRun: args.dryRun, + verbose: args.verbose, + }); + } if (shouldCommit) { output.logSingleLine(`Committing changes with git`); diff --git a/packages/nx/src/command-line/release/utils/git.ts b/packages/nx/src/command-line/release/utils/git.ts index 8af5d8c331..2944fa357c 100644 --- a/packages/nx/src/command-line/release/utils/git.ts +++ b/packages/nx/src/command-line/release/utils/git.ts @@ -147,13 +147,21 @@ export async function getGitDiff( }); } +export async function getChangedTrackedFiles(): Promise> { + const result = await execCommand('git', ['status', '--porcelain']); + const lines = result.split('\n').filter((l) => l.trim().length > 0); + return new Set(lines.map((l) => l.substring(3))); +} + export async function gitAdd({ changedFiles, + deletedFiles, dryRun, verbose, logFn, }: { - changedFiles: string[]; + changedFiles?: string[]; + deletedFiles?: string[]; dryRun?: boolean; verbose?: boolean; logFn?: (...messages: string[]) => void; @@ -162,7 +170,7 @@ export async function gitAdd({ let ignoredFiles: string[] = []; let filesToAdd: string[] = []; - for (const f of changedFiles) { + for (const f of changedFiles ?? []) { const isFileIgnored = await isIgnored(f); if (isFileIgnored) { ignoredFiles.push(f); @@ -171,13 +179,29 @@ export async function gitAdd({ } } + if (deletedFiles?.length > 0) { + const changedTrackedFiles = await getChangedTrackedFiles(); + for (const f of deletedFiles ?? []) { + const isFileIgnored = await isIgnored(f); + if (isFileIgnored) { + ignoredFiles.push(f); + // git add will fail if trying to add an untracked file that doesn't exist + } else if (changedTrackedFiles.has(f)) { + filesToAdd.push(f); + } + } + } + if (verbose && ignoredFiles.length) { logFn(`Will not add the following files because they are ignored by git:`); ignoredFiles.forEach((f) => logFn(f)); } if (!filesToAdd.length) { - logFn('\nNo files to stage. Skipping git add.'); + if (!dryRun) { + logFn('\nNo files to stage. Skipping git add.'); + } + // if this is a dry run, it's possible that there would have been actual files to add, so it's deceptive to say "No files to stage". return; } @@ -349,6 +373,25 @@ export function parseCommits(commits: RawGitCommit[]): GitCommit[] { return commits.map((commit) => parseGitCommit(commit)).filter(Boolean); } +export function parseConventionalCommitsMessage(message: string): { + type: string; + scope: string; + description: string; + breaking: boolean; +} | null { + const match = message.match(ConventionalCommitRegex); + if (!match) { + return null; + } + + return { + type: match.groups.type || '', + scope: match.groups.scope || '', + description: match.groups.description || '', + breaking: Boolean(match.groups.breaking), + }; +} + // https://www.conventionalcommits.org/en/v1.0.0/ // https://regex101.com/r/FSfNvA/1 const ConventionalCommitRegex = @@ -360,16 +403,15 @@ const ChangedFileRegex = /(A|M|D|R\d*|C\d*)\t([^\t\n]*)\t?(.*)?/gm; const RevertHashRE = /This reverts commit (?[\da-f]{40})./gm; export function parseGitCommit(commit: RawGitCommit): GitCommit | null { - const match = commit.message.match(ConventionalCommitRegex); - if (!match) { + const parsedMessage = parseConventionalCommitsMessage(commit.message); + if (!parsedMessage) { return null; } - const scope = match.groups.scope || ''; - + const scope = parsedMessage.scope; const isBreaking = - Boolean(match.groups.breaking) || commit.body.includes('BREAKING CHANGE:'); - let description = match.groups.description; + parsedMessage.breaking || commit.body.includes('BREAKING CHANGE:'); + let description = parsedMessage.description; // Extract references from message const references: Reference[] = []; @@ -386,7 +428,7 @@ export function parseGitCommit(commit: RawGitCommit): GitCommit | null { // Remove references and normalize description = description.replace(PullRequestRE, '').trim(); - let type = match.groups.type; + let type = parsedMessage.type; // Extract any reverted hashes, if applicable const revertedHashes = []; const matchedHashes = commit.body.matchAll(RevertHashRE); diff --git a/packages/nx/src/command-line/release/utils/shared.spec.ts b/packages/nx/src/command-line/release/utils/shared.spec.ts index 572db3a10f..6d76eb2378 100644 --- a/packages/nx/src/command-line/release/utils/shared.spec.ts +++ b/packages/nx/src/command-line/release/utils/shared.spec.ts @@ -17,6 +17,7 @@ describe('shared', () => { }, changelog: false, releaseTagPattern: '{projectName}-{version}', + versionPlans: false, }, { name: 'two', @@ -29,6 +30,7 @@ describe('shared', () => { }, changelog: false, releaseTagPattern: '{projectName}-{version}', + versionPlans: false, }, ]; const releaseGroupToFilteredProjects = new Map() @@ -101,6 +103,7 @@ describe('shared', () => { }, releaseTagPattern: '{projectName}-{version}', name: '__default__', + versionPlans: false, }, ]; @@ -190,6 +193,7 @@ describe('shared', () => { releaseTagPattern: 'my-group-{version}', changelog: undefined, version: undefined, + versionPlans: false, }; const releaseGroupToFilteredProjects = new Map().set( releaseGroup, diff --git a/packages/nx/src/command-line/release/utils/shared.ts b/packages/nx/src/command-line/release/utils/shared.ts index ba21c258ea..a75a1c5e42 100644 --- a/packages/nx/src/command-line/release/utils/shared.ts +++ b/packages/nx/src/command-line/release/utils/shared.ts @@ -21,7 +21,13 @@ export type ReleaseVersionGeneratorResult = { verbose?: boolean; generatorOptions?: Record; } - ) => Promise; + ) => Promise< + | string[] + | { + changedFiles: string[]; + deletedFiles: string[]; + } + >; }; export type VersionData = Record< @@ -69,20 +75,29 @@ export class ReleaseVersion { } } -export async function commitChanges( - changedFiles: string[], - isDryRun: boolean, - isVerbose: boolean, - gitCommitMessages: string[], - gitCommitArgs?: string -) { - if (!changedFiles.length) { +export async function commitChanges({ + changedFiles, + deletedFiles, + isDryRun, + isVerbose, + gitCommitMessages, + gitCommitArgs, +}: { + changedFiles?: string[]; + deletedFiles?: string[]; + isDryRun?: boolean; + isVerbose?: boolean; + gitCommitMessages?: string[]; + gitCommitArgs?: string; +}) { + if (!changedFiles?.length && !deletedFiles?.length) { throw new Error('Error: No changed files to commit'); } output.logSingleLine(`Committing changes with git`); await gitAdd({ changedFiles, + deletedFiles, dryRun: isDryRun, verbose: isVerbose, }); diff --git a/packages/nx/src/command-line/release/version.ts b/packages/nx/src/command-line/release/version.ts index bf74ab6b33..d1d0d4d063 100644 --- a/packages/nx/src/command-line/release/version.ts +++ b/packages/nx/src/command-line/release/version.ts @@ -30,6 +30,10 @@ import { ReleaseGroupWithName, filterReleaseGroups, } from './config/filter-release-groups'; +import { + readRawVersionPlans, + setVersionPlansOnGroups, +} from './config/version-plans'; import { batchProjectsByGeneratorConfig } from './utils/batch-projects-by-generator-config'; import { gitAdd, gitTag } from './utils/git'; import { printDiff } from './utils/print-changes'; @@ -60,7 +64,7 @@ export interface ReleaseVersionGeneratorSchema { releaseGroup: ReleaseGroupWithName; projectGraph: ProjectGraph; specifier?: string; - specifierSource?: 'prompt' | 'conventional-commits'; + specifierSource?: 'prompt' | 'conventional-commits' | 'version-plans'; preid?: string; packageRoot?: string; currentVersionResolver?: 'registry' | 'disk' | 'git-tag'; @@ -73,6 +77,7 @@ export interface ReleaseVersionGeneratorSchema { installArgs?: string; installIgnoreScripts?: boolean; conventionalCommitsConfig?: NxReleaseConfig['conventionalCommits']; + deleteVersionPlans?: boolean; /** * 'auto' allows users to opt into dependents being updated (a patch version bump) when a dependency is versioned. * This is only applicable to independently released projects. @@ -159,6 +164,17 @@ export async function releaseVersion( output.error(filterError); process.exit(1); } + const rawVersionPlans = await readRawVersionPlans(); + setVersionPlansOnGroups( + rawVersionPlans, + releaseGroups, + Object.keys(projectGraph.nodes) + ); + + if (args.deleteVersionPlans === undefined) { + // default to not delete version plans after versioning as they may be needed for changelog generation + args.deleteVersionPlans = false; + } runPreVersionCommand(nxReleaseConfig.version.preVersionCommand, { dryRun: args.dryRun, @@ -177,6 +193,7 @@ export async function releaseVersion( * and need to get staged and committed as part of the existing commit, if applicable. */ const additionalChangedFiles = new Set(); + const additionalDeletedFiles = new Set(); if (args.projects?.length) { /** @@ -227,7 +244,7 @@ export async function releaseVersion( ); // Capture the callback so that we can run it after flushing the changes to disk generatorCallbacks.push(async () => { - const changedFiles = await generatorCallback(tree, { + const result = await generatorCallback(tree, { dryRun: !!args.dryRun, verbose: !!args.verbose, generatorOptions: { @@ -235,7 +252,10 @@ export async function releaseVersion( ...args.generatorOptionsOverrides, }, }); + const { changedFiles, deletedFiles } = + parseGeneratorCallbackResult(result); changedFiles.forEach((f) => additionalChangedFiles.add(f)); + deletedFiles.forEach((f) => additionalDeletedFiles.add(f)); }); } } @@ -272,18 +292,20 @@ export async function releaseVersion( } if (args.gitCommit ?? nxReleaseConfig.version.git.commit) { - await commitChanges( + await commitChanges({ changedFiles, - !!args.dryRun, - !!args.verbose, - createCommitMessageValues( + deletedFiles: Array.from(additionalDeletedFiles), + isDryRun: !!args.dryRun, + isVerbose: !!args.verbose, + gitCommitMessages: createCommitMessageValues( releaseGroups, releaseGroupToFilteredProjects, versionData, commitMessage ), - args.gitCommitArgs || nxReleaseConfig.version.git.commitArgs - ); + gitCommitArgs: + args.gitCommitArgs || nxReleaseConfig.version.git.commitArgs, + }); } else if (args.stageChanges ?? nxReleaseConfig.version.git.stageChanges) { output.logSingleLine(`Staging changed files with git`); await gitAdd({ @@ -359,7 +381,7 @@ export async function releaseVersion( ); // Capture the callback so that we can run it after flushing the changes to disk generatorCallbacks.push(async () => { - const changedFiles = await generatorCallback(tree, { + const result = await generatorCallback(tree, { dryRun: !!args.dryRun, verbose: !!args.verbose, generatorOptions: { @@ -367,7 +389,10 @@ export async function releaseVersion( ...args.generatorOptionsOverrides, }, }); + const { changedFiles, deletedFiles } = + parseGeneratorCallbackResult(result); changedFiles.forEach((f) => additionalChangedFiles.add(f)); + deletedFiles.forEach((f) => additionalDeletedFiles.add(f)); }); } } @@ -415,18 +440,20 @@ export async function releaseVersion( } if (args.gitCommit ?? nxReleaseConfig.version.git.commit) { - await commitChanges( + await commitChanges({ changedFiles, - !!args.dryRun, - !!args.verbose, - createCommitMessageValues( + deletedFiles: Array.from(additionalDeletedFiles), + isDryRun: !!args.dryRun, + isVerbose: !!args.verbose, + gitCommitMessages: createCommitMessageValues( releaseGroups, releaseGroupToFilteredProjects, versionData, commitMessage ), - args.gitCommitArgs || nxReleaseConfig.version.git.commitArgs - ); + gitCommitArgs: + args.gitCommitArgs || nxReleaseConfig.version.git.commitArgs, + }); } else if (args.stageChanges ?? nxReleaseConfig.version.git.stageChanges) { output.logSingleLine(`Staging changed files with git`); await gitAdd({ @@ -495,6 +522,7 @@ async function runVersionOnProjects( releaseGroup, firstRelease: args.firstRelease ?? false, conventionalCommitsConfig, + deleteVersionPlans: args.deleteVersionPlans, }; // Apply generator defaults from schema.json file etc @@ -555,7 +583,7 @@ function printAndFlushChanges(tree: Tree, isDryRun: boolean) { joinPathFragments(tree.root, f.path) ).toString(); printDiff(currentContentsOnDisk, f.content?.toString() || ''); - } else if (f.type === 'DELETE') { + } else if (f.type === 'DELETE' && !f.path.includes('.nx')) { throw new Error( 'Unexpected DELETE change, please report this as an issue' ); @@ -686,3 +714,16 @@ function runPreVersionCommand( process.exit(1); } } + +function parseGeneratorCallbackResult( + result: string[] | { changedFiles: string[]; deletedFiles: string[] } +): { changedFiles: string[]; deletedFiles: string[] } { + if (Array.isArray(result)) { + return { + changedFiles: result, + deletedFiles: [], + }; + } else { + return result; + } +} diff --git a/packages/nx/src/config/nx-json.ts b/packages/nx/src/config/nx-json.ts index d52623e49f..546e18093a 100644 --- a/packages/nx/src/config/nx-json.ts +++ b/packages/nx/src/config/nx-json.ts @@ -218,6 +218,11 @@ interface NxReleaseConfiguration { * Optionally override the git/release tag pattern to use for this group. */ releaseTagPattern?: string; + /** + * Enables using version plans as a specifier source for versioning and + * to determine changes for changelog generation. + */ + versionPlans?: boolean; } >; /** @@ -285,6 +290,11 @@ interface NxReleaseConfiguration { */ git?: NxReleaseGitConfiguration; conventionalCommits?: NxReleaseConventionalCommitsConfiguration; + /** + * Enables using version plans as a specifier source for versioning and + * to determine changes for changelog generation. + */ + versionPlans?: boolean; } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e9e14548e..8cad9f498b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ dependencies: framer-motion: specifier: ^11.1.7 version: 11.1.7(react-dom@18.3.1)(react@18.3.1) + front-matter: + specifier: ^4.0.2 + version: 4.0.2 glob: specifier: 7.1.4 version: 7.1.4 @@ -22212,6 +22215,12 @@ packages: resolution: {integrity: sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==} dev: true + /front-matter@4.0.2: + resolution: {integrity: sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg==} + dependencies: + js-yaml: 3.14.1 + dev: false + /fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} dev: true diff --git a/scripts/documentation/generators/generate-cli-data.ts b/scripts/documentation/generators/generate-cli-data.ts index f8f66cd854..095c43e916 100644 --- a/scripts/documentation/generators/generate-cli-data.ts +++ b/scripts/documentation/generators/generate-cli-data.ts @@ -105,7 +105,10 @@ description: "${command.description}" (name) => !sharedCommands.includes(name) && !hiddenCommands.includes(name) && - nxCommands[name].description + // These are all supported yargs fields for description + (nxCommands[name].description || + nxCommands[name].describe || + nxCommands[name].desc) ) .map((name) => parseCommand(name, nxCommands[name])) .map(async (command) => generateMarkdown(await command)) diff --git a/scripts/documentation/utils.ts b/scripts/documentation/utils.ts index 0a5af52f36..817af94ac2 100644 --- a/scripts/documentation/utils.ts +++ b/scripts/documentation/utils.ts @@ -3,6 +3,7 @@ import { bold, h, lines as mdLines, strikethrough } from 'markdown-factory'; import { join } from 'path'; import { format, resolveConfig } from 'prettier'; import { MenuItem } from '@nx/nx-dev/models-menu'; +import yargs, { CommandModule } from 'yargs'; const stripAnsi = require('strip-ansi'); const importFresh = require('import-fresh'); @@ -198,10 +199,16 @@ export async function parseCommand( return acc; }, {}); const subcommands = await Promise.all( - Object.entries(getCommands(builder)).map( - ([subCommandName, subCommandConfig]) => + Object.entries(getCommands(builder)) + .filter(([, subCommandConfig]) => { + const c = subCommandConfig as CommandModule; + // These are all supported yargs fields for description, even though the types don't reflect that + // @ts-ignore + return c.description || c.describe || c.desc; + }) + .map(([subCommandName, subCommandConfig]) => parseCommand(subCommandName, subCommandConfig) - ) + ) ); return {