feat(release): add 'git-tag' currentVersionResolver and conventional commits support (#19267)
Co-authored-by: James Henry <james@henry.sc>
This commit is contained in:
parent
a7e0abd3e4
commit
dbb73aa2eb
@ -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.",
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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"]
|
||||
}
|
||||
|
||||
@ -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: [],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -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`,
|
||||
|
||||
@ -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,
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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'] {
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
32
packages/nx/src/command-line/release/utils/exec-command.ts
Normal file
32
packages/nx/src/command-line/release/utils/exec-command.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
123
packages/nx/src/command-line/release/utils/git.spec.ts
Normal file
123
packages/nx/src/command-line/release/utils/git.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user