Skip to content

Commit

Permalink
feat(release): conventional-commits, git-tag, and single generator
Browse files Browse the repository at this point in the history
  • Loading branch information
fahslaj committed Oct 16, 2023
1 parent 2cc1561 commit f788d9f
Show file tree
Hide file tree
Showing 11 changed files with 623 additions and 167 deletions.
16 changes: 13 additions & 3 deletions docs/generated/packages/js/generators/release-version.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,17 @@
},
"specifier": {
"type": "string",
"description": "Exact version or semver keyword to apply to the selected release group. NOTE: This should be set on the release group level, not the project level."
"description": "Exact version or semver keyword to apply to the selected release group. Overrides specifierSource."
},
"releaseGroupName": {
"type": "string",
"description": "The name of the release group being versioned in the current execution."
},
"specifierSource": {
"type": "string",
"default": "prompt",
"description": "Which approach to use to determine the semver specifier used to bump the version of the project.",
"enum": ["prompt", "conventional-commits"]
},
"preid": {
"type": "string",
Expand All @@ -34,15 +44,15 @@
"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",
"description": "Additional metadata to pass to the current version resolver.",
"default": {}
}
},
"required": ["projects", "projectGraph", "specifier"],
"required": ["projects", "projectGraph", "releaseGroupName"],
"presets": []
},
"description": "DO NOT INVOKE DIRECTLY WITH `nx generate`. Use `nx release version` instead.",
Expand Down
148 changes: 148 additions & 0 deletions e2e/release/src/release.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
runCLI,
runCommandAsync,
runCommandUntil,
tmpProjPath,
uniq,
updateJson,
} from '@nx/e2e/utils';
Expand Down Expand Up @@ -546,5 +547,152 @@ describe('nx release', () => {

// port and process cleanup
await killProcessAndPorts(process.pid, verdaccioPort);

// Add custom nx release config to control version resolution
updateJson<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'],
version: {
generator: '@nx/js:release-version',
generatorOptions: {
// Resolve the latest version from the git tag
currentVersionResolver: 'git-tag',
currentVersionResolverMetadata: {
tagVersionPrefix: 'xx',
},
},
},
},
},
};
return nxJson;
});

// Add a git tag to the repo
execSync(`git tag -a xx1100.0.0 -m xx1100.0.0`, {
cwd: tmpProjPath(),
});

const versionOutput3 = runCLI(`release version minor`);
expect(
versionOutput3.match(/Running release version for project: my-pkg-\d*/g)
.length
).toEqual(3);
expect(
versionOutput3.match(
/Reading data for package "@proj\/my-pkg-\d*" from my-pkg-\d*\/package.json/g
).length
).toEqual(3);

// It should resolve the current version from the git tag once...
expect(
versionOutput3.match(
new RegExp(
`Resolved the current version as 1100.0.0 from git tag "xx1100.0.0"`,
'g'
)
).length
).toEqual(1);
// ...and then reuse it twice
expect(
versionOutput3.match(
new RegExp(
`Using the current version 1100.0.0 already resolved from git tag "xx1100.0.0"`,
'g'
)
).length
).toEqual(2);

expect(
versionOutput3.match(
/New version 1100.1.0 written to my-pkg-\d*\/package.json/g
).length
).toEqual(3);

// Only one dependency relationship exists, so this log should only match once
expect(
versionOutput3.match(
/Applying new version 1100.1.0 to 1 package which depends on my-pkg-\d*/g
).length
).toEqual(1);

createFile(
`${pkg1}/my-file.txt`,
'update for conventional-commits testing'
);

// Add custom nx release config to control version resolution
updateJson<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'],
version: {
generator: '@nx/js:release-version',
generatorOptions: {
specifierSource: 'conventional-commits',
currentVersionResolver: 'git-tag',
currentVersionResolverMetadata: {
tagVersionPrefix: 'xx',
},
},
},
},
},
};
return nxJson;
});

const versionOutput4 = runCLI(`release version`);

expect(
versionOutput4.match(/Running release version for project: my-pkg-\d*/g)
.length
).toEqual(3);
expect(
versionOutput4.match(
/Reading data for package "@proj\/my-pkg-\d*" from my-pkg-\d*\/package.json/g
).length
).toEqual(3);

// It should resolve the current version from the git tag once...
expect(
versionOutput4.match(
new RegExp(
`Resolved the current version as 1100.0.0 from git tag "xx1100.0.0"`,
'g'
)
).length
).toEqual(1);
// ...and then reuse it twice
expect(
versionOutput4.match(
new RegExp(
`Using the current version 1100.0.0 already resolved from git tag "xx1100.0.0"`,
'g'
)
).length
).toEqual(2);

expect(versionOutput4.match(/Skipping versioning/g).length).toEqual(3);

execSync(
`git add ${pkg1}/my-file.txt && git commit -m "feat!: add new file"`,
{
cwd: tmpProjPath(),
}
);

const versionOutput5 = runCLI(`release version`);

expect(
versionOutput5.match(
/New version 1101.0.0 written to my-pkg-\d*\/package.json/g
).length
).toEqual(3);
}, 500000);
});
120 changes: 118 additions & 2 deletions packages/js/src/generators/release-version/release-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,31 @@ import {
} from '@nx/devkit';
import * as chalk from 'chalk';
import { exec } from 'child_process';
import { getLastGitTag } from 'nx/src/command-line/release/utils/git';
import {
resolveSemverSpecifierFromConventionalCommits,
resolveSemverSpecifierFromPrompt,
} from 'nx/src/command-line/release/utils/resolve-semver-specifier';
import { isValidSemverSpecifier } from 'nx/src/command-line/release/utils/semver';
import { deriveNewSemverVersion } from 'nx/src/command-line/release/version';
import { interpolate } from 'nx/src/tasks-runner/utils';
import * as ora from 'ora';
import { relative } from 'path';
import { prerelease } from 'semver';
import { ReleaseVersionGeneratorSchema } from './schema';
import { resolveLocalPackageDependencies } from './utils/resolve-local-package-dependencies';

export async function releaseVersionGenerator(
tree: Tree,
options: ReleaseVersionGeneratorSchema
) {
// If the user provided a specifier, validate it
if (options.specifier && !isValidSemverSpecifier(options.specifier)) {
throw new Error(
`The given version specifier "${options.specifier}" is not valid. You provide an exact version or a valid semver keyword such as "major", "minor", "patch", etc.`
);
}

const projects = options.projects;

// Resolve any custom package roots for each project upfront as they will need to be reused during dependency resolution
Expand All @@ -40,6 +54,14 @@ export async function releaseVersionGenerator(

let currentVersion: string;

// only used for options.currentVersionResolver === 'git-tag', but
// must be declared here in order to reuse it for additional projects
let lastMatchingGitTag: string;

// if specifier is undefined, then we haven't resolved it yet
// if specifier is null, then it has been resolved and no changes are necessary
let specifier = options.specifier ? options.specifier : undefined;

for (const project of projects) {
const projectName = project.name;
const packageRoot = projectNameToPackageRootMap.get(projectName);
Expand Down Expand Up @@ -79,7 +101,10 @@ To fix this you will either need to add a package.json file at that location, or
switch (options.currentVersionResolver) {
case 'registry': {
const metadata = options.currentVersionResolverMetadata;
const registry = metadata?.registry ?? 'https://registry.npmjs.org';
const registry =
metadata?.registry ??
(await getNpmRegistry()) ??
'https://registry.npmjs.org';
const tag = metadata?.tag ?? 'latest';

// If the currentVersionResolver is set to registry, we only want to make the request once for the whole batch of projects
Expand Down Expand Up @@ -127,12 +152,88 @@ To fix this you will either need to add a package.json file at that location, or
`📄 Resolved the current version as ${currentVersion} from ${packageJsonPath}`
);
break;
case 'git-tag': {
if (!currentVersion) {
const tagVersionPrefix =
(options.currentVersionResolverMetadata
?.tagVersionPrefix as string) ?? 'v';
const matchingPattern = `${tagVersionPrefix}*.*.*`;
lastMatchingGitTag = await getLastGitTag(matchingPattern);

if (!lastMatchingGitTag) {
throw new Error(
`No git tags matching pattern "${matchingPattern}" were found.`
);
}

currentVersion = lastMatchingGitTag.replace(tagVersionPrefix, '');
log(
`📄 Resolved the current version as ${currentVersion} from git tag "${lastMatchingGitTag}".`
);
} else {
log(
`📄 Using the current version ${currentVersion} already resolved from git tag "${lastMatchingGitTag}".`
);
}
break;
}
default:
throw new Error(
`Invalid value for options.currentVersionResolver: ${options.currentVersionResolver}`
);
}

// if specifier is null, then we determined previously via conventional commits that no changes are necessary
if (specifier === undefined) {
const specifierSource = options.specifierSource;
switch (specifierSource) {
case 'conventional-commits':
if (options.currentVersionResolver !== 'git-tag') {
throw new Error(
`Invalid currentVersionResolver "${options.currentVersionResolver}" provided for release group "${options.releaseGroupName}". Must be "git-tag" when "specifierSource" is "conventional-commits"`
);
}

// Always assume that if the current version is a prerelease, then the next version should be a prerelease.
// Users must manually graduate from a prerelease to a release by providing an explicit specifier.
if (prerelease(currentVersion)) {
specifier = 'prerelease';
log(
`📄 Resolved the specifier as "${specifier}" since the current version is a prerelease.`
);
break;
}

specifier = await resolveSemverSpecifierFromConventionalCommits(
lastMatchingGitTag,
options.projectGraph,
projects.map((p) => p.name)
);

log(
`📄 Resolved the specifier as "${specifier}" using git history and the conventional commits standard.`
);
break;
case 'prompt':
specifier = await resolveSemverSpecifierFromPrompt(
`What kind of change is this for the ${projects.length} matched projects(s) within release group "${options.releaseGroupName}"?`,
`What is the exact version for the ${projects.length} matched project(s) within release group "${options.releaseGroupName}"?`
);
break;
default:
throw new Error(
`Invalid specifierSource "${specifierSource}" provided. Must be one of "prompt" or "conventional-commits"`
);
}
}

if (!specifier) {
log(
`🚫 Skipping versioning "${projectPackageJson.name}" as no changes were detected.`
);
continue;
}

// Resolve any local package dependencies for this project (before applying the new version)
const localPackageDependencies = resolveLocalPackageDependencies(
tree,
Expand All @@ -143,7 +244,7 @@ To fix this you will either need to add a package.json file at that location, or

const newVersion = deriveNewSemverVersion(
currentVersion,
options.specifier,
specifier,
options.preid
);

Expand Down Expand Up @@ -217,3 +318,18 @@ function getColor(projectName: string) {

return colors[colorIndex];
}

async function getNpmRegistry() {
// Must be non-blocking async to allow spinner to render
return await new Promise<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());
});
});
}
Loading

0 comments on commit f788d9f

Please sign in to comment.