diff --git a/docs/generated/packages/js/generators/release-version.json b/docs/generated/packages/js/generators/release-version.json index 4068844d0caf2..7c3a4adff021a 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." + }, + "releaseGroup": { + "type": "object", + "description": "The resolved release group configuration, including name, relevant to all projects 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", "releaseGroup"], "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 3f28cc0372356..9813513e1a4d5 100644 --- a/e2e/release/src/release.test.ts +++ b/e2e/release/src/release.test.ts @@ -639,5 +639,143 @@ 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'], + releaseTagPattern: 'xx{version}', + version: { + generator: '@nx/js:release-version', + generatorOptions: { + // Resolve the latest version from the git tag + currentVersionResolver: 'git-tag', + }, + }, + }, + }, + }; + return nxJson; + }); + + // Add a git tag to the repo + await runCommandAsync(`git tag xx1100.0.0`); + + 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'], + releaseTagPattern: 'xx{version}', + version: { + generator: '@nx/js:release-version', + generatorOptions: { + specifierSource: 'conventional-commits', + currentVersionResolver: 'git-tag', + }, + }, + }, + }, + }; + 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); + + await runCommandAsync( + `git add ${pkg1}/my-file.txt && git commit -m "feat!: add new file"` + ); + + 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/executors/release-publish/release-publish.impl.ts b/packages/js/src/executors/release-publish/release-publish.impl.ts index 6187ab32470b4..bfce09f905c82 100644 --- a/packages/js/src/executors/release-publish/release-publish.impl.ts +++ b/packages/js/src/executors/release-publish/release-publish.impl.ts @@ -33,13 +33,13 @@ export default async function runExecutor( const packageJsonPath = joinPathFragments(packageRoot, 'package.json'); const projectPackageJson = readJsonFile(packageJsonPath); - const name = projectPackageJson.name; + const packageName = projectPackageJson.name; // If package and project name match, we can make log messages terser let packageTxt = - name === context.projectName - ? `package "${name}"` - : `package "${name}" from project "${context.projectName}"`; + packageName === context.projectName + ? `package "${packageName}"` + : `package "${packageName}" from project "${context.projectName}"`; if (projectPackageJson.private === true) { console.warn( @@ -80,7 +80,7 @@ export default async function runExecutor( const stdoutData = JSON.parse(output.toString()); // If npm workspaces are in use, the publish output will nest the data under the package name, so we normalize it first - const normalizedStdoutData = stdoutData[context.projectName!] ?? stdoutData; + const normalizedStdoutData = stdoutData[packageName] ?? stdoutData; logTar(normalizedStdoutData); if (options.dryRun) { diff --git a/packages/js/src/generators/release-version/release-version.spec.ts b/packages/js/src/generators/release-version/release-version.spec.ts index e26bb82f1b7c3..2edade0ba0e5b 100644 --- a/packages/js/src/generators/release-version/release-version.spec.ts +++ b/packages/js/src/generators/release-version/release-version.spec.ts @@ -1,5 +1,6 @@ -import { ProjectGraph, Tree, readJson } from '@nx/devkit'; +import { ProjectGraph, Tree, output, readJson } from '@nx/devkit'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { ReleaseGroupWithName } from 'nx/src/command-line/release/config/filter-release-groups'; import { releaseVersionGenerator } from './release-version'; import { createWorkspaceWithPackageDependencies } from './test-utils/create-workspace-with-package-dependencies'; @@ -58,6 +59,7 @@ describe('release-version', () => { projectGraph, specifier: 'major', currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup(), }); expect(readJson(tree, 'libs/my-lib/package.json').version).toEqual('1.0.0'); @@ -66,6 +68,7 @@ describe('release-version', () => { projectGraph, specifier: 'minor', currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup(), }); expect(readJson(tree, 'libs/my-lib/package.json').version).toEqual('1.1.0'); @@ -74,6 +77,7 @@ describe('release-version', () => { projectGraph, specifier: 'patch', currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup(), }); expect(readJson(tree, 'libs/my-lib/package.json').version).toEqual('1.1.1'); @@ -82,6 +86,7 @@ describe('release-version', () => { projectGraph, specifier: '1.2.3', // exact version currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup(), }); expect(readJson(tree, 'libs/my-lib/package.json').version).toEqual('1.2.3'); }); @@ -92,6 +97,7 @@ describe('release-version', () => { projectGraph, specifier: 'major', currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup(), }); expect(readJson(tree, 'libs/my-lib/package.json')).toMatchInlineSnapshot(` @@ -130,19 +136,42 @@ describe('release-version', () => { tree.delete('libs/my-lib/package.json'); }); - it(`should error with guidance when not all of the given projects are appropriate for JS versioning`, async () => { - await expect( - releaseVersionGenerator(tree, { - projects: Object.values(projectGraph.nodes), // version all projects - projectGraph, - specifier: 'major', - currentVersionResolver: 'disk', - }) - ).rejects.toThrowErrorMatchingInlineSnapshot(` - "The project "my-lib" does not have a package.json available at libs/my-lib/package.json. - - To fix this you will either need to add a package.json file at that location, or configure "release" within your nx.json to exclude "my-lib" from the current release group, or amend the packageRoot configuration to point to where the package.json should be." - `); + it(`should exit with code one and print guidance when not all of the given projects are appropriate for JS versioning`, async () => { + const processSpy = jest.spyOn(process, 'exit').mockImplementation(() => { + return undefined as never; + }); + const outputSpy = jest.spyOn(output, 'error').mockImplementation(() => { + return undefined as never; + }); + + await releaseVersionGenerator(tree, { + projects: Object.values(projectGraph.nodes), // version all projects + projectGraph, + specifier: 'major', + currentVersionResolver: 'disk', + releaseGroup: createReleaseGroup(), + }); + + expect(outputSpy).toHaveBeenCalledWith({ + title: `The project "my-lib" does not have a package.json available at libs/my-lib/package.json. + +To fix this you will either need to add a package.json file at that location, or configure "release" within your nx.json to exclude "my-lib" from the current release group, or amend the packageRoot configuration to point to where the package.json should be.`, + }); + + expect(processSpy).toHaveBeenCalledWith(1); + + processSpy.mockRestore(); + outputSpy.mockRestore(); }); }); }); + +function createReleaseGroup( + partialGroup: Partial = {} +): ReleaseGroupWithName { + return { + name: 'default', + releaseTagPattern: '{projectName}@v{version}', + ...partialGroup, + } as ReleaseGroupWithName; +} diff --git a/packages/js/src/generators/release-version/release-version.ts b/packages/js/src/generators/release-version/release-version.ts index df141829f614a..32244e5b6336f 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 { getLatestGitTagForPattern } 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,176 +27,297 @@ export async function releaseVersionGenerator( tree: Tree, options: ReleaseVersionGeneratorSchema ) { - const projects = options.projects; - - // Resolve any custom package roots for each project upfront as they will need to be reused during dependency resolution - const projectNameToPackageRootMap = new Map(); - for (const project of projects) { - projectNameToPackageRootMap.set( - project.name, - // Default to the project root if no custom packageRoot - !options.packageRoot - ? project.data.root - : interpolate(options.packageRoot, { - workspaceRoot: '', - projectRoot: project.data.root, - projectName: project.name, - }) - ); - } + try { + // If the user provided a specifier, validate that it is valid semver or a relative semver keyword + 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.` + ); + } - let currentVersion: string; + const projects = options.projects; - for (const project of projects) { - const projectName = project.name; - const packageRoot = projectNameToPackageRootMap.get(projectName); - const packageJsonPath = joinPathFragments(packageRoot, 'package.json'); - const workspaceRelativePackageJsonPath = relative( - workspaceRoot, - packageJsonPath - ); + // Resolve any custom package roots for each project upfront as they will need to be reused during dependency resolution + const projectNameToPackageRootMap = new Map(); + for (const project of projects) { + projectNameToPackageRootMap.set( + project.name, + // Default to the project root if no custom packageRoot + !options.packageRoot + ? project.data.root + : interpolate(options.packageRoot, { + workspaceRoot: '', + projectRoot: project.data.root, + projectName: project.name, + }) + ); + } - const color = getColor(projectName); - const log = (msg: string) => { - console.log(color.instance.bold(projectName) + ' ' + msg); - }; + let currentVersion: string; + + // only used for options.currentVersionResolver === 'git-tag', but + // must be declared here in order to reuse it for additional projects + let latestMatchingGitTag: { tag: string; extractedVersion: 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); + const packageJsonPath = joinPathFragments(packageRoot, 'package.json'); + const workspaceRelativePackageJsonPath = relative( + workspaceRoot, + packageJsonPath + ); + + const color = getColor(projectName); + const log = (msg: string) => { + console.log(color.instance.bold(projectName) + ' ' + msg); + }; + + if (!tree.exists(packageJsonPath)) { + throw new Error( + `The project "${projectName}" does not have a package.json available at ${workspaceRelativePackageJsonPath}. - if (!tree.exists(packageJsonPath)) { - throw new Error( - `The project "${projectName}" does not have a package.json available at ${workspaceRelativePackageJsonPath}. - To fix this you will either need to add a package.json file at that location, or configure "release" within your nx.json to exclude "${projectName}" from the current release group, or amend the packageRoot configuration to point to where the package.json should be.` + ); + } + + output.logSingleLine( + `Running release version for project: ${color.instance.bold( + project.name + )}` ); - } - output.logSingleLine( - `Running release version for project: ${color.instance.bold( - project.name - )}` - ); - - const projectPackageJson = readJson(tree, packageJsonPath); - log( - `🔍 Reading data for package "${projectPackageJson.name}" from ${workspaceRelativePackageJsonPath}` - ); - - const { name: packageName, version: currentVersionFromDisk } = - projectPackageJson; - - switch (options.currentVersionResolver) { - case 'registry': { - const metadata = options.currentVersionResolverMetadata; - const registry = metadata?.registry ?? '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 - if (!currentVersion) { - const spinner = ora( - `${Array.from(new Array(projectName.length + 3)).join( - ' ' - )}Resolving the current version for tag "${tag}" on ${registry}` - ); - spinner.color = - color.spinnerColor as typeof colors[number]['spinnerColor']; - spinner.start(); - - // Must be non-blocking async to allow spinner to render - currentVersion = await new Promise((resolve, reject) => { - exec( - `npm view ${packageName} version --registry=${registry} --tag=${tag}`, - (error, stdout, stderr) => { - if (error) { - return reject(error); - } - if (stderr) { - return reject(stderr); - } - return resolve(stdout.trim()); - } + const projectPackageJson = readJson(tree, packageJsonPath); + log( + `🔍 Reading data for package "${projectPackageJson.name}" from ${workspaceRelativePackageJsonPath}` + ); + + const { name: packageName, version: currentVersionFromDisk } = + projectPackageJson; + + switch (options.currentVersionResolver) { + case 'registry': { + const metadata = options.currentVersionResolverMetadata; + 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 + if (!currentVersion) { + const spinner = ora( + `${Array.from(new Array(projectName.length + 3)).join( + ' ' + )}Resolving the current version for tag "${tag}" on ${registry}` ); - }); + spinner.color = + color.spinnerColor as typeof colors[number]['spinnerColor']; + spinner.start(); + + // Must be non-blocking async to allow spinner to render + currentVersion = await new Promise((resolve, reject) => { + exec( + `npm view ${packageName} version --registry=${registry} --tag=${tag}`, + (error, stdout, stderr) => { + if (error) { + return reject(error); + } + if (stderr) { + return reject(stderr); + } + return resolve(stdout.trim()); + } + ); + }); - spinner.stop(); + spinner.stop(); + log( + `📄 Resolved the current version as ${currentVersion} for tag "${tag}" from registry ${registry}` + ); + } else { + log( + `📄 Using the current version ${currentVersion} already resolved from the registry ${registry}` + ); + } + break; + } + case 'disk': + currentVersion = currentVersionFromDisk; log( - `📄 Resolved the current version as ${currentVersion} for tag "${tag}" from registry ${registry}` + `📄 Resolved the current version as ${currentVersion} from ${packageJsonPath}` ); - } else { - log( - `📄 Using the current version ${currentVersion} already resolved from the registry ${registry}` + break; + case 'git-tag': { + if (!currentVersion) { + const releaseTagPattern = options.releaseGroup.releaseTagPattern; + latestMatchingGitTag = await getLatestGitTagForPattern( + releaseTagPattern, + { + projectName: project.name, + } + ); + if (!latestMatchingGitTag) { + throw new Error( + `No git tags matching pattern "${releaseTagPattern}" for project "${project.name}" were found.` + ); + } + + currentVersion = latestMatchingGitTag.extractedVersion; + log( + `📄 Resolved the current version as ${currentVersion} from git tag "${latestMatchingGitTag.tag}".` + ); + } else { + log( + `📄 Using the current version ${currentVersion} already resolved from git tag "${latestMatchingGitTag.tag}".` + ); + } + break; + } + default: + throw new Error( + `Invalid value for options.currentVersionResolver: ${options.currentVersionResolver}` ); + } + + if (options.specifier) { + log(`📄 Using the provided version specifier "${options.specifier}".`); + } + + // 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.releaseGroup.name}". Must be "git-tag" when "specifierSource" is "conventional-commits"` + ); + } + + specifier = await resolveSemverSpecifierFromConventionalCommits( + latestMatchingGitTag.tag, + options.projectGraph, + projects.map((p) => p.name) + ); + + if (!specifier) { + log( + `🚫 No changes were detected using git history and the conventional commits standard.` + ); + break; + } + + // 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.` + ); + } else { + 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.releaseGroup.name}"?`, + `What is the exact version for the ${projects.length} matched project(s) within release group "${options.releaseGroup.name}"?` + ); + break; + default: + throw new Error( + `Invalid specifierSource "${specifierSource}" provided. Must be one of "prompt" or "conventional-commits"` + ); } - break; } - case 'disk': - currentVersion = currentVersionFromDisk; + + if (!specifier) { log( - `📄 Resolved the current version as ${currentVersion} from ${packageJsonPath}` + `🚫 Skipping versioning "${projectPackageJson.name}" as no changes were detected.` ); - break; - default: - throw new Error( - `Invalid value for options.currentVersionResolver: ${options.currentVersionResolver}` - ); - } + continue; + } - // Resolve any local package dependencies for this project (before applying the new version) - const localPackageDependencies = resolveLocalPackageDependencies( - tree, - options.projectGraph, - projects, - projectNameToPackageRootMap - ); - - const newVersion = deriveNewSemverVersion( - currentVersion, - options.specifier, - options.preid - ); - - writeJson(tree, packageJsonPath, { - ...projectPackageJson, - version: newVersion, - }); + // Resolve any local package dependencies for this project (before applying the new version) + const localPackageDependencies = resolveLocalPackageDependencies( + tree, + options.projectGraph, + projects, + projectNameToPackageRootMap + ); - log( - `✍️ New version ${newVersion} written to ${workspaceRelativePackageJsonPath}` - ); + const newVersion = deriveNewSemverVersion( + currentVersion, + specifier, + options.preid + ); - const dependentProjects = Object.values(localPackageDependencies) - .filter((localPackageDependencies) => { - return localPackageDependencies.some( - (localPackageDependency) => - localPackageDependency.target === project.name - ); - }) - .flat(); + writeJson(tree, packageJsonPath, { + ...projectPackageJson, + version: newVersion, + }); - if (dependentProjects.length > 0) { log( - `✍️ Applying new version ${newVersion} to ${ - dependentProjects.length - } ${ - dependentProjects.length > 1 - ? 'packages which depend' - : 'package which depends' - } on ${project.name}` + `✍️ New version ${newVersion} written to ${workspaceRelativePackageJsonPath}` ); - } - for (const dependentProject of dependentProjects) { - updateJson( - tree, - joinPathFragments( - projectNameToPackageRootMap.get(dependentProject.source), - 'package.json' - ), - (json) => { - json[dependentProject.dependencyCollection][packageName] = newVersion; - return json; - } - ); + const dependentProjects = Object.values(localPackageDependencies) + .filter((localPackageDependencies) => { + return localPackageDependencies.some( + (localPackageDependency) => + localPackageDependency.target === project.name + ); + }) + .flat(); + + if (dependentProjects.length > 0) { + log( + `✍️ Applying new version ${newVersion} to ${ + dependentProjects.length + } ${ + dependentProjects.length > 1 + ? 'packages which depend' + : 'package which depends' + } on ${project.name}` + ); + } + + for (const dependentProject of dependentProjects) { + updateJson( + tree, + joinPathFragments( + projectNameToPackageRootMap.get(dependentProject.source), + 'package.json' + ), + (json) => { + json[dependentProject.dependencyCollection][packageName] = + newVersion; + return json; + } + ); + } } + } catch (e) { + if (process.env.NX_VERBOSE_LOGGING === 'true') { + output.error({ + title: e.message, + }); + // Dump the full stack trace in verbose mode + console.error(e); + } else { + output.error({ + title: e.message, + }); + } + process.exit(1); } } @@ -217,3 +345,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 58989ef57c735..ea28ef95dcdee 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." + }, + "releaseGroup": { + "type": "object", + "description": "The resolved release group configuration, including name, relevant to all projects 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", "releaseGroup"] } diff --git a/packages/nx/changelog-renderer/index.spec.ts b/packages/nx/changelog-renderer/index.spec.ts index 815189cc6b576..a496765803d2c 100644 --- a/packages/nx/changelog-renderer/index.spec.ts +++ b/packages/nx/changelog-renderer/index.spec.ts @@ -27,6 +27,7 @@ describe('defaultChangelogRenderer()', () => { }, ], isBreaking: false, + affectedFiles: [], }, { message: 'feat(pkg-b): and another new capability', @@ -52,6 +53,7 @@ describe('defaultChangelogRenderer()', () => { }, ], isBreaking: false, + affectedFiles: [], }, { message: 'feat(pkg-a): new hotness', @@ -77,6 +79,7 @@ describe('defaultChangelogRenderer()', () => { }, ], isBreaking: false, + affectedFiles: [], }, { message: 'feat(pkg-b): brand new thing', @@ -102,6 +105,7 @@ describe('defaultChangelogRenderer()', () => { }, ], isBreaking: false, + affectedFiles: [], }, { message: 'fix(pkg-a): squashing bugs', @@ -127,6 +131,7 @@ describe('defaultChangelogRenderer()', () => { }, ], isBreaking: false, + affectedFiles: [], }, ]; diff --git a/packages/nx/src/command-line/release/changelog.ts b/packages/nx/src/command-line/release/changelog.ts index 5645a5410442a..e168dd7a16698 100644 --- a/packages/nx/src/command-line/release/changelog.ts +++ b/packages/nx/src/command-line/release/changelog.ts @@ -24,7 +24,7 @@ import { filterReleaseGroups } from './config/filter-release-groups'; import { GitCommit, getGitDiff, - getLastGitTag, + getLatestGitTagForPattern, parseCommits, } from './utils/git'; import { @@ -107,7 +107,9 @@ export async function changelogHandler(args: ChangelogOptions): Promise { releaseTagPattern: nxReleaseConfig.releaseTagPattern, }); - const from = args.from || (await getLastGitTag()); + const from = + args.from || + (await getLatestGitTagForPattern(nxReleaseConfig.releaseTagPattern))?.tag; 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/config/config.spec.ts b/packages/nx/src/command-line/release/config/config.spec.ts index 9e66faada30a7..6b7cd9dfb5123 100644 --- a/packages/nx/src/command-line/release/config/config.spec.ts +++ b/packages/nx/src/command-line/release/config/config.spec.ts @@ -729,6 +729,101 @@ describe('createNxReleaseConfig()', () => { } `); }); + + it('should return an error if no projects can be resolved for a group', async () => { + const res = await createNxReleaseConfig(projectGraph, { + groups: { + 'group-1': { + projects: ['lib-does-not-exist'], + }, + }, + }); + expect(res).toMatchInlineSnapshot(` + { + "error": { + "code": "RELEASE_GROUP_MATCHES_NO_PROJECTS", + "data": { + "releaseGroupName": "group-1", + }, + }, + "nxReleaseConfig": null, + } + `); + }); + + it('should return an error if any matched projects do not have the required target specified', async () => { + const res = await createNxReleaseConfig( + { + ...projectGraph, + nodes: { + ...projectGraph.nodes, + 'project-without-target': { + name: 'project-without-target', + type: 'lib', + data: { + root: 'libs/project-without-target', + targets: {}, + } as any, + }, + }, + }, + { + groups: { + 'group-1': { + projects: '*', // using string form to ensure that is supported in addition to array form + }, + }, + }, + 'nx-release-publish' + ); + expect(res).toMatchInlineSnapshot(` + { + "error": { + "code": "PROJECTS_MISSING_TARGET", + "data": { + "projects": [ + "project-without-target", + ], + "targetName": "nx-release-publish", + }, + }, + "nxReleaseConfig": null, + } + `); + + const res2 = await createNxReleaseConfig( + { + ...projectGraph, + nodes: { + ...projectGraph.nodes, + 'another-project-without-target': { + name: 'another-project-without-target', + type: 'lib', + data: { + root: 'libs/another-project-without-target', + targets: {}, + } as any, + }, + }, + }, + {}, + 'nx-release-publish' + ); + expect(res2).toMatchInlineSnapshot(` + { + "error": { + "code": "PROJECTS_MISSING_TARGET", + "data": { + "projects": [ + "another-project-without-target", + ], + "targetName": "nx-release-publish", + }, + }, + "nxReleaseConfig": null, + } + `); + }); }); describe('release group config errors', () => { @@ -850,5 +945,49 @@ describe('createNxReleaseConfig()', () => { } `); }); + + it("should return an error if a group's releaseTagPattern has no {version} placeholder", async () => { + const res = await createNxReleaseConfig(projectGraph, { + groups: { + 'group-1': { + projects: '*', + releaseTagPattern: 'v', + }, + }, + }); + expect(res).toMatchInlineSnapshot(` + { + "error": { + "code": "RELEASE_GROUP_RELEASE_TAG_PATTERN_VERSION_PLACEHOLDER_MISSING_OR_EXCESSIVE", + "data": { + "releaseGroupName": "group-1", + }, + }, + "nxReleaseConfig": null, + } + `); + }); + + it("should return an error if a group's releaseTagPattern has more than one {version} placeholder", async () => { + const res = await createNxReleaseConfig(projectGraph, { + groups: { + 'group-1': { + projects: '*', + releaseTagPattern: '{version}v{version}', + }, + }, + }); + expect(res).toMatchInlineSnapshot(` + { + "error": { + "code": "RELEASE_GROUP_RELEASE_TAG_PATTERN_VERSION_PLACEHOLDER_MISSING_OR_EXCESSIVE", + "data": { + "releaseGroupName": "group-1", + }, + }, + "nxReleaseConfig": null, + } + `); + }); }); }); diff --git a/packages/nx/src/command-line/release/config/config.ts b/packages/nx/src/command-line/release/config/config.ts index ae86a9fb77240..b6e452474bd70 100644 --- a/packages/nx/src/command-line/release/config/config.ts +++ b/packages/nx/src/command-line/release/config/config.ts @@ -50,6 +50,7 @@ export type NxReleaseConfig = DeepRequired< export interface CreateNxReleaseConfigError { code: | 'RELEASE_GROUP_MATCHES_NO_PROJECTS' + | 'RELEASE_GROUP_RELEASE_TAG_PATTERN_VERSION_PLACEHOLDER_MISSING_OR_EXCESSIVE' | 'PROJECT_MATCHES_MULTIPLE_GROUPS' | 'PROJECTS_MISSING_TARGET'; data: Record; @@ -194,6 +195,20 @@ export async function createNxReleaseConfig( } } + // If provided, ensure release tag pattern is valid + if (releaseGroup.releaseTagPattern) { + const error = ensureReleaseTagPatternIsValid( + releaseGroup.releaseTagPattern, + releaseGroupName + ); + if (error) { + return { + error, + nxReleaseConfig: null, + }; + } + } + for (const project of matchingProjects) { if (alreadyMatchedProjects.has(project)) { return { @@ -290,6 +305,20 @@ export async function handleNxReleaseConfigError( }); } break; + case 'RELEASE_GROUP_RELEASE_TAG_PATTERN_VERSION_PLACEHOLDER_MISSING_OR_EXCESSIVE': + { + const nxJsonMessage = await resolveNxJsonConfigErrorMessage([ + 'release', + 'groups', + error.data.releaseGroupName as string, + 'releaseTagPattern', + ]); + output.error({ + title: `Release group "${error.data.releaseGroupName}" has an invalid releaseTagPattern. Please ensure the pattern contains exactly one instance of the "{version}" placeholder`, + bodyLines: [nxJsonMessage], + }); + } + break; default: throw new Error(`Unhandled error code: ${error.code}`); } @@ -297,6 +326,21 @@ export async function handleNxReleaseConfigError( process.exit(1); } +function ensureReleaseTagPatternIsValid( + releaseTagPattern: string, + releaseGroupName: string +): null | CreateNxReleaseConfigError { + // ensure that any provided releaseTagPattern contains exactly one instance of {version} + return releaseTagPattern.split('{version}').length === 2 + ? null + : { + code: 'RELEASE_GROUP_RELEASE_TAG_PATTERN_VERSION_PLACEHOLDER_MISSING_OR_EXCESSIVE', + data: { + releaseGroupName, + }, + }; +} + function ensureProjectsConfigIsArray( groups: NxJsonConfiguration['release']['groups'] ): NxReleaseConfig['groups'] { 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 99ab3f8bfac0c..2485bd53c16c8 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 @@ -3,7 +3,7 @@ import { findMatchingProjects } from '../../../utils/find-matching-projects'; import { output } from '../../../utils/output'; import { CATCH_ALL_RELEASE_GROUP, NxReleaseConfig } from './config'; -type ReleaseGroupWithName = NxReleaseConfig['groups'][string] & { +export type ReleaseGroupWithName = NxReleaseConfig['groups'][string] & { name: string; }; diff --git a/packages/nx/src/command-line/release/utils/exec-command.ts b/packages/nx/src/command-line/release/utils/exec-command.ts new file mode 100644 index 0000000000000..eb1f2f911df68 --- /dev/null +++ b/packages/nx/src/command-line/release/utils/exec-command.ts @@ -0,0 +1,32 @@ +import { spawn } from 'node:child_process'; + +export async function execCommand( + cmd: string, + args: string[], + options?: any +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { + ...options, + stdio: ['pipe', 'pipe', 'pipe'], // stdin, stdout, stderr + encoding: 'utf-8', + }); + + let stdout = ''; + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + + child.on('error', (error) => { + reject(error); + }); + + child.on('close', (code) => { + if (code !== 0) { + reject(new Error(`Command failed with exit code ${code}`)); + } else { + resolve(stdout); + } + }); + }); +} diff --git a/packages/nx/src/command-line/release/utils/git.spec.ts b/packages/nx/src/command-line/release/utils/git.spec.ts new file mode 100644 index 0000000000000..d57a6b764f9ce --- /dev/null +++ b/packages/nx/src/command-line/release/utils/git.spec.ts @@ -0,0 +1,123 @@ +import { getLatestGitTagForPattern } from './git'; + +jest.mock('./exec-command', () => ({ + execCommand: jest.fn(() => + Promise.resolve(` +x5.0.0 +release/4.😐2.2 +release/4.2.1 +release/my-lib-1@v4.2.1 +v4.0.1 +4.0.0-rc.1+build.1 +v4.0.0-beta.1 +my-lib-1@v4.0.0-beta.1 +my-lib-2v4.0.0-beta.1 +my-lib-34.0.0-beta.1 +4.0.0-beta.0-my-lib-1 +3.0.0-beta.0-alpha +1.0.0 +`) + ), +})); + +const releaseTagPatternTestCases = [ + { + pattern: 'v{version}', + projectName: 'my-lib-1', + expectedTag: 'v4.0.1', + expectedVersion: '4.0.1', + }, + { + pattern: 'x{version}', + projectName: 'my-lib-1', + expectedTag: 'x5.0.0', + expectedVersion: '5.0.0', + }, + { + pattern: 'release/{version}', + projectName: 'my-lib-1', + expectedTag: 'release/4.2.1', + expectedVersion: '4.2.1', + }, + { + pattern: 'release/{projectName}@v{version}', + projectName: 'my-lib-1', + expectedTag: 'release/my-lib-1@v4.2.1', + expectedVersion: '4.2.1', + }, + { + pattern: '{version}', + projectName: 'my-lib-1', + expectedTag: '4.0.0-rc.1+build.1', + expectedVersion: '4.0.0-rc.1+build.1', + }, + { + pattern: '{projectName}@v{version}', + projectName: 'my-lib-1', + expectedTag: 'my-lib-1@v4.0.0-beta.1', + expectedVersion: '4.0.0-beta.1', + }, + { + pattern: '{projectName}v{version}', + projectName: 'my-lib-2', + expectedTag: 'my-lib-2v4.0.0-beta.1', + expectedVersion: '4.0.0-beta.1', + }, + { + pattern: '{projectName}{version}', + projectName: 'my-lib-3', + expectedTag: 'my-lib-34.0.0-beta.1', + expectedVersion: '4.0.0-beta.1', + }, + { + pattern: '{version}-{projectName}', + projectName: 'my-lib-1', + expectedTag: '4.0.0-beta.0-my-lib-1', + expectedVersion: '4.0.0-beta.0', + }, + { + pattern: '{version}-{projectName}', + projectName: 'alpha', + expectedTag: '3.0.0-beta.0-alpha', + expectedVersion: '3.0.0-beta.0', + }, +]; + +describe('getLatestGitTagForPattern', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it.each(releaseTagPatternTestCases)( + 'should return tag $expectedTag for pattern $pattern', + async ({ pattern, projectName, expectedTag, expectedVersion }) => { + const result = await getLatestGitTagForPattern(pattern, { + projectName, + }); + + expect(result.tag).toEqual(expectedTag); + expect(result.extractedVersion).toEqual(expectedVersion); + } + ); + + it('should return null if execCommand throws an error', async () => { + // should return null if execCommand throws an error + (require('./exec-command').execCommand as jest.Mock).mockImplementationOnce( + () => { + throw new Error('error'); + } + ); + const result = await getLatestGitTagForPattern('#{version}', { + projectName: 'my-lib-1', + }); + expect(result).toEqual(null); + }); + + it('should return null if no tags match the pattern', async () => { + const result = await getLatestGitTagForPattern('#{version}', { + projectName: 'my-lib-1', + }); + + expect(result).toEqual(null); + }); +}); diff --git a/packages/nx/src/command-line/release/utils/git.ts b/packages/nx/src/command-line/release/utils/git.ts index 2612d381c3e05..8e7a5d92829be 100644 --- a/packages/nx/src/command-line/release/utils/git.ts +++ b/packages/nx/src/command-line/release/utils/git.ts @@ -2,7 +2,8 @@ * Special thanks to changelogen for the original inspiration for many of these utilities: * https://github.com/unjs/changelogen */ -import { spawn } from 'node:child_process'; +import { interpolate } from '../../../tasks-runner/utils'; +import { execCommand } from './exec-command'; export interface GitCommitAuthor { name: string; @@ -28,13 +29,62 @@ 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); +function escapeRegExp(string) { + return string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&'); +} + +// https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string +const SEMVER_REGEX = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/g; + +export async function getLatestGitTagForPattern( + releaseTagPattern: string, + additionalInterpolationData = {} +): Promise<{ tag: string; extractedVersion: string } | null> { + try { + const tags = await execCommand('git', ['tag', '--sort', '-v:refname']).then( + (r) => + r + .trim() + .split('\n') + .map((t) => t.trim()) + .filter(Boolean) + ); + if (!tags.length) { + return null; + } + + const interpolatedTagPattern = interpolate(releaseTagPattern, { + version: ' ', + ...additionalInterpolationData, + }); + + const tagRegexp = `^${escapeRegExp(interpolatedTagPattern).replace( + ' ', + '(.+)' + )}`; + const matchingSemverTags = tags.filter( + (tag) => + // Do the match against SEMVER_REGEX to ensure that we skip tags that aren't valid semver versions + !!tag.match(tagRegexp) && tag.match(tagRegexp)[1]?.match(SEMVER_REGEX) + ); + + if (!matchingSemverTags.length) { + return null; + } + + const [latestMatchingTag, version] = matchingSemverTags[0].match(tagRegexp); + + return { + tag: latestMatchingTag, + extractedVersion: version, + }; + } catch { + return null; + } } export async function getGitDiff( @@ -77,6 +127,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 +166,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,36 +187,6 @@ export function parseGitCommit(commit: RawGitCommit): GitCommit | null { scope, references, isBreaking, + affectedFiles, }; } - -async function execCommand( - cmd: string, - args: string[], - options?: any -): Promise { - return new Promise((resolve, reject) => { - const child = spawn(cmd, args, { - ...options, - stdio: ['pipe', 'pipe', 'pipe'], // stdin, stdout, stderr - encoding: 'utf-8', - }); - - let stdout = ''; - child.stdout.on('data', (chunk) => { - stdout += chunk; - }); - - child.on('error', (error) => { - reject(error); - }); - - child.on('close', (code) => { - if (code !== 0) { - reject(new Error(`Command failed with exit code ${code}`)); - } else { - resolve(stdout); - } - }); - }); -} diff --git a/packages/nx/src/command-line/release/utils/resolve-nx-json-error-message.ts b/packages/nx/src/command-line/release/utils/resolve-nx-json-error-message.ts index 85f8776b4e06d..dd6d04bf89757 100644 --- a/packages/nx/src/command-line/release/utils/resolve-nx-json-error-message.ts +++ b/packages/nx/src/command-line/release/utils/resolve-nx-json-error-message.ts @@ -14,7 +14,10 @@ export async function resolveNxJsonConfigErrorMessage( joinPathFragments(workspaceRoot, 'nx.json') )}`; if (errorLines) { - nxJsonMessage += `, lines ${errorLines.startLine}-${errorLines.endLine}`; + nxJsonMessage += + errorLines.startLine === errorLines.endLine + ? `, line ${errorLines.startLine}` + : `, lines ${errorLines.startLine}-${errorLines.endLine}`; } return nxJsonMessage; } 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 0000000000000..f355c5e7e2ed5 --- /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 c315923e6aef7..a87781bf584c5 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 7e4a2daf5c066..ffd86d9ad530c 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 9da7c211d1068..e0d3b4ed9af40 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 { @@ -26,13 +24,14 @@ import { parseGeneratorString } from '../generate/generate'; import { getGeneratorInformation } from '../generate/generator-utils'; import { VersionOptions } from './command-object'; import { - CATCH_ALL_RELEASE_GROUP, createNxReleaseConfig, handleNxReleaseConfigError, } from './config/config'; -import { filterReleaseGroups } from './config/filter-release-groups'; +import { + ReleaseGroupWithName, + 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 +39,13 @@ export { deriveNewSemverVersion } from './utils/semver'; export interface ReleaseVersionGeneratorSchema { // The projects being versioned in the current execution projects: ProjectGraphProjectNode[]; + releaseGroup: ReleaseGroupWithName; 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 +100,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 +110,8 @@ export async function versionHandler(args: VersionOptions): Promise { args, tree, generatorData, - Array.from(releaseGroupToFilteredProjects.get(releaseGroup)), - semverSpecifier + releaseGroupProjectNames, + releaseGroup ); } @@ -140,16 +135,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 +142,7 @@ export async function versionHandler(args: VersionOptions): Promise { tree, generatorData, releaseGroup.projects, - semverSpecifier + releaseGroup ); } @@ -173,30 +158,14 @@ async function runVersionOnProjects( tree: Tree, generatorData: GeneratorData, projectNames: string[], - newVersionSpecifier: string + releaseGroup: ReleaseGroupWithName ) { - // 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, + releaseGroup, + // Always ensure a string to avoid generator schema validation errors + 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