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": {
|
"specifier": {
|
||||||
"type": "string",
|
"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": {
|
"preid": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@ -34,7 +44,7 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "disk",
|
"default": "disk",
|
||||||
"description": "Which approach to use to determine the current version of the project.",
|
"description": "Which approach to use to determine the current version of the project.",
|
||||||
"enum": ["registry", "disk"]
|
"enum": ["registry", "disk", "git-tag"]
|
||||||
},
|
},
|
||||||
"currentVersionResolverMetadata": {
|
"currentVersionResolverMetadata": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@ -42,7 +52,7 @@
|
|||||||
"default": {}
|
"default": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["projects", "projectGraph", "specifier"],
|
"required": ["projects", "projectGraph", "releaseGroup"],
|
||||||
"presets": []
|
"presets": []
|
||||||
},
|
},
|
||||||
"description": "DO NOT INVOKE DIRECTLY WITH `nx generate`. Use `nx release version` instead.",
|
"description": "DO NOT INVOKE DIRECTLY WITH `nx generate`. Use `nx release version` instead.",
|
||||||
|
|||||||
@ -639,5 +639,143 @@ describe('nx release', () => {
|
|||||||
|
|
||||||
// port and process cleanup
|
// port and process cleanup
|
||||||
await killProcessAndPorts(process.pid, verdaccioPort);
|
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);
|
}, 500000);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -33,13 +33,13 @@ export default async function runExecutor(
|
|||||||
|
|
||||||
const packageJsonPath = joinPathFragments(packageRoot, 'package.json');
|
const packageJsonPath = joinPathFragments(packageRoot, 'package.json');
|
||||||
const projectPackageJson = readJsonFile(packageJsonPath);
|
const projectPackageJson = readJsonFile(packageJsonPath);
|
||||||
const name = projectPackageJson.name;
|
const packageName = projectPackageJson.name;
|
||||||
|
|
||||||
// If package and project name match, we can make log messages terser
|
// If package and project name match, we can make log messages terser
|
||||||
let packageTxt =
|
let packageTxt =
|
||||||
name === context.projectName
|
packageName === context.projectName
|
||||||
? `package "${name}"`
|
? `package "${packageName}"`
|
||||||
: `package "${name}" from project "${context.projectName}"`;
|
: `package "${packageName}" from project "${context.projectName}"`;
|
||||||
|
|
||||||
if (projectPackageJson.private === true) {
|
if (projectPackageJson.private === true) {
|
||||||
console.warn(
|
console.warn(
|
||||||
@ -80,7 +80,7 @@ export default async function runExecutor(
|
|||||||
const stdoutData = JSON.parse(output.toString());
|
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
|
// 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);
|
logTar(normalizedStdoutData);
|
||||||
|
|
||||||
if (options.dryRun) {
|
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 { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
|
||||||
|
import { ReleaseGroupWithName } from 'nx/src/command-line/release/config/filter-release-groups';
|
||||||
import { releaseVersionGenerator } from './release-version';
|
import { releaseVersionGenerator } from './release-version';
|
||||||
import { createWorkspaceWithPackageDependencies } from './test-utils/create-workspace-with-package-dependencies';
|
import { createWorkspaceWithPackageDependencies } from './test-utils/create-workspace-with-package-dependencies';
|
||||||
|
|
||||||
@ -58,6 +59,7 @@ describe('release-version', () => {
|
|||||||
projectGraph,
|
projectGraph,
|
||||||
specifier: 'major',
|
specifier: 'major',
|
||||||
currentVersionResolver: 'disk',
|
currentVersionResolver: 'disk',
|
||||||
|
releaseGroup: createReleaseGroup(),
|
||||||
});
|
});
|
||||||
expect(readJson(tree, 'libs/my-lib/package.json').version).toEqual('1.0.0');
|
expect(readJson(tree, 'libs/my-lib/package.json').version).toEqual('1.0.0');
|
||||||
|
|
||||||
@ -66,6 +68,7 @@ describe('release-version', () => {
|
|||||||
projectGraph,
|
projectGraph,
|
||||||
specifier: 'minor',
|
specifier: 'minor',
|
||||||
currentVersionResolver: 'disk',
|
currentVersionResolver: 'disk',
|
||||||
|
releaseGroup: createReleaseGroup(),
|
||||||
});
|
});
|
||||||
expect(readJson(tree, 'libs/my-lib/package.json').version).toEqual('1.1.0');
|
expect(readJson(tree, 'libs/my-lib/package.json').version).toEqual('1.1.0');
|
||||||
|
|
||||||
@ -74,6 +77,7 @@ describe('release-version', () => {
|
|||||||
projectGraph,
|
projectGraph,
|
||||||
specifier: 'patch',
|
specifier: 'patch',
|
||||||
currentVersionResolver: 'disk',
|
currentVersionResolver: 'disk',
|
||||||
|
releaseGroup: createReleaseGroup(),
|
||||||
});
|
});
|
||||||
expect(readJson(tree, 'libs/my-lib/package.json').version).toEqual('1.1.1');
|
expect(readJson(tree, 'libs/my-lib/package.json').version).toEqual('1.1.1');
|
||||||
|
|
||||||
@ -82,6 +86,7 @@ describe('release-version', () => {
|
|||||||
projectGraph,
|
projectGraph,
|
||||||
specifier: '1.2.3', // exact version
|
specifier: '1.2.3', // exact version
|
||||||
currentVersionResolver: 'disk',
|
currentVersionResolver: 'disk',
|
||||||
|
releaseGroup: createReleaseGroup(),
|
||||||
});
|
});
|
||||||
expect(readJson(tree, 'libs/my-lib/package.json').version).toEqual('1.2.3');
|
expect(readJson(tree, 'libs/my-lib/package.json').version).toEqual('1.2.3');
|
||||||
});
|
});
|
||||||
@ -92,6 +97,7 @@ describe('release-version', () => {
|
|||||||
projectGraph,
|
projectGraph,
|
||||||
specifier: 'major',
|
specifier: 'major',
|
||||||
currentVersionResolver: 'disk',
|
currentVersionResolver: 'disk',
|
||||||
|
releaseGroup: createReleaseGroup(),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(readJson(tree, 'libs/my-lib/package.json')).toMatchInlineSnapshot(`
|
expect(readJson(tree, 'libs/my-lib/package.json')).toMatchInlineSnapshot(`
|
||||||
@ -130,19 +136,42 @@ describe('release-version', () => {
|
|||||||
tree.delete('libs/my-lib/package.json');
|
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 () => {
|
it(`should exit with code one and print guidance when not all of the given projects are appropriate for JS versioning`, async () => {
|
||||||
await expect(
|
const processSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
|
||||||
releaseVersionGenerator(tree, {
|
return undefined as never;
|
||||||
|
});
|
||||||
|
const outputSpy = jest.spyOn(output, 'error').mockImplementation(() => {
|
||||||
|
return undefined as never;
|
||||||
|
});
|
||||||
|
|
||||||
|
await releaseVersionGenerator(tree, {
|
||||||
projects: Object.values(projectGraph.nodes), // version all projects
|
projects: Object.values(projectGraph.nodes), // version all projects
|
||||||
projectGraph,
|
projectGraph,
|
||||||
specifier: 'major',
|
specifier: 'major',
|
||||||
currentVersionResolver: 'disk',
|
currentVersionResolver: 'disk',
|
||||||
})
|
releaseGroup: createReleaseGroup(),
|
||||||
).rejects.toThrowErrorMatchingInlineSnapshot(`
|
});
|
||||||
"The project "my-lib" does not have a package.json available at libs/my-lib/package.json.
|
|
||||||
|
|
||||||
To fix this you will either need to add a package.json file at that location, or configure "release" within your nx.json to exclude "my-lib" from the current release group, or amend the packageRoot configuration to point to where the package.json should be."
|
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';
|
} from '@nx/devkit';
|
||||||
import * as chalk from 'chalk';
|
import * as chalk from 'chalk';
|
||||||
import { exec } from 'child_process';
|
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 { deriveNewSemverVersion } from 'nx/src/command-line/release/version';
|
||||||
import { interpolate } from 'nx/src/tasks-runner/utils';
|
import { interpolate } from 'nx/src/tasks-runner/utils';
|
||||||
import * as ora from 'ora';
|
import * as ora from 'ora';
|
||||||
import { relative } from 'path';
|
import { relative } from 'path';
|
||||||
|
import { prerelease } from 'semver';
|
||||||
import { ReleaseVersionGeneratorSchema } from './schema';
|
import { ReleaseVersionGeneratorSchema } from './schema';
|
||||||
import { resolveLocalPackageDependencies } from './utils/resolve-local-package-dependencies';
|
import { resolveLocalPackageDependencies } from './utils/resolve-local-package-dependencies';
|
||||||
|
|
||||||
@ -20,6 +27,14 @@ export async function releaseVersionGenerator(
|
|||||||
tree: Tree,
|
tree: Tree,
|
||||||
options: ReleaseVersionGeneratorSchema
|
options: ReleaseVersionGeneratorSchema
|
||||||
) {
|
) {
|
||||||
|
try {
|
||||||
|
// If the user provided a specifier, validate that it is valid semver or a relative semver keyword
|
||||||
|
if (options.specifier && !isValidSemverSpecifier(options.specifier)) {
|
||||||
|
throw new Error(
|
||||||
|
`The given version specifier "${options.specifier}" is not valid. You provide an exact version or a valid semver keyword such as "major", "minor", "patch", etc.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const projects = options.projects;
|
const projects = options.projects;
|
||||||
|
|
||||||
// Resolve any custom package roots for each project upfront as they will need to be reused during dependency resolution
|
// Resolve any custom package roots for each project upfront as they will need to be reused during dependency resolution
|
||||||
@ -40,6 +55,14 @@ export async function releaseVersionGenerator(
|
|||||||
|
|
||||||
let currentVersion: string;
|
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) {
|
for (const project of projects) {
|
||||||
const projectName = project.name;
|
const projectName = project.name;
|
||||||
const packageRoot = projectNameToPackageRootMap.get(projectName);
|
const packageRoot = projectNameToPackageRootMap.get(projectName);
|
||||||
@ -79,7 +102,10 @@ To fix this you will either need to add a package.json file at that location, or
|
|||||||
switch (options.currentVersionResolver) {
|
switch (options.currentVersionResolver) {
|
||||||
case 'registry': {
|
case 'registry': {
|
||||||
const metadata = options.currentVersionResolverMetadata;
|
const metadata = options.currentVersionResolverMetadata;
|
||||||
const registry = metadata?.registry ?? 'https://registry.npmjs.org';
|
const registry =
|
||||||
|
metadata?.registry ??
|
||||||
|
(await getNpmRegistry()) ??
|
||||||
|
'https://registry.npmjs.org';
|
||||||
const tag = metadata?.tag ?? 'latest';
|
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 the currentVersionResolver is set to registry, we only want to make the request once for the whole batch of projects
|
||||||
@ -127,12 +153,99 @@ To fix this you will either need to add a package.json file at that location, or
|
|||||||
`📄 Resolved the current version as ${currentVersion} from ${packageJsonPath}`
|
`📄 Resolved the current version as ${currentVersion} from ${packageJsonPath}`
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case 'git-tag': {
|
||||||
|
if (!currentVersion) {
|
||||||
|
const releaseTagPattern = options.releaseGroup.releaseTagPattern;
|
||||||
|
latestMatchingGitTag = await getLatestGitTagForPattern(
|
||||||
|
releaseTagPattern,
|
||||||
|
{
|
||||||
|
projectName: project.name,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!latestMatchingGitTag) {
|
||||||
|
throw new Error(
|
||||||
|
`No git tags matching pattern "${releaseTagPattern}" for project "${project.name}" were found.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentVersion = latestMatchingGitTag.extractedVersion;
|
||||||
|
log(
|
||||||
|
`📄 Resolved the current version as ${currentVersion} from git tag "${latestMatchingGitTag.tag}".`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log(
|
||||||
|
`📄 Using the current version ${currentVersion} already resolved from git tag "${latestMatchingGitTag.tag}".`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid value for options.currentVersionResolver: ${options.currentVersionResolver}`
|
`Invalid value for options.currentVersionResolver: ${options.currentVersionResolver}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.specifier) {
|
||||||
|
log(`📄 Using the provided version specifier "${options.specifier}".`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// if specifier is null, then we determined previously via conventional commits that no changes are necessary
|
||||||
|
if (specifier === undefined) {
|
||||||
|
const specifierSource = options.specifierSource;
|
||||||
|
switch (specifierSource) {
|
||||||
|
case 'conventional-commits':
|
||||||
|
if (options.currentVersionResolver !== 'git-tag') {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid currentVersionResolver "${options.currentVersionResolver}" provided for release group "${options.releaseGroup.name}". Must be "git-tag" when "specifierSource" is "conventional-commits"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
specifier = await resolveSemverSpecifierFromConventionalCommits(
|
||||||
|
latestMatchingGitTag.tag,
|
||||||
|
options.projectGraph,
|
||||||
|
projects.map((p) => p.name)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!specifier) {
|
||||||
|
log(
|
||||||
|
`🚫 No changes were detected using git history and the conventional commits standard.`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always assume that if the current version is a prerelease, then the next version should be a prerelease.
|
||||||
|
// Users must manually graduate from a prerelease to a release by providing an explicit specifier.
|
||||||
|
if (prerelease(currentVersion)) {
|
||||||
|
specifier = 'prerelease';
|
||||||
|
log(
|
||||||
|
`📄 Resolved the specifier as "${specifier}" since the current version is a prerelease.`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log(
|
||||||
|
`📄 Resolved the specifier as "${specifier}" using git history and the conventional commits standard.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'prompt':
|
||||||
|
specifier = await resolveSemverSpecifierFromPrompt(
|
||||||
|
`What kind of change is this for the ${projects.length} matched projects(s) within release group "${options.releaseGroup.name}"?`,
|
||||||
|
`What is the exact version for the ${projects.length} matched project(s) within release group "${options.releaseGroup.name}"?`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(
|
||||||
|
`Invalid specifierSource "${specifierSource}" provided. Must be one of "prompt" or "conventional-commits"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
// Resolve any local package dependencies for this project (before applying the new version)
|
||||||
const localPackageDependencies = resolveLocalPackageDependencies(
|
const localPackageDependencies = resolveLocalPackageDependencies(
|
||||||
tree,
|
tree,
|
||||||
@ -143,7 +256,7 @@ To fix this you will either need to add a package.json file at that location, or
|
|||||||
|
|
||||||
const newVersion = deriveNewSemverVersion(
|
const newVersion = deriveNewSemverVersion(
|
||||||
currentVersion,
|
currentVersion,
|
||||||
options.specifier,
|
specifier,
|
||||||
options.preid
|
options.preid
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -185,12 +298,27 @@ To fix this you will either need to add a package.json file at that location, or
|
|||||||
'package.json'
|
'package.json'
|
||||||
),
|
),
|
||||||
(json) => {
|
(json) => {
|
||||||
json[dependentProject.dependencyCollection][packageName] = newVersion;
|
json[dependentProject.dependencyCollection][packageName] =
|
||||||
|
newVersion;
|
||||||
return json;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default releaseVersionGenerator;
|
export default releaseVersionGenerator;
|
||||||
@ -217,3 +345,18 @@ function getColor(projectName: string) {
|
|||||||
|
|
||||||
return colors[colorIndex];
|
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": {
|
"specifier": {
|
||||||
"type": "string",
|
"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": {
|
"preid": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@ -33,7 +43,7 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "disk",
|
"default": "disk",
|
||||||
"description": "Which approach to use to determine the current version of the project.",
|
"description": "Which approach to use to determine the current version of the project.",
|
||||||
"enum": ["registry", "disk"]
|
"enum": ["registry", "disk", "git-tag"]
|
||||||
},
|
},
|
||||||
"currentVersionResolverMetadata": {
|
"currentVersionResolverMetadata": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@ -41,5 +51,5 @@
|
|||||||
"default": {}
|
"default": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["projects", "projectGraph", "specifier"]
|
"required": ["projects", "projectGraph", "releaseGroup"]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,6 +27,7 @@ describe('defaultChangelogRenderer()', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
isBreaking: false,
|
isBreaking: false,
|
||||||
|
affectedFiles: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: 'feat(pkg-b): and another new capability',
|
message: 'feat(pkg-b): and another new capability',
|
||||||
@ -52,6 +53,7 @@ describe('defaultChangelogRenderer()', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
isBreaking: false,
|
isBreaking: false,
|
||||||
|
affectedFiles: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: 'feat(pkg-a): new hotness',
|
message: 'feat(pkg-a): new hotness',
|
||||||
@ -77,6 +79,7 @@ describe('defaultChangelogRenderer()', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
isBreaking: false,
|
isBreaking: false,
|
||||||
|
affectedFiles: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: 'feat(pkg-b): brand new thing',
|
message: 'feat(pkg-b): brand new thing',
|
||||||
@ -102,6 +105,7 @@ describe('defaultChangelogRenderer()', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
isBreaking: false,
|
isBreaking: false,
|
||||||
|
affectedFiles: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: 'fix(pkg-a): squashing bugs',
|
message: 'fix(pkg-a): squashing bugs',
|
||||||
@ -127,6 +131,7 @@ describe('defaultChangelogRenderer()', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
isBreaking: false,
|
isBreaking: false,
|
||||||
|
affectedFiles: [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -24,7 +24,7 @@ import { filterReleaseGroups } from './config/filter-release-groups';
|
|||||||
import {
|
import {
|
||||||
GitCommit,
|
GitCommit,
|
||||||
getGitDiff,
|
getGitDiff,
|
||||||
getLastGitTag,
|
getLatestGitTagForPattern,
|
||||||
parseCommits,
|
parseCommits,
|
||||||
} from './utils/git';
|
} from './utils/git';
|
||||||
import {
|
import {
|
||||||
@ -107,7 +107,9 @@ export async function changelogHandler(args: ChangelogOptions): Promise<void> {
|
|||||||
releaseTagPattern: nxReleaseConfig.releaseTagPattern,
|
releaseTagPattern: nxReleaseConfig.releaseTagPattern,
|
||||||
});
|
});
|
||||||
|
|
||||||
const from = args.from || (await getLastGitTag());
|
const from =
|
||||||
|
args.from ||
|
||||||
|
(await getLatestGitTagForPattern(nxReleaseConfig.releaseTagPattern))?.tag;
|
||||||
if (!from) {
|
if (!from) {
|
||||||
output.error({
|
output.error({
|
||||||
title: `Unable to determine the previous git tag, please provide an explicit git reference using --from`,
|
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', () => {
|
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 {
|
export interface CreateNxReleaseConfigError {
|
||||||
code:
|
code:
|
||||||
| 'RELEASE_GROUP_MATCHES_NO_PROJECTS'
|
| 'RELEASE_GROUP_MATCHES_NO_PROJECTS'
|
||||||
|
| 'RELEASE_GROUP_RELEASE_TAG_PATTERN_VERSION_PLACEHOLDER_MISSING_OR_EXCESSIVE'
|
||||||
| 'PROJECT_MATCHES_MULTIPLE_GROUPS'
|
| 'PROJECT_MATCHES_MULTIPLE_GROUPS'
|
||||||
| 'PROJECTS_MISSING_TARGET';
|
| 'PROJECTS_MISSING_TARGET';
|
||||||
data: Record<string, string | string[]>;
|
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) {
|
for (const project of matchingProjects) {
|
||||||
if (alreadyMatchedProjects.has(project)) {
|
if (alreadyMatchedProjects.has(project)) {
|
||||||
return {
|
return {
|
||||||
@ -290,6 +305,20 @@ export async function handleNxReleaseConfigError(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
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:
|
default:
|
||||||
throw new Error(`Unhandled error code: ${error.code}`);
|
throw new Error(`Unhandled error code: ${error.code}`);
|
||||||
}
|
}
|
||||||
@ -297,6 +326,21 @@ export async function handleNxReleaseConfigError(
|
|||||||
process.exit(1);
|
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(
|
function ensureProjectsConfigIsArray(
|
||||||
groups: NxJsonConfiguration['release']['groups']
|
groups: NxJsonConfiguration['release']['groups']
|
||||||
): NxReleaseConfig['groups'] {
|
): NxReleaseConfig['groups'] {
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { findMatchingProjects } from '../../../utils/find-matching-projects';
|
|||||||
import { output } from '../../../utils/output';
|
import { output } from '../../../utils/output';
|
||||||
import { CATCH_ALL_RELEASE_GROUP, NxReleaseConfig } from './config';
|
import { CATCH_ALL_RELEASE_GROUP, NxReleaseConfig } from './config';
|
||||||
|
|
||||||
type ReleaseGroupWithName = NxReleaseConfig['groups'][string] & {
|
export type ReleaseGroupWithName = NxReleaseConfig['groups'][string] & {
|
||||||
name: 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:
|
* Special thanks to changelogen for the original inspiration for many of these utilities:
|
||||||
* https://github.com/unjs/changelogen
|
* 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 {
|
export interface GitCommitAuthor {
|
||||||
name: string;
|
name: string;
|
||||||
@ -28,13 +29,62 @@ export interface GitCommit extends RawGitCommit {
|
|||||||
references: Reference[];
|
references: Reference[];
|
||||||
authors: GitCommitAuthor[];
|
authors: GitCommitAuthor[];
|
||||||
isBreaking: boolean;
|
isBreaking: boolean;
|
||||||
|
affectedFiles: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLastGitTag() {
|
function escapeRegExp(string) {
|
||||||
const r = await execCommand('git', ['describe', '--tags', '--abbrev=0'])
|
return string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||||
.then((r) => r.split('\n').filter(Boolean))
|
}
|
||||||
.catch(() => []);
|
|
||||||
return r.at(-1);
|
// 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(
|
export async function getGitDiff(
|
||||||
@ -77,6 +127,7 @@ const ConventionalCommitRegex =
|
|||||||
const CoAuthoredByRegex = /co-authored-by:\s*(?<name>.+)(<(?<email>.+)>)/gim;
|
const CoAuthoredByRegex = /co-authored-by:\s*(?<name>.+)(<(?<email>.+)>)/gim;
|
||||||
const PullRequestRE = /\([ a-z]*(#\d+)\s*\)/gm;
|
const PullRequestRE = /\([ a-z]*(#\d+)\s*\)/gm;
|
||||||
const IssueRE = /(#\d+)/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 {
|
export function parseGitCommit(commit: RawGitCommit): GitCommit | null {
|
||||||
const match = commit.message.match(ConventionalCommitRegex);
|
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 {
|
return {
|
||||||
...commit,
|
...commit,
|
||||||
authors,
|
authors,
|
||||||
@ -123,36 +187,6 @@ export function parseGitCommit(commit: RawGitCommit): GitCommit | null {
|
|||||||
scope,
|
scope,
|
||||||
references,
|
references,
|
||||||
isBreaking,
|
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')
|
joinPathFragments(workspaceRoot, 'nx.json')
|
||||||
)}`;
|
)}`;
|
||||||
if (errorLines) {
|
if (errorLines) {
|
||||||
nxJsonMessage += `, lines ${errorLines.startLine}-${errorLines.endLine}`;
|
nxJsonMessage +=
|
||||||
|
errorLines.startLine === errorLines.endLine
|
||||||
|
? `, line ${errorLines.startLine}`
|
||||||
|
: `, lines ${errorLines.startLine}-${errorLines.endLine}`;
|
||||||
}
|
}
|
||||||
return nxJsonMessage;
|
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,6 +1,12 @@
|
|||||||
import { deriveNewSemverVersion } from './semver';
|
import { GitCommit } from './git';
|
||||||
|
import {
|
||||||
|
ConventionalCommitsConfig,
|
||||||
|
deriveNewSemverVersion,
|
||||||
|
determineSemverChange,
|
||||||
|
} from './semver';
|
||||||
|
|
||||||
describe('deriveNewSemverVersion()', () => {
|
describe('semver', () => {
|
||||||
|
describe('deriveNewSemverVersion()', () => {
|
||||||
const testCases = [
|
const testCases = [
|
||||||
{
|
{
|
||||||
input: {
|
input: {
|
||||||
@ -67,4 +73,91 @@ describe('deriveNewSemverVersion()', () => {
|
|||||||
`"Invalid semver version specifier "foo" provided. Please provide either a valid semver version or a valid semver version keyword."`
|
`"Invalid semver version specifier "foo" provided. Please provide either a valid semver version or a valid semver version keyword."`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
// tests for determineSemverChange()
|
||||||
|
describe('determineSemverChange()', () => {
|
||||||
|
const config: ConventionalCommitsConfig = {
|
||||||
|
types: {
|
||||||
|
feat: {
|
||||||
|
semver: 'minor',
|
||||||
|
},
|
||||||
|
fix: {
|
||||||
|
semver: 'patch',
|
||||||
|
},
|
||||||
|
chore: {
|
||||||
|
semver: 'patch',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const featNonBreakingCommit: GitCommit = {
|
||||||
|
type: 'feat',
|
||||||
|
isBreaking: false,
|
||||||
|
} as GitCommit;
|
||||||
|
const featBreakingCommit: GitCommit = {
|
||||||
|
type: 'feat',
|
||||||
|
isBreaking: true,
|
||||||
|
} as GitCommit;
|
||||||
|
const fixCommit: GitCommit = {
|
||||||
|
type: 'fix',
|
||||||
|
isBreaking: false,
|
||||||
|
} as GitCommit;
|
||||||
|
const choreCommit: GitCommit = {
|
||||||
|
type: 'chore',
|
||||||
|
isBreaking: false,
|
||||||
|
} as GitCommit;
|
||||||
|
const unknownTypeCommit: GitCommit = {
|
||||||
|
type: 'perf',
|
||||||
|
isBreaking: false,
|
||||||
|
} as GitCommit;
|
||||||
|
const unknownTypeBreakingCommit: GitCommit = {
|
||||||
|
type: 'perf',
|
||||||
|
isBreaking: true,
|
||||||
|
} as GitCommit;
|
||||||
|
|
||||||
|
it('should return the highest bump level of all commits', () => {
|
||||||
|
expect(
|
||||||
|
determineSemverChange(
|
||||||
|
[fixCommit, featNonBreakingCommit, choreCommit],
|
||||||
|
config
|
||||||
|
)
|
||||||
|
).toEqual('minor');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should 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 { RELEASE_TYPES, ReleaseType, inc, valid } from 'semver';
|
||||||
|
import { GitCommit } from './git';
|
||||||
|
|
||||||
export function isRelativeVersionKeyword(val: string): val is ReleaseType {
|
export function isRelativeVersionKeyword(val: string): val is ReleaseType {
|
||||||
return RELEASE_TYPES.includes(val as 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(
|
export function deriveNewSemverVersion(
|
||||||
currentSemverVersion: string,
|
currentSemverVersion: string,
|
||||||
semverSpecifier: string,
|
semverSpecifier: string,
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
import * as chalk from 'chalk';
|
import * as chalk from 'chalk';
|
||||||
import * as enquirer from 'enquirer';
|
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { relative } from 'node:path';
|
import { relative } from 'node:path';
|
||||||
import { RELEASE_TYPES, valid } from 'semver';
|
|
||||||
import { Generator } from '../../config/misc-interfaces';
|
import { Generator } from '../../config/misc-interfaces';
|
||||||
import { readNxJson } from '../../config/nx-json';
|
import { readNxJson } from '../../config/nx-json';
|
||||||
import {
|
import {
|
||||||
@ -26,13 +24,14 @@ import { parseGeneratorString } from '../generate/generate';
|
|||||||
import { getGeneratorInformation } from '../generate/generator-utils';
|
import { getGeneratorInformation } from '../generate/generator-utils';
|
||||||
import { VersionOptions } from './command-object';
|
import { VersionOptions } from './command-object';
|
||||||
import {
|
import {
|
||||||
CATCH_ALL_RELEASE_GROUP,
|
|
||||||
createNxReleaseConfig,
|
createNxReleaseConfig,
|
||||||
handleNxReleaseConfigError,
|
handleNxReleaseConfigError,
|
||||||
} from './config/config';
|
} 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 { printDiff } from './utils/print-changes';
|
||||||
import { isRelativeVersionKeyword } from './utils/semver';
|
|
||||||
|
|
||||||
// Reexport for use in plugin release-version generator implementations
|
// Reexport for use in plugin release-version generator implementations
|
||||||
export { deriveNewSemverVersion } from './utils/semver';
|
export { deriveNewSemverVersion } from './utils/semver';
|
||||||
@ -40,11 +39,13 @@ export { deriveNewSemverVersion } from './utils/semver';
|
|||||||
export interface ReleaseVersionGeneratorSchema {
|
export interface ReleaseVersionGeneratorSchema {
|
||||||
// The projects being versioned in the current execution
|
// The projects being versioned in the current execution
|
||||||
projects: ProjectGraphProjectNode[];
|
projects: ProjectGraphProjectNode[];
|
||||||
|
releaseGroup: ReleaseGroupWithName;
|
||||||
projectGraph: ProjectGraph;
|
projectGraph: ProjectGraph;
|
||||||
specifier: string;
|
specifier?: string;
|
||||||
|
specifierSource?: 'prompt' | 'conventional-commits';
|
||||||
preid?: string;
|
preid?: string;
|
||||||
packageRoot?: string;
|
packageRoot?: string;
|
||||||
currentVersionResolver?: 'registry' | 'disk';
|
currentVersionResolver?: 'registry' | 'disk' | 'git-tag';
|
||||||
currentVersionResolverMetadata?: Record<string, unknown>;
|
currentVersionResolverMetadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,14 +100,8 @@ export async function versionHandler(args: VersionOptions): Promise<void> {
|
|||||||
configGeneratorOptions: releaseGroup.version.generatorOptions,
|
configGeneratorOptions: releaseGroup.version.generatorOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
const semverSpecifier = await resolveSemverSpecifier(
|
const releaseGroupProjectNames = Array.from(
|
||||||
args.specifier,
|
releaseGroupToFilteredProjects.get(releaseGroup)
|
||||||
`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}"?`
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await runVersionOnProjects(
|
await runVersionOnProjects(
|
||||||
@ -115,8 +110,8 @@ export async function versionHandler(args: VersionOptions): Promise<void> {
|
|||||||
args,
|
args,
|
||||||
tree,
|
tree,
|
||||||
generatorData,
|
generatorData,
|
||||||
Array.from(releaseGroupToFilteredProjects.get(releaseGroup)),
|
releaseGroupProjectNames,
|
||||||
semverSpecifier
|
releaseGroup
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,16 +135,6 @@ export async function versionHandler(args: VersionOptions): Promise<void> {
|
|||||||
configGeneratorOptions: releaseGroup.version.generatorOptions,
|
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(
|
await runVersionOnProjects(
|
||||||
projectGraph,
|
projectGraph,
|
||||||
nxJson,
|
nxJson,
|
||||||
@ -157,7 +142,7 @@ export async function versionHandler(args: VersionOptions): Promise<void> {
|
|||||||
tree,
|
tree,
|
||||||
generatorData,
|
generatorData,
|
||||||
releaseGroup.projects,
|
releaseGroup.projects,
|
||||||
semverSpecifier
|
releaseGroup
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -173,30 +158,14 @@ async function runVersionOnProjects(
|
|||||||
tree: Tree,
|
tree: Tree,
|
||||||
generatorData: GeneratorData,
|
generatorData: GeneratorData,
|
||||||
projectNames: string[],
|
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 = {
|
const generatorOptions: ReleaseVersionGeneratorSchema = {
|
||||||
projects: projectNames.map((p) => projectGraph.nodes[p]),
|
projects: projectNames.map((p) => projectGraph.nodes[p]),
|
||||||
projectGraph,
|
projectGraph,
|
||||||
specifier: newVersionSpecifier,
|
releaseGroup,
|
||||||
|
// Always ensure a string to avoid generator schema validation errors
|
||||||
|
specifier: args.specifier ?? '',
|
||||||
preid: args.preid,
|
preid: args.preid,
|
||||||
...generatorData.configGeneratorOptions,
|
...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(
|
function extractGeneratorCollectionAndName(
|
||||||
description: string,
|
description: string,
|
||||||
generatorString: string
|
generatorString: string
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user