From f788d9fec6f609f7c9cf13636769e2a0cb2f06f7 Mon Sep 17 00:00:00 2001 From: Austin Fahsl Date: Mon, 16 Oct 2023 11:30:17 -0600 Subject: [PATCH] feat(release): conventional-commits, git-tag, and single generator --- .../js/generators/release-version.json | 16 +- e2e/release/src/release.test.ts | 148 +++++++++++++ .../release-version/release-version.ts | 120 +++++++++- .../generators/release-version/schema.json | 16 +- .../nx/src/command-line/release/changelog.ts | 3 +- .../nx/src/command-line/release/utils/git.ts | 37 +++- .../release/utils/markdown.spec.ts | 7 +- .../release/utils/resolve-semver-specifier.ts | 86 ++++++++ .../command-line/release/utils/semver.spec.ts | 207 +++++++++++++----- .../src/command-line/release/utils/semver.ts | 40 ++++ .../nx/src/command-line/release/version.ts | 110 ++-------- 11 files changed, 623 insertions(+), 167 deletions(-) create mode 100644 packages/nx/src/command-line/release/utils/resolve-semver-specifier.ts diff --git a/docs/generated/packages/js/generators/release-version.json b/docs/generated/packages/js/generators/release-version.json index 4068844d0caf2b..ba6617e8996a72 100644 --- a/docs/generated/packages/js/generators/release-version.json +++ b/docs/generated/packages/js/generators/release-version.json @@ -20,7 +20,17 @@ }, "specifier": { "type": "string", - "description": "Exact version or semver keyword to apply to the selected release group. NOTE: This should be set on the release group level, not the project level." + "description": "Exact version or semver keyword to apply to the selected release group. Overrides specifierSource." + }, + "releaseGroupName": { + "type": "string", + "description": "The name of the release group being versioned in the current execution." + }, + "specifierSource": { + "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"] }, "preid": { "type": "string", @@ -34,7 +44,7 @@ "type": "string", "default": "disk", "description": "Which approach to use to determine the current version of the project.", - "enum": ["registry", "disk"] + "enum": ["registry", "disk", "git-tag"] }, "currentVersionResolverMetadata": { "type": "object", @@ -42,7 +52,7 @@ "default": {} } }, - "required": ["projects", "projectGraph", "specifier"], + "required": ["projects", "projectGraph", "releaseGroupName"], "presets": [] }, "description": "DO NOT INVOKE DIRECTLY WITH `nx generate`. Use `nx release version` instead.", diff --git a/e2e/release/src/release.test.ts b/e2e/release/src/release.test.ts index 7341ee28df1d3e..09f60c0b639a4d 100644 --- a/e2e/release/src/release.test.ts +++ b/e2e/release/src/release.test.ts @@ -9,6 +9,7 @@ import { runCLI, runCommandAsync, runCommandUntil, + tmpProjPath, uniq, updateJson, } from '@nx/e2e/utils'; @@ -546,5 +547,152 @@ describe('nx release', () => { // port and process cleanup await killProcessAndPorts(process.pid, verdaccioPort); + + // Add custom nx release config to control version resolution + updateJson('nx.json', (nxJson) => { + nxJson.release = { + groups: { + default: { + // @proj/source will be added as a project by the verdaccio setup, but we aren't versioning or publishing it, so we exclude it here + projects: ['*', '!@proj/source'], + version: { + generator: '@nx/js:release-version', + generatorOptions: { + // Resolve the latest version from the git tag + currentVersionResolver: 'git-tag', + currentVersionResolverMetadata: { + tagVersionPrefix: 'xx', + }, + }, + }, + }, + }, + }; + return nxJson; + }); + + // Add a git tag to the repo + execSync(`git tag -a xx1100.0.0 -m xx1100.0.0`, { + cwd: tmpProjPath(), + }); + + const versionOutput3 = runCLI(`release version minor`); + expect( + versionOutput3.match(/Running release version for project: my-pkg-\d*/g) + .length + ).toEqual(3); + expect( + versionOutput3.match( + /Reading data for package "@proj\/my-pkg-\d*" from my-pkg-\d*\/package.json/g + ).length + ).toEqual(3); + + // It should resolve the current version from the git tag once... + expect( + versionOutput3.match( + new RegExp( + `Resolved the current version as 1100.0.0 from git tag "xx1100.0.0"`, + 'g' + ) + ).length + ).toEqual(1); + // ...and then reuse it twice + expect( + versionOutput3.match( + new RegExp( + `Using the current version 1100.0.0 already resolved from git tag "xx1100.0.0"`, + 'g' + ) + ).length + ).toEqual(2); + + expect( + versionOutput3.match( + /New version 1100.1.0 written to my-pkg-\d*\/package.json/g + ).length + ).toEqual(3); + + // Only one dependency relationship exists, so this log should only match once + expect( + versionOutput3.match( + /Applying new version 1100.1.0 to 1 package which depends on my-pkg-\d*/g + ).length + ).toEqual(1); + + createFile( + `${pkg1}/my-file.txt`, + 'update for conventional-commits testing' + ); + + // Add custom nx release config to control version resolution + updateJson('nx.json', (nxJson) => { + nxJson.release = { + groups: { + default: { + // @proj/source will be added as a project by the verdaccio setup, but we aren't versioning or publishing it, so we exclude it here + projects: ['*', '!@proj/source'], + version: { + generator: '@nx/js:release-version', + generatorOptions: { + specifierSource: 'conventional-commits', + currentVersionResolver: 'git-tag', + currentVersionResolverMetadata: { + tagVersionPrefix: 'xx', + }, + }, + }, + }, + }, + }; + return nxJson; + }); + + const versionOutput4 = runCLI(`release version`); + + expect( + versionOutput4.match(/Running release version for project: my-pkg-\d*/g) + .length + ).toEqual(3); + expect( + versionOutput4.match( + /Reading data for package "@proj\/my-pkg-\d*" from my-pkg-\d*\/package.json/g + ).length + ).toEqual(3); + + // It should resolve the current version from the git tag once... + expect( + versionOutput4.match( + new RegExp( + `Resolved the current version as 1100.0.0 from git tag "xx1100.0.0"`, + 'g' + ) + ).length + ).toEqual(1); + // ...and then reuse it twice + expect( + versionOutput4.match( + new RegExp( + `Using the current version 1100.0.0 already resolved from git tag "xx1100.0.0"`, + 'g' + ) + ).length + ).toEqual(2); + + expect(versionOutput4.match(/Skipping versioning/g).length).toEqual(3); + + execSync( + `git add ${pkg1}/my-file.txt && git commit -m "feat!: add new file"`, + { + cwd: tmpProjPath(), + } + ); + + const versionOutput5 = runCLI(`release version`); + + expect( + versionOutput5.match( + /New version 1101.0.0 written to my-pkg-\d*\/package.json/g + ).length + ).toEqual(3); }, 500000); }); diff --git a/packages/js/src/generators/release-version/release-version.ts b/packages/js/src/generators/release-version/release-version.ts index df141829f614a2..a2c587231a276f 100644 --- a/packages/js/src/generators/release-version/release-version.ts +++ b/packages/js/src/generators/release-version/release-version.ts @@ -9,10 +9,17 @@ import { } from '@nx/devkit'; import * as chalk from 'chalk'; import { exec } from 'child_process'; +import { getLastGitTag } from 'nx/src/command-line/release/utils/git'; +import { + resolveSemverSpecifierFromConventionalCommits, + resolveSemverSpecifierFromPrompt, +} from 'nx/src/command-line/release/utils/resolve-semver-specifier'; +import { isValidSemverSpecifier } from 'nx/src/command-line/release/utils/semver'; import { deriveNewSemverVersion } from 'nx/src/command-line/release/version'; import { interpolate } from 'nx/src/tasks-runner/utils'; import * as ora from 'ora'; import { relative } from 'path'; +import { prerelease } from 'semver'; import { ReleaseVersionGeneratorSchema } from './schema'; import { resolveLocalPackageDependencies } from './utils/resolve-local-package-dependencies'; @@ -20,6 +27,13 @@ export async function releaseVersionGenerator( tree: Tree, options: ReleaseVersionGeneratorSchema ) { + // If the user provided a specifier, validate it + if (options.specifier && !isValidSemverSpecifier(options.specifier)) { + throw new Error( + `The given version specifier "${options.specifier}" is not valid. You provide an exact version or a valid semver keyword such as "major", "minor", "patch", etc.` + ); + } + const projects = options.projects; // Resolve any custom package roots for each project upfront as they will need to be reused during dependency resolution @@ -40,6 +54,14 @@ export async function releaseVersionGenerator( let currentVersion: string; + // only used for options.currentVersionResolver === 'git-tag', but + // must be declared here in order to reuse it for additional projects + let lastMatchingGitTag: string; + + // if specifier is undefined, then we haven't resolved it yet + // if specifier is null, then it has been resolved and no changes are necessary + let specifier = options.specifier ? options.specifier : undefined; + for (const project of projects) { const projectName = project.name; const packageRoot = projectNameToPackageRootMap.get(projectName); @@ -79,7 +101,10 @@ To fix this you will either need to add a package.json file at that location, or switch (options.currentVersionResolver) { case 'registry': { const metadata = options.currentVersionResolverMetadata; - const registry = metadata?.registry ?? 'https://registry.npmjs.org'; + const registry = + metadata?.registry ?? + (await getNpmRegistry()) ?? + 'https://registry.npmjs.org'; const tag = metadata?.tag ?? 'latest'; // If the currentVersionResolver is set to registry, we only want to make the request once for the whole batch of projects @@ -127,12 +152,88 @@ To fix this you will either need to add a package.json file at that location, or `📄 Resolved the current version as ${currentVersion} from ${packageJsonPath}` ); break; + case 'git-tag': { + if (!currentVersion) { + const tagVersionPrefix = + (options.currentVersionResolverMetadata + ?.tagVersionPrefix as string) ?? 'v'; + const matchingPattern = `${tagVersionPrefix}*.*.*`; + lastMatchingGitTag = await getLastGitTag(matchingPattern); + + if (!lastMatchingGitTag) { + throw new Error( + `No git tags matching pattern "${matchingPattern}" were found.` + ); + } + + currentVersion = lastMatchingGitTag.replace(tagVersionPrefix, ''); + log( + `📄 Resolved the current version as ${currentVersion} from git tag "${lastMatchingGitTag}".` + ); + } else { + log( + `📄 Using the current version ${currentVersion} already resolved from git tag "${lastMatchingGitTag}".` + ); + } + break; + } default: throw new Error( `Invalid value for options.currentVersionResolver: ${options.currentVersionResolver}` ); } + // if specifier is null, then we determined previously via conventional commits that no changes are necessary + if (specifier === undefined) { + const specifierSource = options.specifierSource; + switch (specifierSource) { + case 'conventional-commits': + if (options.currentVersionResolver !== 'git-tag') { + throw new Error( + `Invalid currentVersionResolver "${options.currentVersionResolver}" provided for release group "${options.releaseGroupName}". Must be "git-tag" when "specifierSource" is "conventional-commits"` + ); + } + + // Always assume that if the current version is a prerelease, then the next version should be a prerelease. + // Users must manually graduate from a prerelease to a release by providing an explicit specifier. + if (prerelease(currentVersion)) { + specifier = 'prerelease'; + log( + `📄 Resolved the specifier as "${specifier}" since the current version is a prerelease.` + ); + break; + } + + specifier = await resolveSemverSpecifierFromConventionalCommits( + lastMatchingGitTag, + options.projectGraph, + projects.map((p) => p.name) + ); + + log( + `📄 Resolved the specifier as "${specifier}" using git history and the conventional commits standard.` + ); + break; + case 'prompt': + specifier = await resolveSemverSpecifierFromPrompt( + `What kind of change is this for the ${projects.length} matched projects(s) within release group "${options.releaseGroupName}"?`, + `What is the exact version for the ${projects.length} matched project(s) within release group "${options.releaseGroupName}"?` + ); + break; + default: + throw new Error( + `Invalid specifierSource "${specifierSource}" provided. Must be one of "prompt" or "conventional-commits"` + ); + } + } + + if (!specifier) { + log( + `🚫 Skipping versioning "${projectPackageJson.name}" as no changes were detected.` + ); + continue; + } + // Resolve any local package dependencies for this project (before applying the new version) const localPackageDependencies = resolveLocalPackageDependencies( tree, @@ -143,7 +244,7 @@ To fix this you will either need to add a package.json file at that location, or const newVersion = deriveNewSemverVersion( currentVersion, - options.specifier, + specifier, options.preid ); @@ -217,3 +318,18 @@ function getColor(projectName: string) { return colors[colorIndex]; } + +async function getNpmRegistry() { + // Must be non-blocking async to allow spinner to render + return await new Promise((resolve, reject) => { + exec('npm config get registry', (error, stdout, stderr) => { + if (error) { + return reject(error); + } + if (stderr) { + return reject(stderr); + } + return resolve(stdout.trim()); + }); + }); +} diff --git a/packages/js/src/generators/release-version/schema.json b/packages/js/src/generators/release-version/schema.json index 58989ef57c735e..4284b62ccb5d44 100644 --- a/packages/js/src/generators/release-version/schema.json +++ b/packages/js/src/generators/release-version/schema.json @@ -19,7 +19,17 @@ }, "specifier": { "type": "string", - "description": "Exact version or semver keyword to apply to the selected release group. NOTE: This should be set on the release group level, not the project level." + "description": "Exact version or semver keyword to apply to the selected release group. Overrides specifierSource." + }, + "releaseGroupName": { + "type": "string", + "description": "The name of the release group being versioned in the current execution." + }, + "specifierSource": { + "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"] }, "preid": { "type": "string", @@ -33,7 +43,7 @@ "type": "string", "default": "disk", "description": "Which approach to use to determine the current version of the project.", - "enum": ["registry", "disk"] + "enum": ["registry", "disk", "git-tag"] }, "currentVersionResolverMetadata": { "type": "object", @@ -41,5 +51,5 @@ "default": {} } }, - "required": ["projects", "projectGraph", "specifier"] + "required": ["projects", "projectGraph", "releaseGroupName"] } diff --git a/packages/nx/src/command-line/release/changelog.ts b/packages/nx/src/command-line/release/changelog.ts index 9f8e4b25efe086..48a88a0ffaaac0 100644 --- a/packages/nx/src/command-line/release/changelog.ts +++ b/packages/nx/src/command-line/release/changelog.ts @@ -37,6 +37,7 @@ export async function changelogHandler(args: ChangelogOptions): Promise { const releaseVersion = args.version.startsWith(tagVersionPrefix) ? args.version : `${tagVersionPrefix}${args.version}`; + const tagMatchingPattern = `${tagVersionPrefix}*.*.*`; // We are either creating/previewing a changelog file, a Github release, or both let logTitle = args.dryRun ? 'Previewing a ' : 'Generating a '; @@ -57,7 +58,7 @@ export async function changelogHandler(args: ChangelogOptions): Promise { title: logTitle, }); - const from = args.from || (await getLastGitTag()); + const from = args.from || (await getLastGitTag(tagMatchingPattern)); if (!from) { output.error({ title: `Unable to determine the previous git tag, please provide an explicit git reference using --from`, diff --git a/packages/nx/src/command-line/release/utils/git.ts b/packages/nx/src/command-line/release/utils/git.ts index 2612d381c3e052..fde55d55f986d7 100644 --- a/packages/nx/src/command-line/release/utils/git.ts +++ b/packages/nx/src/command-line/release/utils/git.ts @@ -28,13 +28,25 @@ export interface GitCommit extends RawGitCommit { references: Reference[]; authors: GitCommitAuthor[]; isBreaking: boolean; + affectedFiles: string[]; } -export async function getLastGitTag() { - const r = await execCommand('git', ['describe', '--tags', '--abbrev=0']) - .then((r) => r.split('\n').filter(Boolean)) - .catch(() => []); - return r.at(-1); +export async function getLastGitTag(matchingPattern: string) { + const matchingTags = await execCommand('git', [ + 'tag', + '-l', + matchingPattern, + '--sort', + '-v:refname', + ]).then((r) => + r + .trim() + .split('\n') + .map((t) => t.trim()) + .filter(Boolean) + ); + + return matchingTags.length ? matchingTags[0] : null; } export async function getGitDiff( @@ -77,6 +89,7 @@ const ConventionalCommitRegex = const CoAuthoredByRegex = /co-authored-by:\s*(?.+)(<(?.+)>)/gim; const PullRequestRE = /\([ a-z]*(#\d+)\s*\)/gm; const IssueRE = /(#\d+)/gm; +const ChangedFileRegex = /(A|M|D|R\d*|C\d*)\t([^\t\n]*)\t?(.*)?/gm; export function parseGitCommit(commit: RawGitCommit): GitCommit | null { const match = commit.message.match(ConventionalCommitRegex); @@ -115,6 +128,19 @@ export function parseGitCommit(commit: RawGitCommit): GitCommit | null { }); } + // Extract file changes from commit body + const affectedFiles = Array.from( + commit.body.matchAll(ChangedFileRegex) + ).reduce( + ( + prev, + [fullLine, changeType, file1, file2]: [string, string, string, string?] + ) => + // file2 only exists for some change types, such as renames + file2 ? [...prev, file1, file2] : [...prev, file1], + [] as string[] + ); + return { ...commit, authors, @@ -123,6 +149,7 @@ export function parseGitCommit(commit: RawGitCommit): GitCommit | null { scope, references, isBreaking, + affectedFiles, }; } diff --git a/packages/nx/src/command-line/release/utils/markdown.spec.ts b/packages/nx/src/command-line/release/utils/markdown.spec.ts index 013ac62c061a44..d7ef382119be39 100644 --- a/packages/nx/src/command-line/release/utils/markdown.spec.ts +++ b/packages/nx/src/command-line/release/utils/markdown.spec.ts @@ -1,5 +1,5 @@ import { GitCommit } from './git'; -import { parseChangelogMarkdown, generateMarkdown } from './markdown'; +import { generateMarkdown, parseChangelogMarkdown } from './markdown'; describe('markdown utils', () => { describe('generateMarkdown()', () => { @@ -29,6 +29,7 @@ describe('markdown utils', () => { }, ], isBreaking: false, + affectedFiles: [], }, { message: 'feat(pkg-b): and another new capability', @@ -54,6 +55,7 @@ describe('markdown utils', () => { }, ], isBreaking: false, + affectedFiles: [], }, { message: 'feat(pkg-a): new hotness', @@ -79,6 +81,7 @@ describe('markdown utils', () => { }, ], isBreaking: false, + affectedFiles: [], }, { message: 'feat(pkg-b): brand new thing', @@ -104,6 +107,7 @@ describe('markdown utils', () => { }, ], isBreaking: false, + affectedFiles: [], }, { message: 'fix(pkg-a): squashing bugs', @@ -129,6 +133,7 @@ describe('markdown utils', () => { }, ], isBreaking: false, + affectedFiles: [], }, ]; diff --git a/packages/nx/src/command-line/release/utils/resolve-semver-specifier.ts b/packages/nx/src/command-line/release/utils/resolve-semver-specifier.ts new file mode 100644 index 00000000000000..30a9ed19b2c0b3 --- /dev/null +++ b/packages/nx/src/command-line/release/utils/resolve-semver-specifier.ts @@ -0,0 +1,86 @@ +import { prompt } from 'enquirer'; +import { RELEASE_TYPES, valid } from 'semver'; +import { ProjectGraph } from '../../../config/project-graph'; +import { createProjectFileMapUsingProjectGraph } from '../../../project-graph/file-map-utils'; +import { getGitDiff, parseCommits } from './git'; +import { ConventionalCommitsConfig, determineSemverChange } from './semver'; + +// TODO: Extract config to nx.json configuration when adding changelog customization +const CONVENTIONAL_COMMITS_CONFIG: ConventionalCommitsConfig = { + types: { + feat: { + semver: 'minor', + }, + fix: { + semver: 'patch', + }, + }, +}; + +export async function resolveSemverSpecifierFromConventionalCommits( + from: string, + projectGraph: ProjectGraph, + projectNames: string[] +): Promise { + const commits = await getGitDiff(from); + const parsedCommits = parseCommits(commits); + const projectFileMap = await createProjectFileMapUsingProjectGraph( + projectGraph + ); + const filesInReleaseGroup = new Set( + projectNames.reduce( + (files, p) => [...files, ...projectFileMap[p].map((f) => f.file)], + [] as string[] + ) + ); + + const relevantCommits = parsedCommits.filter((c) => + c.affectedFiles.some((f) => filesInReleaseGroup.has(f)) + ); + + return determineSemverChange(relevantCommits, CONVENTIONAL_COMMITS_CONFIG); +} + +export async function resolveSemverSpecifierFromPrompt( + selectionMessage: string, + customVersionMessage: string +): Promise { + try { + const reply = await prompt<{ specifier: string }>([ + { + name: 'specifier', + message: selectionMessage, + type: 'select', + choices: [ + ...RELEASE_TYPES.map((t) => ({ name: t, message: t })), + { + name: 'custom', + message: 'Custom exact version', + }, + ], + }, + ]); + if (reply.specifier !== 'custom') { + return reply.specifier; + } else { + const reply = await prompt<{ specifier: string }>([ + { + name: 'specifier', + message: customVersionMessage, + type: 'input', + validate: (input) => { + if (valid(input)) { + return true; + } + return 'Please enter a valid semver version'; + }, + }, + ]); + return reply.specifier; + } + } catch { + // TODO: log the error to the user? + // We need to catch the error from enquirer prompt, otherwise yargs will print its help + process.exit(1); + } +} diff --git a/packages/nx/src/command-line/release/utils/semver.spec.ts b/packages/nx/src/command-line/release/utils/semver.spec.ts index c315923e6aef7d..a87781bf584c59 100644 --- a/packages/nx/src/command-line/release/utils/semver.spec.ts +++ b/packages/nx/src/command-line/release/utils/semver.spec.ts @@ -1,70 +1,163 @@ -import { deriveNewSemverVersion } from './semver'; +import { GitCommit } from './git'; +import { + ConventionalCommitsConfig, + deriveNewSemverVersion, + determineSemverChange, +} from './semver'; -describe('deriveNewSemverVersion()', () => { - const testCases = [ - { - input: { - currentVersion: '1.0.0', - specifier: 'major', +describe('semver', () => { + describe('deriveNewSemverVersion()', () => { + const testCases = [ + { + input: { + currentVersion: '1.0.0', + specifier: 'major', + }, + expected: '2.0.0', }, - expected: '2.0.0', - }, - { - input: { - currentVersion: '1.0.0', - specifier: 'minor', + { + input: { + currentVersion: '1.0.0', + specifier: 'minor', + }, + expected: '1.1.0', }, - expected: '1.1.0', - }, - { - input: { - currentVersion: '1.0.0', - specifier: 'patch', + { + input: { + currentVersion: '1.0.0', + specifier: 'patch', + }, + expected: '1.0.1', }, - expected: '1.0.1', - }, - { - input: { - currentVersion: '1.0.0', - specifier: '99.9.9', // exact version + { + input: { + currentVersion: '1.0.0', + specifier: '99.9.9', // exact version + }, + expected: '99.9.9', }, - expected: '99.9.9', - }, - { - input: { - currentVersion: '1.0.0', - specifier: '99.9.9', // exact version + { + input: { + currentVersion: '1.0.0', + specifier: '99.9.9', // exact version + }, + expected: '99.9.9', }, - expected: '99.9.9', - }, - ]; + ]; - testCases.forEach((c, i) => { - it(`should derive an appropriate semver version, CASE: ${i}`, () => { - expect( - deriveNewSemverVersion(c.input.currentVersion, c.input.specifier) - ).toEqual(c.expected); + testCases.forEach((c, i) => { + it(`should derive an appropriate semver version, CASE: ${i}`, () => { + expect( + deriveNewSemverVersion(c.input.currentVersion, c.input.specifier) + ).toEqual(c.expected); + }); }); - }); - it('should throw if the current version is not a valid semver version', () => { - expect(() => - deriveNewSemverVersion('not-a-valid-semver-version', 'minor') - ).toThrowErrorMatchingInlineSnapshot( - `"Invalid semver version "not-a-valid-semver-version" provided."` - ); - expect(() => - deriveNewSemverVersion('major', 'minor') - ).toThrowErrorMatchingInlineSnapshot( - `"Invalid semver version "major" provided."` - ); + it('should throw if the current version is not a valid semver version', () => { + expect(() => + deriveNewSemverVersion('not-a-valid-semver-version', 'minor') + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid semver version "not-a-valid-semver-version" provided."` + ); + expect(() => + deriveNewSemverVersion('major', 'minor') + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid semver version "major" provided."` + ); + }); + + it('should throw if the new version specifier is not a valid semver version or semver keyword', () => { + expect(() => + deriveNewSemverVersion('1.0.0', 'foo') + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid semver version specifier "foo" provided. Please provide either a valid semver version or a valid semver version keyword."` + ); + }); }); + // tests for determineSemverChange() + describe('determineSemverChange()', () => { + const config: ConventionalCommitsConfig = { + types: { + feat: { + semver: 'minor', + }, + fix: { + semver: 'patch', + }, + chore: { + semver: 'patch', + }, + }, + }; + + const featNonBreakingCommit: GitCommit = { + type: 'feat', + isBreaking: false, + } as GitCommit; + const featBreakingCommit: GitCommit = { + type: 'feat', + isBreaking: true, + } as GitCommit; + const fixCommit: GitCommit = { + type: 'fix', + isBreaking: false, + } as GitCommit; + const choreCommit: GitCommit = { + type: 'chore', + isBreaking: false, + } as GitCommit; + const unknownTypeCommit: GitCommit = { + type: 'perf', + isBreaking: false, + } as GitCommit; + const unknownTypeBreakingCommit: GitCommit = { + type: 'perf', + isBreaking: true, + } as GitCommit; + + it('should return the highest bump level of all commits', () => { + expect( + determineSemverChange( + [fixCommit, featNonBreakingCommit, choreCommit], + config + ) + ).toEqual('minor'); + }); - it('should throw if the new version specifier is not a valid semver version or semver keyword', () => { - expect(() => - deriveNewSemverVersion('1.0.0', 'foo') - ).toThrowErrorMatchingInlineSnapshot( - `"Invalid semver version specifier "foo" provided. Please provide either a valid semver version or a valid semver version keyword."` - ); + it('should return major if any commits are breaking', () => { + expect( + determineSemverChange( + [fixCommit, featBreakingCommit, featNonBreakingCommit, choreCommit], + config + ) + ).toEqual('major'); + }); + + it('should return major if any commits (including unknown types) are breaking', () => { + expect( + determineSemverChange( + [ + fixCommit, + unknownTypeBreakingCommit, + featNonBreakingCommit, + choreCommit, + ], + config + ) + ).toEqual('major'); + }); + + it('should return patch when given only patch commits, ignoring unknown types', () => { + expect( + determineSemverChange( + [fixCommit, choreCommit, unknownTypeCommit], + config + ) + ).toEqual('patch'); + }); + + it('should return null when given only unknown type commits', () => { + expect(determineSemverChange([unknownTypeCommit], config)).toEqual(null); + }); }); }); diff --git a/packages/nx/src/command-line/release/utils/semver.ts b/packages/nx/src/command-line/release/utils/semver.ts index 7e4a2daf5c0666..ffd86d9ad530ca 100644 --- a/packages/nx/src/command-line/release/utils/semver.ts +++ b/packages/nx/src/command-line/release/utils/semver.ts @@ -1,9 +1,49 @@ +/** + * Special thanks to changelogen for the original inspiration for many of these utilities: + * https://github.com/unjs/changelogen + */ + import { RELEASE_TYPES, ReleaseType, inc, valid } from 'semver'; +import { GitCommit } from './git'; export function isRelativeVersionKeyword(val: string): val is ReleaseType { return RELEASE_TYPES.includes(val as ReleaseType); } +export function isValidSemverSpecifier(specifier: string): boolean { + return ( + specifier && !!(valid(specifier) || isRelativeVersionKeyword(specifier)) + ); +} + +export interface ConventionalCommitsConfig { + types: { + [type: string]: { + semver: 'patch' | 'minor' | 'major'; + }; + }; +} + +// https://github.com/unjs/changelogen/blob/main/src/semver.ts +export function determineSemverChange( + commits: GitCommit[], + config: ConventionalCommitsConfig +): 'patch' | 'minor' | 'major' | null { + let [hasMajor, hasMinor, hasPatch] = [false, false, false]; + for (const commit of commits) { + const semverType = config.types[commit.type]?.semver; + if (semverType === 'major' || commit.isBreaking) { + hasMajor = true; + } else if (semverType === 'minor') { + hasMinor = true; + } else if (semverType === 'patch') { + hasPatch = true; + } + } + + return hasMajor ? 'major' : hasMinor ? 'minor' : hasPatch ? 'patch' : null; +} + export function deriveNewSemverVersion( currentSemverVersion: string, semverSpecifier: string, diff --git a/packages/nx/src/command-line/release/version.ts b/packages/nx/src/command-line/release/version.ts index 9da7c211d10689..a3c0856ad89ead 100644 --- a/packages/nx/src/command-line/release/version.ts +++ b/packages/nx/src/command-line/release/version.ts @@ -1,8 +1,6 @@ import * as chalk from 'chalk'; -import * as enquirer from 'enquirer'; import { readFileSync } from 'node:fs'; import { relative } from 'node:path'; -import { RELEASE_TYPES, valid } from 'semver'; import { Generator } from '../../config/misc-interfaces'; import { readNxJson } from '../../config/nx-json'; import { @@ -32,7 +30,6 @@ import { } from './config/config'; import { filterReleaseGroups } from './config/filter-release-groups'; import { printDiff } from './utils/print-changes'; -import { isRelativeVersionKeyword } from './utils/semver'; // Reexport for use in plugin release-version generator implementations export { deriveNewSemverVersion } from './utils/semver'; @@ -40,11 +37,13 @@ export { deriveNewSemverVersion } from './utils/semver'; export interface ReleaseVersionGeneratorSchema { // The projects being versioned in the current execution projects: ProjectGraphProjectNode[]; + releaseGroupName: string; projectGraph: ProjectGraph; - specifier: string; + specifier?: string; + specifierSource?: 'prompt' | 'conventional-commits'; preid?: string; packageRoot?: string; - currentVersionResolver?: 'registry' | 'disk'; + currentVersionResolver?: 'registry' | 'disk' | 'git-tag'; currentVersionResolverMetadata?: Record; } @@ -99,14 +98,8 @@ export async function versionHandler(args: VersionOptions): Promise { configGeneratorOptions: releaseGroup.version.generatorOptions, }); - const semverSpecifier = await resolveSemverSpecifier( - args.specifier, - `What kind of change is this for the ${ - releaseGroupToFilteredProjects.get(releaseGroup).size - } matched project(s) within release group "${releaseGroupName}"?`, - `What is the exact version for the ${ - releaseGroupToFilteredProjects.get(releaseGroup).size - } matched project(s) within release group "${releaseGroupName}"?` + const releaseGroupProjectNames = Array.from( + releaseGroupToFilteredProjects.get(releaseGroup) ); await runVersionOnProjects( @@ -115,8 +108,8 @@ export async function versionHandler(args: VersionOptions): Promise { args, tree, generatorData, - Array.from(releaseGroupToFilteredProjects.get(releaseGroup)), - semverSpecifier + releaseGroupProjectNames, + releaseGroupName ); } @@ -140,16 +133,6 @@ export async function versionHandler(args: VersionOptions): Promise { configGeneratorOptions: releaseGroup.version.generatorOptions, }); - const semverSpecifier = await resolveSemverSpecifier( - args.specifier, - releaseGroupName === CATCH_ALL_RELEASE_GROUP - ? `What kind of change is this for all packages?` - : `What kind of change is this for release group "${releaseGroupName}"?`, - releaseGroupName === CATCH_ALL_RELEASE_GROUP - ? `What is the exact version for all packages?` - : `What is the exact version for release group "${releaseGroupName}"?` - ); - await runVersionOnProjects( projectGraph, nxJson, @@ -157,7 +140,7 @@ export async function versionHandler(args: VersionOptions): Promise { tree, generatorData, releaseGroup.projects, - semverSpecifier + releaseGroupName ); } @@ -173,30 +156,16 @@ async function runVersionOnProjects( tree: Tree, generatorData: GeneratorData, projectNames: string[], - newVersionSpecifier: string + releaseGroupName: string ) { - // Should be impossible state - if (!newVersionSpecifier) { - output.error({ - title: `No version or semver keyword could be determined`, - }); - process.exit(1); - } - // Specifier could be user provided so we need to validate it - if ( - !valid(newVersionSpecifier) && - !isRelativeVersionKeyword(newVersionSpecifier) - ) { - output.error({ - title: `The given version specifier "${newVersionSpecifier}" is not valid. You provide an exact version or a valid semver keyword such as "major", "minor", "patch", etc.`, - }); - process.exit(1); - } - const generatorOptions: ReleaseVersionGeneratorSchema = { projects: projectNames.map((p) => projectGraph.nodes[p]), projectGraph, - specifier: newVersionSpecifier, + releaseGroupName: + releaseGroupName === CATCH_ALL_RELEASE_GROUP + ? 'default' + : releaseGroupName, + specifier: args.specifier ?? '', preid: args.preid, ...generatorData.configGeneratorOptions, }; @@ -259,55 +228,6 @@ function printChanges(tree: Tree, isDryRun: boolean) { } } -async function resolveSemverSpecifier( - cliArgSpecifier: string, - selectionMessage: string, - customVersionMessage: string -): Promise { - try { - let newVersionSpecifier = cliArgSpecifier; - // If the user didn't provide a new version specifier directly on the CLI, prompt for one - if (!newVersionSpecifier) { - const reply = await enquirer.prompt<{ specifier: string }>([ - { - name: 'specifier', - message: selectionMessage, - type: 'select', - choices: [ - ...RELEASE_TYPES.map((t) => ({ name: t, message: t })), - { - name: 'custom', - message: 'Custom exact version', - }, - ], - }, - ]); - if (reply.specifier !== 'custom') { - newVersionSpecifier = reply.specifier; - } else { - const reply = await enquirer.prompt<{ specifier: string }>([ - { - name: 'specifier', - message: customVersionMessage, - type: 'input', - validate: (input) => { - if (valid(input)) { - return true; - } - return 'Please enter a valid semver version'; - }, - }, - ]); - newVersionSpecifier = reply.specifier; - } - } - return newVersionSpecifier; - } catch { - // We need to catch the error from enquirer prompt, otherwise yargs will print its help - process.exit(1); - } -} - function extractGeneratorCollectionAndName( description: string, generatorString: string