feat(release): add 'git-tag' currentVersionResolver and conventional commits support (#19267)

Co-authored-by: James Henry <james@henry.sc>
This commit is contained in:
Austin Fahsl 2023-10-26 02:52:49 -06:00 committed by GitHub
parent a7e0abd3e4
commit dbb73aa2eb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1219 additions and 368 deletions

View File

@ -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.",

View File

@ -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<NxJsonConfiguration>('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<NxJsonConfiguration>('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);
});

View File

@ -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) {

View File

@ -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.
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;
});
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."
`);
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> = {}
): ReleaseGroupWithName {
return {
name: 'default',
releaseTagPattern: '{projectName}@v{version}',
...partialGroup,
} as ReleaseGroupWithName;
}

View File

@ -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<string, string>();
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,
})
);
}
let currentVersion: string;
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)) {
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 project "${projectName}" does not have a package.json available at ${workspaceRelativePackageJsonPath}.
`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
const projectNameToPackageRootMap = new Map<string, string>();
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,
})
);
}
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}.
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 projectPackageJson = readJson(tree, packageJsonPath);
log(
`🔍 Reading data for package "${projectPackageJson.name}" from ${workspaceRelativePackageJsonPath}`
);
const { name: packageName, version: currentVersionFromDisk } =
projectPackageJson;
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';
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();
// 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}`
// Must be non-blocking async to allow spinner to render
currentVersion = await new Promise<string>((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();
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} from ${packageJsonPath}`
);
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<string>((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());
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.`
);
}
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}`
);
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;
}
break;
default:
throw new Error(
`Invalid value for options.currentVersionResolver: ${options.currentVersionResolver}`
);
}
case 'disk':
currentVersion = currentVersionFromDisk;
log(
`📄 Resolved the current version as ${currentVersion} from ${packageJsonPath}`
);
break;
default:
throw new Error(
`Invalid value for options.currentVersionResolver: ${options.currentVersionResolver}`
);
}
// Resolve any local package dependencies for this project (before applying the new version)
const localPackageDependencies = resolveLocalPackageDependencies(
tree,
options.projectGraph,
projects,
projectNameToPackageRootMap
);
if (options.specifier) {
log(`📄 Using the provided version specifier "${options.specifier}".`);
}
const newVersion = deriveNewSemverVersion(
currentVersion,
options.specifier,
options.preid
);
// 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"`
);
}
writeJson(tree, packageJsonPath, {
...projectPackageJson,
version: newVersion,
});
specifier = await resolveSemverSpecifierFromConventionalCommits(
latestMatchingGitTag.tag,
options.projectGraph,
projects.map((p) => p.name)
);
log(
`✍️ New version ${newVersion} written to ${workspaceRelativePackageJsonPath}`
);
if (!specifier) {
log(
`🚫 No changes were detected using git history and the conventional commits standard.`
);
break;
}
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;
// 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"`
);
}
}
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,
options.projectGraph,
projects,
projectNameToPackageRootMap
);
const newVersion = deriveNewSemverVersion(
currentVersion,
specifier,
options.preid
);
writeJson(tree, packageJsonPath, {
...projectPackageJson,
version: newVersion,
});
log(
`✍️ New version ${newVersion} written to ${workspaceRelativePackageJsonPath}`
);
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<string>((resolve, reject) => {
exec('npm config get registry', (error, stdout, stderr) => {
if (error) {
return reject(error);
}
if (stderr) {
return reject(stderr);
}
return resolve(stdout.trim());
});
});
}

View File

@ -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"]
}

View File

@ -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: [],
},
];

View File

@ -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<void> {
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`,

View File

@ -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,
}
`);
});
});
});

View File

@ -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<string, string | string[]>;
@ -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'] {

View File

@ -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;
};

View File

@ -0,0 +1,32 @@
import { spawn } from 'node:child_process';
export async function execCommand(
cmd: string,
args: string[],
options?: any
): Promise<string> {
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);
}
});
});
}

View File

@ -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);
});
});

View File

@ -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*(?<name>.+)(<(?<email>.+)>)/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<string> {
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);
}
});
});
}

View File

@ -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;
}

View File

@ -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<string | null> {
const commits = await getGitDiff(from);
const parsedCommits = parseCommits(commits);
const projectFileMap = await createProjectFileMapUsingProjectGraph(
projectGraph
);
const filesInReleaseGroup = new Set<string>(
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<string> {
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);
}
}

View File

@ -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 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',
},
},
};
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."`
);
});
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 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 the highest bump level of all commits', () => {
expect(
determineSemverChange(
[fixCommit, featNonBreakingCommit, choreCommit],
config
)
).toEqual('minor');
});
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);
});
});
});

View File

@ -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,

View File

@ -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<string, unknown>;
}
@ -99,14 +100,8 @@ export async function versionHandler(args: VersionOptions): Promise<void> {
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<void> {
args,
tree,
generatorData,
Array.from(releaseGroupToFilteredProjects.get(releaseGroup)),
semverSpecifier
releaseGroupProjectNames,
releaseGroup
);
}
@ -140,16 +135,6 @@ export async function versionHandler(args: VersionOptions): Promise<void> {
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<void> {
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<string> {
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