feat(release): add support for version plans (#23190)
This commit is contained in:
parent
e95204b037
commit
b2855fd6e1
@ -25,12 +25,6 @@ Default: `false`
|
||||
|
||||
Preview the changes without updating files/creating releases
|
||||
|
||||
### first-release
|
||||
|
||||
Type: `boolean`
|
||||
|
||||
Indicates that this is the first release for the selected release group. If the current version cannot be determined as usual, the version on disk will be used as a fallback. This is useful when using git or the registry to determine the current version of packages, since those sources are only available after the first release. Also indicates that changelog generation should not assume a previous git tag exists and that publishing should not check for the existence of the package before running.
|
||||
|
||||
### groups
|
||||
|
||||
Type: `string`
|
||||
@ -73,6 +67,12 @@ nx release [specifier]
|
||||
|
||||
#### Options
|
||||
|
||||
##### first-release
|
||||
|
||||
Type: `boolean`
|
||||
|
||||
Indicates that this is the first release for the selected release group. If the current version cannot be determined as usual, the version on disk will be used as a fallback. This is useful when using git or the registry to determine the current version of packages, since those sources are only available after the first release. Also indicates that changelog generation should not assume a previous git tag exists and that publishing should not check for the existence of the package before running.
|
||||
|
||||
##### help
|
||||
|
||||
Type: `boolean`
|
||||
@ -113,6 +113,12 @@ nx release version [specifier]
|
||||
|
||||
#### Options
|
||||
|
||||
##### first-release
|
||||
|
||||
Type: `boolean`
|
||||
|
||||
Indicates that this is the first release for the selected release group. If the current version cannot be determined as usual, the version on disk will be used as a fallback. This is useful when using git or the registry to determine the current version of packages, since those sources are only available after the first release. Also indicates that changelog generation should not assume a previous git tag exists and that publishing should not check for the existence of the package before running.
|
||||
|
||||
##### git-commit
|
||||
|
||||
Type: `boolean`
|
||||
@ -189,6 +195,12 @@ nx release changelog [version]
|
||||
|
||||
#### Options
|
||||
|
||||
##### first-release
|
||||
|
||||
Type: `boolean`
|
||||
|
||||
Indicates that this is the first release for the selected release group. If the current version cannot be determined as usual, the version on disk will be used as a fallback. This is useful when using git or the registry to determine the current version of packages, since those sources are only available after the first release. Also indicates that changelog generation should not assume a previous git tag exists and that publishing should not check for the existence of the package before running.
|
||||
|
||||
##### from
|
||||
|
||||
Type: `string`
|
||||
@ -297,6 +309,12 @@ Type: `string`
|
||||
|
||||
Exclude certain projects from being processed
|
||||
|
||||
##### first-release
|
||||
|
||||
Type: `boolean`
|
||||
|
||||
Indicates that this is the first release for the selected release group. If the current version cannot be determined as usual, the version on disk will be used as a fallback. This is useful when using git or the registry to determine the current version of packages, since those sources are only available after the first release. Also indicates that changelog generation should not assume a previous git tag exists and that publishing should not check for the existence of the package before running.
|
||||
|
||||
##### graph
|
||||
|
||||
Type: `string`
|
||||
|
||||
@ -30,7 +30,7 @@
|
||||
"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"]
|
||||
"enum": ["prompt", "conventional-commits", "version-plans"]
|
||||
},
|
||||
"preid": {
|
||||
"type": "string",
|
||||
|
||||
@ -25,12 +25,6 @@ Default: `false`
|
||||
|
||||
Preview the changes without updating files/creating releases
|
||||
|
||||
### first-release
|
||||
|
||||
Type: `boolean`
|
||||
|
||||
Indicates that this is the first release for the selected release group. If the current version cannot be determined as usual, the version on disk will be used as a fallback. This is useful when using git or the registry to determine the current version of packages, since those sources are only available after the first release. Also indicates that changelog generation should not assume a previous git tag exists and that publishing should not check for the existence of the package before running.
|
||||
|
||||
### groups
|
||||
|
||||
Type: `string`
|
||||
@ -73,6 +67,12 @@ nx release [specifier]
|
||||
|
||||
#### Options
|
||||
|
||||
##### first-release
|
||||
|
||||
Type: `boolean`
|
||||
|
||||
Indicates that this is the first release for the selected release group. If the current version cannot be determined as usual, the version on disk will be used as a fallback. This is useful when using git or the registry to determine the current version of packages, since those sources are only available after the first release. Also indicates that changelog generation should not assume a previous git tag exists and that publishing should not check for the existence of the package before running.
|
||||
|
||||
##### help
|
||||
|
||||
Type: `boolean`
|
||||
@ -113,6 +113,12 @@ nx release version [specifier]
|
||||
|
||||
#### Options
|
||||
|
||||
##### first-release
|
||||
|
||||
Type: `boolean`
|
||||
|
||||
Indicates that this is the first release for the selected release group. If the current version cannot be determined as usual, the version on disk will be used as a fallback. This is useful when using git or the registry to determine the current version of packages, since those sources are only available after the first release. Also indicates that changelog generation should not assume a previous git tag exists and that publishing should not check for the existence of the package before running.
|
||||
|
||||
##### git-commit
|
||||
|
||||
Type: `boolean`
|
||||
@ -189,6 +195,12 @@ nx release changelog [version]
|
||||
|
||||
#### Options
|
||||
|
||||
##### first-release
|
||||
|
||||
Type: `boolean`
|
||||
|
||||
Indicates that this is the first release for the selected release group. If the current version cannot be determined as usual, the version on disk will be used as a fallback. This is useful when using git or the registry to determine the current version of packages, since those sources are only available after the first release. Also indicates that changelog generation should not assume a previous git tag exists and that publishing should not check for the existence of the package before running.
|
||||
|
||||
##### from
|
||||
|
||||
Type: `string`
|
||||
@ -297,6 +309,12 @@ Type: `string`
|
||||
|
||||
Exclude certain projects from being processed
|
||||
|
||||
##### first-release
|
||||
|
||||
Type: `boolean`
|
||||
|
||||
Indicates that this is the first release for the selected release group. If the current version cannot be determined as usual, the version on disk will be used as a fallback. This is useful when using git or the registry to determine the current version of packages, since those sources are only available after the first release. Also indicates that changelog generation should not assume a previous git tag exists and that publishing should not check for the existence of the package before running.
|
||||
|
||||
##### graph
|
||||
|
||||
Type: `string`
|
||||
|
||||
@ -88,6 +88,13 @@
|
||||
"@nx/nx-source:populate-local-registry-storage"
|
||||
],
|
||||
"inputs": ["e2eInputs", "^production"]
|
||||
},
|
||||
"e2e-ci--src/version-plans.test.ts": {
|
||||
"dependsOn": [
|
||||
"nx:build-native",
|
||||
"@nx/nx-source:populate-local-registry-storage"
|
||||
],
|
||||
"inputs": ["e2eInputs", "^production"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
772
e2e/release/src/version-plans.test.ts
Normal file
772
e2e/release/src/version-plans.test.ts
Normal file
@ -0,0 +1,772 @@
|
||||
import { NxJsonConfiguration } from '@nx/devkit';
|
||||
import {
|
||||
cleanupProject,
|
||||
exists,
|
||||
newProject,
|
||||
packageInstall,
|
||||
runCLI,
|
||||
runCommand,
|
||||
runCommandAsync,
|
||||
tmpProjPath,
|
||||
uniq,
|
||||
updateJson,
|
||||
} from '@nx/e2e/utils';
|
||||
import { ensureDir, readdirSync, writeFile } from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
|
||||
expect.addSnapshotSerializer({
|
||||
serialize(str: string) {
|
||||
return (
|
||||
str
|
||||
// Remove all output unique to specific projects to ensure deterministic snapshots
|
||||
.replaceAll(/my-pkg-\d+/g, '{project-name}')
|
||||
.replaceAll(
|
||||
/integrity:\s*.*/g,
|
||||
'integrity: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
|
||||
)
|
||||
.replaceAll(/\b[0-9a-f]{40}\b/g, '{SHASUM}')
|
||||
.replaceAll(/\d*B index\.js/g, 'XXB index.js')
|
||||
.replaceAll(/\d*B project\.json/g, 'XXB project.json')
|
||||
.replaceAll(/\d*B package\.json/g, 'XXXB package.json')
|
||||
.replaceAll(/size:\s*\d*\s?B/g, 'size: XXXB')
|
||||
.replaceAll(/\d*\.\d*\s?kB/g, 'XXX.XXX kb')
|
||||
.replaceAll(/[a-fA-F0-9]{7}/g, '{COMMIT_SHA}')
|
||||
.replaceAll(/Test @[\w\d]+/g, 'Test @{COMMIT_AUTHOR}')
|
||||
// Normalize the version title date.
|
||||
.replaceAll(/\(\d{4}-\d{2}-\d{2}\)/g, '(YYYY-MM-DD)')
|
||||
// We trim each line to reduce the chances of snapshot flakiness
|
||||
.split('\n')
|
||||
.map((r) => r.trim())
|
||||
.join('\n')
|
||||
);
|
||||
},
|
||||
test(val: string) {
|
||||
return val != null && typeof val === 'string';
|
||||
},
|
||||
});
|
||||
|
||||
describe('nx release version plans', () => {
|
||||
let pkg1: string;
|
||||
let pkg2: string;
|
||||
let pkg3: string;
|
||||
let pkg4: string;
|
||||
let pkg5: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
newProject({
|
||||
unsetProjectNameAndRootFormat: false,
|
||||
packages: ['@nx/js'],
|
||||
});
|
||||
|
||||
pkg1 = uniq('my-pkg-1');
|
||||
runCLI(`generate @nx/workspace:npm-package ${pkg1}`);
|
||||
|
||||
pkg2 = uniq('my-pkg-2');
|
||||
runCLI(`generate @nx/workspace:npm-package ${pkg2}`);
|
||||
|
||||
pkg3 = uniq('my-pkg-3');
|
||||
runCLI(`generate @nx/workspace:npm-package ${pkg3}`);
|
||||
|
||||
pkg4 = uniq('my-pkg-4');
|
||||
runCLI(`generate @nx/workspace:npm-package ${pkg4}`);
|
||||
|
||||
pkg5 = uniq('my-pkg-5');
|
||||
runCLI(`generate @nx/workspace:npm-package ${pkg5}`);
|
||||
|
||||
await runCommandAsync(`git add .`);
|
||||
await runCommandAsync(`git commit -m "chore: initial commit"`);
|
||||
await runCommandAsync(`git tag -a v0.0.0 -m "v0.0.0"`);
|
||||
await runCommandAsync(`git tag -a ${pkg3}@0.0.0 -m "${pkg3}@0.0.0"`);
|
||||
await runCommandAsync(`git tag -a ${pkg4}@0.0.0 -m "${pkg4}@0.0.0"`);
|
||||
await runCommandAsync(`git tag -a ${pkg5}@0.0.0 -m "${pkg5}@0.0.0"`);
|
||||
}, 60000);
|
||||
|
||||
afterEach(() => cleanupProject());
|
||||
|
||||
it('should pick new versions based on version plans', async () => {
|
||||
updateJson<NxJsonConfiguration>('nx.json', (nxJson) => {
|
||||
nxJson.release = {
|
||||
groups: {
|
||||
'fixed-group': {
|
||||
projects: [pkg1, pkg2],
|
||||
releaseTagPattern: 'v{version}',
|
||||
},
|
||||
'independent-group': {
|
||||
projects: [pkg3, pkg4, pkg5],
|
||||
projectsRelationship: 'independent',
|
||||
releaseTagPattern: '{projectName}@{version}',
|
||||
},
|
||||
},
|
||||
version: {
|
||||
generatorOptions: {
|
||||
specifierSource: 'version-plans',
|
||||
},
|
||||
},
|
||||
changelog: {
|
||||
projectChangelogs: true,
|
||||
},
|
||||
versionPlans: true,
|
||||
};
|
||||
return nxJson;
|
||||
});
|
||||
|
||||
const versionPlansDir = tmpProjPath('.nx/version-plans');
|
||||
await ensureDir(versionPlansDir);
|
||||
|
||||
runCLI(
|
||||
'release plan minor -g fixed-group -m "feat: Update the fixed packages with a minor release." --verbose',
|
||||
{
|
||||
silenceError: true,
|
||||
}
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
join(versionPlansDir, 'bump-independent.md'),
|
||||
`---
|
||||
${pkg3}: patch
|
||||
${pkg4}: preminor
|
||||
${pkg5}: prerelease
|
||||
---
|
||||
|
||||
feat: Update the independent packages with a patch, preminor, and prerelease.
|
||||
`
|
||||
);
|
||||
|
||||
await runCommandAsync(`git add ${versionPlansDir}`);
|
||||
await runCommandAsync(
|
||||
`git commit -m "chore: add version plans for fixed and independent groups"`
|
||||
);
|
||||
|
||||
const result = runCLI('release --verbose', {
|
||||
silenceError: true,
|
||||
});
|
||||
|
||||
expect(result).toContain(
|
||||
`${pkg1} 📄 Resolved the specifier as "minor" using version plans.`
|
||||
);
|
||||
// pkg2 uses the previously resolved specifier from pkg1
|
||||
expect(result).toContain(
|
||||
`${pkg2} ✍️ New version 0.1.0 written to ${pkg2}/package.json`
|
||||
);
|
||||
expect(result).toContain(
|
||||
`${pkg3} 📄 Resolved the specifier as "patch" using version plans.`
|
||||
);
|
||||
expect(result).toContain(
|
||||
`${pkg4} 📄 Resolved the specifier as "preminor" using version plans.`
|
||||
);
|
||||
expect(result).toContain(
|
||||
`${pkg5} 📄 Resolved the specifier as "prerelease" using version plans.`
|
||||
);
|
||||
|
||||
// replace the date with a placeholder to make the snapshot deterministic
|
||||
const resultWithoutDate = result.replace(
|
||||
/\(\d{4}-\d{2}-\d{2}\)/g,
|
||||
'(YYYY-MM-DD)'
|
||||
);
|
||||
|
||||
expect(resultWithoutDate).toContain(
|
||||
`NX Generating an entry in ${pkg1}/CHANGELOG.md for v0.1.0
|
||||
|
||||
|
||||
+ ## 0.1.0 (YYYY-MM-DD)
|
||||
+
|
||||
+
|
||||
+ ### 🚀 Features
|
||||
+
|
||||
+ - Update the fixed packages with a minor release.`
|
||||
);
|
||||
expect(resultWithoutDate).toContain(
|
||||
`NX Generating an entry in ${pkg2}/CHANGELOG.md for v0.1.0
|
||||
|
||||
|
||||
+ ## 0.1.0 (YYYY-MM-DD)
|
||||
+
|
||||
+
|
||||
+ ### 🚀 Features
|
||||
+
|
||||
+ - Update the fixed packages with a minor release.`
|
||||
);
|
||||
expect(resultWithoutDate).toContain(
|
||||
`NX Generating an entry in ${pkg3}/CHANGELOG.md for ${pkg3}@0.0.1
|
||||
|
||||
|
||||
+ ## 0.0.1 (YYYY-MM-DD)
|
||||
+
|
||||
+
|
||||
+ ### 🚀 Features
|
||||
+
|
||||
+ - Update the independent packages with a patch, preminor, and prerelease.`
|
||||
);
|
||||
|
||||
expect(resultWithoutDate).toContain(
|
||||
`NX Generating an entry in ${pkg4}/CHANGELOG.md for ${pkg4}@0.1.0-0
|
||||
|
||||
|
||||
+ ## 0.1.0-0 (YYYY-MM-DD)
|
||||
+
|
||||
+
|
||||
+ ### 🚀 Features
|
||||
+
|
||||
+ - Update the independent packages with a patch, preminor, and prerelease.`
|
||||
);
|
||||
|
||||
expect(resultWithoutDate).toContain(
|
||||
`NX Generating an entry in ${pkg5}/CHANGELOG.md for ${pkg5}@0.0.1-0
|
||||
|
||||
|
||||
+ ## 0.0.1-0 (YYYY-MM-DD)
|
||||
+
|
||||
+
|
||||
+ ### 🚀 Features
|
||||
+
|
||||
+ - Update the independent packages with a patch, preminor, and prerelease.`
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
join(versionPlansDir, 'bump-mixed1.md'),
|
||||
`---
|
||||
${pkg1}: minor
|
||||
${pkg3}: patch
|
||||
---
|
||||
|
||||
fix: Update packages in both groups with a bug fix
|
||||
`
|
||||
);
|
||||
await writeFile(
|
||||
join(versionPlansDir, 'bump-mixed2.md'),
|
||||
`---
|
||||
fixed-group: patch
|
||||
${pkg4}: preminor
|
||||
${pkg5}: patch
|
||||
---
|
||||
|
||||
feat: Update packages in both groups with a feat
|
||||
`
|
||||
);
|
||||
|
||||
await runCommandAsync(`git add ${join(versionPlansDir, 'bump-mixed1.md')}`);
|
||||
await runCommandAsync(`git add ${join(versionPlansDir, 'bump-mixed2.md')}`);
|
||||
await runCommandAsync(
|
||||
`git commit -m "chore: add combined groups version plans"`
|
||||
);
|
||||
|
||||
runCLI('release --dry-run');
|
||||
// dry-run should not remove the version plan
|
||||
expect(exists(join(versionPlansDir, 'bump-mixed1.md'))).toBeTruthy();
|
||||
|
||||
const result2 = runCLI('release --verbose', {
|
||||
silenceError: true,
|
||||
});
|
||||
|
||||
expect(result2).toContain(
|
||||
`${pkg1} 📄 Resolved the specifier as "minor" using version plans.`
|
||||
);
|
||||
// pkg2 uses the previously resolved specifier from pkg1
|
||||
expect(result2).toContain(
|
||||
`${pkg2} ✍️ New version 0.2.0 written to ${pkg2}/package.json`
|
||||
);
|
||||
expect(result2).toContain(
|
||||
`${pkg3} 📄 Resolved the specifier as "patch" using version plans.`
|
||||
);
|
||||
expect(result2).toContain(
|
||||
`${pkg4} 📄 Resolved the specifier as "preminor" using version plans.`
|
||||
);
|
||||
expect(result2).toContain(
|
||||
`${pkg5} 📄 Resolved the specifier as "patch" using version plans.`
|
||||
);
|
||||
|
||||
// replace the date with a placeholder to make the snapshot deterministic
|
||||
const result2WithoutDate = result2.replace(
|
||||
/\(\d{4}-\d{2}-\d{2}\)/g,
|
||||
'(YYYY-MM-DD)'
|
||||
);
|
||||
|
||||
expect(result2WithoutDate).toContain(
|
||||
`NX Generating an entry in ${pkg1}/CHANGELOG.md for v0.2.0
|
||||
|
||||
|
||||
|
||||
+ ## 0.2.0 (YYYY-MM-DD)
|
||||
+
|
||||
+
|
||||
+ ### 🚀 Features
|
||||
+
|
||||
+ - Update packages in both groups with a feat
|
||||
+
|
||||
+
|
||||
+ ### 🩹 Fixes
|
||||
+
|
||||
+ - Update packages in both groups with a bug fix`
|
||||
);
|
||||
expect(result2WithoutDate).toContain(
|
||||
`NX Generating an entry in ${pkg2}/CHANGELOG.md for v0.2.0
|
||||
|
||||
|
||||
|
||||
+ ## 0.2.0 (YYYY-MM-DD)
|
||||
+
|
||||
+
|
||||
+ ### 🚀 Features
|
||||
+
|
||||
+ - Update packages in both groups with a feat
|
||||
+
|
||||
+
|
||||
+ ### 🩹 Fixes
|
||||
+
|
||||
+ - Update packages in both groups with a bug fix
|
||||
`
|
||||
);
|
||||
expect(result2WithoutDate).toContain(
|
||||
`NX Generating an entry in ${pkg3}/CHANGELOG.md for ${pkg3}@0.0.2
|
||||
|
||||
|
||||
|
||||
+ ## 0.0.2 (YYYY-MM-DD)
|
||||
+
|
||||
+
|
||||
+ ### 🩹 Fixes
|
||||
+
|
||||
+ - Update packages in both groups with a bug fix`
|
||||
);
|
||||
|
||||
expect(result2WithoutDate).toContain(
|
||||
`NX Generating an entry in ${pkg4}/CHANGELOG.md for ${pkg4}@0.2.0-0
|
||||
|
||||
|
||||
|
||||
+ ## 0.2.0-0 (YYYY-MM-DD)
|
||||
+
|
||||
+
|
||||
+ ### 🚀 Features
|
||||
+
|
||||
+ - Update packages in both groups with a feat`
|
||||
);
|
||||
|
||||
expect(result2WithoutDate).toContain(
|
||||
`NX Generating an entry in ${pkg5}/CHANGELOG.md for ${pkg5}@0.0.1
|
||||
|
||||
|
||||
|
||||
+ ## 0.0.1 (YYYY-MM-DD)
|
||||
+
|
||||
+
|
||||
+ ### 🚀 Features
|
||||
+
|
||||
+ - Update packages in both groups with a feat`
|
||||
);
|
||||
|
||||
expect(exists(join(versionPlansDir, 'bump-mixed1.md'))).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should pick new versions based on version plans using programmatic api', async () => {
|
||||
updateJson<NxJsonConfiguration>('nx.json', (nxJson) => {
|
||||
nxJson.release = {
|
||||
groups: {
|
||||
'fixed-group': {
|
||||
projects: [pkg1, pkg2],
|
||||
releaseTagPattern: 'v{version}',
|
||||
},
|
||||
'independent-group': {
|
||||
projects: [pkg3, pkg4, pkg5],
|
||||
projectsRelationship: 'independent',
|
||||
releaseTagPattern: '{projectName}@{version}',
|
||||
},
|
||||
},
|
||||
version: {
|
||||
generatorOptions: {
|
||||
specifierSource: 'version-plans',
|
||||
},
|
||||
},
|
||||
changelog: {
|
||||
projectChangelogs: true,
|
||||
},
|
||||
versionPlans: true,
|
||||
};
|
||||
return nxJson;
|
||||
});
|
||||
|
||||
const versionPlansDir = tmpProjPath('.nx/version-plans');
|
||||
await ensureDir(versionPlansDir);
|
||||
|
||||
await writeFile(
|
||||
join(versionPlansDir, 'bump-fixed.md'),
|
||||
`---
|
||||
fixed-group: minor
|
||||
---
|
||||
|
||||
feat: Update the fixed packages with a minor release.
|
||||
`
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
join(versionPlansDir, 'bump-independent.md'),
|
||||
`---
|
||||
${pkg3}: patch
|
||||
${pkg4}: preminor
|
||||
${pkg5}: prerelease
|
||||
---
|
||||
|
||||
feat: Update the independent packages with a patch, preminor, and prerelease.
|
||||
`
|
||||
);
|
||||
|
||||
expect(exists(join(versionPlansDir, 'bump-fixed.md'))).toBe(true);
|
||||
expect(exists(join(versionPlansDir, 'bump-independent.md'))).toBe(true);
|
||||
|
||||
packageInstall('yargs', null, 'latest', 'dev');
|
||||
|
||||
await writeFile(
|
||||
tmpProjPath('release.js'),
|
||||
`
|
||||
const { releaseChangelog, releasePublish, releaseVersion } = require('nx/release');
|
||||
const yargs = require('yargs');
|
||||
|
||||
(async () => {
|
||||
const options = await yargs
|
||||
.version(false) // don't use the default meaning of version in yargs
|
||||
.option('version', {
|
||||
description:
|
||||
'Explicit version specifier to use, if overriding conventional commits',
|
||||
type: 'string',
|
||||
})
|
||||
.option('dryRun', {
|
||||
alias: 'd',
|
||||
description:
|
||||
'Whether or not to perform a dry-run of the release process, defaults to true',
|
||||
type: 'boolean',
|
||||
})
|
||||
.option('verbose', {
|
||||
description:
|
||||
'Whether or not to enable verbose logging, defaults to false',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
})
|
||||
.parseAsync();
|
||||
|
||||
const { workspaceVersion, projectsVersionData } = await releaseVersion({
|
||||
specifier: options.version,
|
||||
dryRun: options.dryRun,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
|
||||
await releaseChangelog({
|
||||
versionData: projectsVersionData,
|
||||
version: workspaceVersion,
|
||||
dryRun: options.dryRun,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
|
||||
// The returned number value from releasePublish will be zero if all projects are published successfully, non-zero if not
|
||||
const publishStatus = await releasePublish({
|
||||
dryRun: options.dryRun,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
process.exit(publishStatus);
|
||||
})();
|
||||
`
|
||||
);
|
||||
|
||||
await runCommandAsync(`git add ${join(versionPlansDir, 'bump-fixed.md')}`);
|
||||
await runCommandAsync(
|
||||
`git add ${join(versionPlansDir, 'bump-independent.md')}`
|
||||
);
|
||||
await runCommandAsync(
|
||||
`git commit -m "chore: add version plans for fixed and independent groups"`
|
||||
);
|
||||
|
||||
const result = runCommand('node release.js', {
|
||||
failOnError: false,
|
||||
});
|
||||
|
||||
expect(result).toContain(
|
||||
`${pkg1} 📄 Resolved the specifier as "minor" using version plans.`
|
||||
);
|
||||
// pkg2 uses the previously resolved specifier from pkg1
|
||||
expect(result).toContain(
|
||||
`${pkg2} ✍️ New version 0.1.0 written to ${pkg2}/package.json`
|
||||
);
|
||||
expect(result).toContain(
|
||||
`${pkg3} 📄 Resolved the specifier as "patch" using version plans.`
|
||||
);
|
||||
expect(result).toContain(
|
||||
`${pkg4} 📄 Resolved the specifier as "preminor" using version plans.`
|
||||
);
|
||||
expect(result).toContain(
|
||||
`${pkg5} 📄 Resolved the specifier as "prerelease" using version plans.`
|
||||
);
|
||||
|
||||
// replace the date with a placeholder to make the snapshot deterministic
|
||||
const resultWithoutDate = result.replace(
|
||||
/\(\d{4}-\d{2}-\d{2}\)/g,
|
||||
'(YYYY-MM-DD)'
|
||||
);
|
||||
|
||||
expect(resultWithoutDate).toContain(
|
||||
`NX Generating an entry in ${pkg1}/CHANGELOG.md for v0.1.0
|
||||
|
||||
|
||||
+ ## 0.1.0 (YYYY-MM-DD)
|
||||
+
|
||||
+
|
||||
+ ### 🚀 Features
|
||||
+
|
||||
+ - Update the fixed packages with a minor release.`
|
||||
);
|
||||
expect(resultWithoutDate).toContain(
|
||||
`NX Generating an entry in ${pkg2}/CHANGELOG.md for v0.1.0
|
||||
|
||||
|
||||
+ ## 0.1.0 (YYYY-MM-DD)
|
||||
+
|
||||
+
|
||||
+ ### 🚀 Features
|
||||
+
|
||||
+ - Update the fixed packages with a minor release.`
|
||||
);
|
||||
expect(resultWithoutDate).toContain(
|
||||
`NX Generating an entry in ${pkg3}/CHANGELOG.md for ${pkg3}@0.0.1
|
||||
|
||||
|
||||
+ ## 0.0.1 (YYYY-MM-DD)
|
||||
+
|
||||
+
|
||||
+ ### 🚀 Features
|
||||
+
|
||||
+ - Update the independent packages with a patch, preminor, and prerelease.`
|
||||
);
|
||||
|
||||
expect(resultWithoutDate).toContain(
|
||||
`NX Generating an entry in ${pkg4}/CHANGELOG.md for ${pkg4}@0.1.0-0
|
||||
|
||||
|
||||
+ ## 0.1.0-0 (YYYY-MM-DD)
|
||||
+
|
||||
+
|
||||
+ ### 🚀 Features
|
||||
+
|
||||
+ - Update the independent packages with a patch, preminor, and prerelease.`
|
||||
);
|
||||
|
||||
expect(resultWithoutDate).toContain(
|
||||
`NX Generating an entry in ${pkg5}/CHANGELOG.md for ${pkg5}@0.0.1-0
|
||||
|
||||
|
||||
+ ## 0.0.1-0 (YYYY-MM-DD)
|
||||
+
|
||||
+
|
||||
+ ### 🚀 Features
|
||||
+
|
||||
+ - Update the independent packages with a patch, preminor, and prerelease.`
|
||||
);
|
||||
|
||||
expect(exists(join(versionPlansDir, 'bump-fixed.md'))).toBeFalsy();
|
||||
expect(exists(join(versionPlansDir, 'bump-independent.md'))).toBeFalsy();
|
||||
|
||||
await writeFile(
|
||||
join(versionPlansDir, 'bump-mixed1.md'),
|
||||
`---
|
||||
${pkg1}: minor
|
||||
${pkg3}: patch
|
||||
---
|
||||
|
||||
fix: Update packages in both groups with a bug fix
|
||||
`
|
||||
);
|
||||
await writeFile(
|
||||
join(versionPlansDir, 'bump-mixed2.md'),
|
||||
`---
|
||||
fixed-group: patch
|
||||
${pkg4}: preminor
|
||||
${pkg5}: patch
|
||||
---
|
||||
|
||||
feat: Update packages in both groups with a feat
|
||||
`
|
||||
);
|
||||
|
||||
await runCommandAsync(`git add ${join(versionPlansDir, 'bump-mixed1.md')}`);
|
||||
await runCommandAsync(`git add ${join(versionPlansDir, 'bump-mixed2.md')}`);
|
||||
await runCommandAsync(
|
||||
`git commit -m "chore: add combined groups version plans"`
|
||||
);
|
||||
|
||||
runCLI('release --dry-run');
|
||||
// dry-run should not remove the version plan
|
||||
expect(exists(join(versionPlansDir, 'bump-mixed1.md'))).toBeTruthy();
|
||||
|
||||
const result2 = runCLI('release --verbose', {
|
||||
silenceError: true,
|
||||
});
|
||||
|
||||
expect(result2).toContain(
|
||||
`${pkg1} 📄 Resolved the specifier as "minor" using version plans.`
|
||||
);
|
||||
// pkg2 uses the previously resolved specifier from pkg1
|
||||
expect(result2).toContain(
|
||||
`${pkg2} ✍️ New version 0.2.0 written to ${pkg2}/package.json`
|
||||
);
|
||||
expect(result2).toContain(
|
||||
`${pkg3} 📄 Resolved the specifier as "patch" using version plans.`
|
||||
);
|
||||
expect(result2).toContain(
|
||||
`${pkg4} 📄 Resolved the specifier as "preminor" using version plans.`
|
||||
);
|
||||
expect(result2).toContain(
|
||||
`${pkg5} 📄 Resolved the specifier as "patch" using version plans.`
|
||||
);
|
||||
|
||||
// replace the date with a placeholder to make the snapshot deterministic
|
||||
const result2WithoutDate = result2.replace(
|
||||
/\(\d{4}-\d{2}-\d{2}\)/g,
|
||||
'(YYYY-MM-DD)'
|
||||
);
|
||||
|
||||
expect(result2WithoutDate).toContain(
|
||||
`NX Generating an entry in ${pkg1}/CHANGELOG.md for v0.2.0
|
||||
|
||||
|
||||
|
||||
+ ## 0.2.0 (YYYY-MM-DD)
|
||||
+
|
||||
+
|
||||
+ ### 🚀 Features
|
||||
+
|
||||
+ - Update packages in both groups with a feat
|
||||
+
|
||||
+
|
||||
+ ### 🩹 Fixes
|
||||
+
|
||||
+ - Update packages in both groups with a bug fix`
|
||||
);
|
||||
expect(result2WithoutDate).toContain(
|
||||
`NX Generating an entry in ${pkg2}/CHANGELOG.md for v0.2.0
|
||||
|
||||
|
||||
|
||||
+ ## 0.2.0 (YYYY-MM-DD)
|
||||
+
|
||||
+
|
||||
+ ### 🚀 Features
|
||||
+
|
||||
+ - Update packages in both groups with a feat
|
||||
+
|
||||
+
|
||||
+ ### 🩹 Fixes
|
||||
+
|
||||
+ - Update packages in both groups with a bug fix
|
||||
`
|
||||
);
|
||||
expect(result2WithoutDate).toContain(
|
||||
`NX Generating an entry in ${pkg3}/CHANGELOG.md for ${pkg3}@0.0.2
|
||||
|
||||
|
||||
|
||||
+ ## 0.0.2 (YYYY-MM-DD)
|
||||
+
|
||||
+
|
||||
+ ### 🩹 Fixes
|
||||
+
|
||||
+ - Update packages in both groups with a bug fix`
|
||||
);
|
||||
|
||||
expect(result2WithoutDate).toContain(
|
||||
`NX Generating an entry in ${pkg4}/CHANGELOG.md for ${pkg4}@0.2.0-0
|
||||
|
||||
|
||||
|
||||
+ ## 0.2.0-0 (YYYY-MM-DD)
|
||||
+
|
||||
+
|
||||
+ ### 🚀 Features
|
||||
+
|
||||
+ - Update packages in both groups with a feat`
|
||||
);
|
||||
|
||||
expect(result2WithoutDate).toContain(
|
||||
`NX Generating an entry in ${pkg5}/CHANGELOG.md for ${pkg5}@0.0.1
|
||||
|
||||
|
||||
|
||||
+ ## 0.0.1 (YYYY-MM-DD)
|
||||
+
|
||||
+
|
||||
+ ### 🚀 Features
|
||||
+
|
||||
+ - Update packages in both groups with a feat`
|
||||
);
|
||||
|
||||
expect(exists(join(versionPlansDir, 'bump-mixed1.md'))).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should pick new versions based on version plans using subcommands', async () => {
|
||||
updateJson<NxJsonConfiguration>('nx.json', (nxJson) => {
|
||||
nxJson.release = {
|
||||
projects: [pkg1, pkg2],
|
||||
releaseTagPattern: 'v{version}',
|
||||
changelog: {
|
||||
projectChangelogs: true,
|
||||
},
|
||||
versionPlans: true,
|
||||
};
|
||||
return nxJson;
|
||||
});
|
||||
|
||||
const versionPlansDir = tmpProjPath('.nx/version-plans');
|
||||
await ensureDir(versionPlansDir);
|
||||
|
||||
runCLI(
|
||||
'release plan minor -m "feat: Update the fixed packages with a minor release." --verbose',
|
||||
{
|
||||
silenceError: true,
|
||||
}
|
||||
);
|
||||
|
||||
// don't commit the new version plan file - it should still be picked up by the release command and deleted appropriately
|
||||
|
||||
const versionResult = runCLI('release version --verbose', {
|
||||
silenceError: true,
|
||||
});
|
||||
|
||||
expect(versionResult).toContain(
|
||||
`${pkg1} 📄 Resolved the specifier as "minor" using version plans.`
|
||||
);
|
||||
// pkg2 uses the previously resolved specifier from pkg1
|
||||
expect(versionResult).toContain(
|
||||
`${pkg2} ✍️ New version 0.1.0 written to ${pkg2}/package.json`
|
||||
);
|
||||
|
||||
const changelogResult = runCLI('release changelog 0.1.0 --verbose', {
|
||||
silenceError: true,
|
||||
});
|
||||
|
||||
const changelogResultWithoutDate = changelogResult.replace(
|
||||
/\(\d{4}-\d{2}-\d{2}\)/g,
|
||||
'(YYYY-MM-DD)'
|
||||
);
|
||||
|
||||
expect(changelogResultWithoutDate).toContain(
|
||||
`NX Generating an entry in ${pkg1}/CHANGELOG.md for v0.1.0
|
||||
|
||||
|
||||
+ ## 0.1.0 (YYYY-MM-DD)
|
||||
+
|
||||
+
|
||||
+ ### 🚀 Features
|
||||
+
|
||||
+ - Update the fixed packages with a minor release.`
|
||||
);
|
||||
expect(changelogResultWithoutDate).toContain(
|
||||
`NX Generating an entry in ${pkg2}/CHANGELOG.md for v0.1.0
|
||||
|
||||
|
||||
+ ## 0.1.0 (YYYY-MM-DD)
|
||||
+
|
||||
+
|
||||
+ ### 🚀 Features
|
||||
+
|
||||
+ - Update the fixed packages with a minor release.`
|
||||
);
|
||||
|
||||
expect(readdirSync(versionPlansDir)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@ -327,6 +327,7 @@
|
||||
"enquirer": "~2.3.6",
|
||||
"fast-glob": "3.2.7",
|
||||
"framer-motion": "^11.1.7",
|
||||
"front-matter": "^4.0.2",
|
||||
"glob": "7.1.4",
|
||||
"history": "^5.3.0",
|
||||
"json-schema-to-typescript": "^10.1.5",
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import {
|
||||
ProjectGraph,
|
||||
ProjectGraphDependency,
|
||||
ProjectGraphProjectNode,
|
||||
Tree,
|
||||
formatFiles,
|
||||
@ -12,9 +10,14 @@ import {
|
||||
writeJson,
|
||||
} from '@nx/devkit';
|
||||
import * as chalk from 'chalk';
|
||||
import { remove } from 'fs-extra';
|
||||
import { exec } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { IMPLICIT_DEFAULT_RELEASE_GROUP } from 'nx/src/command-line/release/config/config';
|
||||
import {
|
||||
GroupVersionPlan,
|
||||
ProjectsVersionPlan,
|
||||
} from 'nx/src/command-line/release/config/version-plans';
|
||||
import {
|
||||
getFirstGitCommit,
|
||||
getLatestGitTagForPattern,
|
||||
@ -32,15 +35,15 @@ import {
|
||||
} from 'nx/src/command-line/release/version';
|
||||
import { interpolate } from 'nx/src/tasks-runner/utils';
|
||||
import * as ora from 'ora';
|
||||
import { prerelease } from 'semver';
|
||||
import { ReleaseType, gt, inc, prerelease } from 'semver';
|
||||
import { parseRegistryOptions } from '../../utils/npm-config';
|
||||
import { ReleaseVersionGeneratorSchema } from './schema';
|
||||
import {
|
||||
LocalPackageDependency,
|
||||
resolveLocalPackageDependencies,
|
||||
} from './utils/resolve-local-package-dependencies';
|
||||
import { updateLockFile } from './utils/update-lock-file';
|
||||
import { sortProjectsTopologically } from './utils/sort-projects-topologically';
|
||||
import { updateLockFile } from './utils/update-lock-file';
|
||||
|
||||
export async function releaseVersionGenerator(
|
||||
tree: Tree,
|
||||
@ -119,6 +122,10 @@ Valid values are: ${validReleaseVersionPrefixes
|
||||
? options.specifier
|
||||
: undefined;
|
||||
|
||||
const deleteVersionPlanCallbacks: ((
|
||||
dryRun?: boolean
|
||||
) => Promise<string[]>)[] = [];
|
||||
|
||||
for (const project of projects) {
|
||||
const projectName = project.name;
|
||||
const packageRoot = projectNameToPackageRootMap.get(projectName);
|
||||
@ -317,6 +324,8 @@ To fix this you will either need to add a package.json file at that location, or
|
||||
|
||||
if (options.specifier) {
|
||||
log(`📄 Using the provided version specifier "${options.specifier}".`);
|
||||
// The user is forcibly overriding whatever specifierSource they had otherwise set by imperatively providing a specifier
|
||||
options.specifierSource = 'prompt';
|
||||
}
|
||||
|
||||
/**
|
||||
@ -434,9 +443,99 @@ To fix this you will either need to add a package.json file at that location, or
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'version-plans': {
|
||||
if (!options.releaseGroup.versionPlans) {
|
||||
if (
|
||||
options.releaseGroup.name === IMPLICIT_DEFAULT_RELEASE_GROUP
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid specifierSource "version-plans" provided. To enable version plans, set the "release.versionPlans" configuration option to "true" in nx.json.`
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Invalid specifierSource "version-plans" provided. To enable version plans for release group "${options.releaseGroup.name}", set the "versionPlans" configuration option to "true" within the release group configuration in nx.json.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.releaseGroup.projectsRelationship === 'independent') {
|
||||
specifier = (
|
||||
options.releaseGroup.versionPlans as ProjectsVersionPlan[]
|
||||
).reduce((spec: ReleaseType, plan: ProjectsVersionPlan) => {
|
||||
if (!spec) {
|
||||
return plan.projectVersionBumps[projectName];
|
||||
}
|
||||
if (plan.projectVersionBumps[projectName]) {
|
||||
const prevNewVersion = inc(currentVersion, spec);
|
||||
const nextNewVersion = inc(
|
||||
currentVersion,
|
||||
plan.projectVersionBumps[projectName]
|
||||
);
|
||||
return gt(nextNewVersion, prevNewVersion)
|
||||
? plan.projectVersionBumps[projectName]
|
||||
: spec;
|
||||
}
|
||||
return spec;
|
||||
}, null);
|
||||
} else {
|
||||
specifier = (
|
||||
options.releaseGroup.versionPlans as GroupVersionPlan[]
|
||||
).reduce((spec: ReleaseType, plan: GroupVersionPlan) => {
|
||||
if (!spec) {
|
||||
return plan.groupVersionBump;
|
||||
}
|
||||
|
||||
const prevNewVersion = inc(currentVersion, spec);
|
||||
const nextNewVersion = inc(
|
||||
currentVersion,
|
||||
plan.groupVersionBump
|
||||
);
|
||||
return gt(nextNewVersion, prevNewVersion)
|
||||
? plan.groupVersionBump
|
||||
: spec;
|
||||
}, null);
|
||||
}
|
||||
|
||||
if (!specifier) {
|
||||
if (
|
||||
updateDependents !== 'never' &&
|
||||
projectToDependencyBumps.has(projectName)
|
||||
) {
|
||||
// No applicable changes to the project directly by the user, but one or more dependencies have been bumped and updateDependents is enabled
|
||||
specifier = updateDependentsBump;
|
||||
log(
|
||||
`📄 Resolved the specifier as "${specifier}" because "release.version.generatorOptions.updateDependents" is enabled`
|
||||
);
|
||||
} else {
|
||||
specifier = null;
|
||||
log(`🚫 No changes were detected within version plans.`);
|
||||
}
|
||||
} else {
|
||||
log(
|
||||
`📄 Resolved the specifier as "${specifier}" using version plans.`
|
||||
);
|
||||
}
|
||||
|
||||
if (options.deleteVersionPlans) {
|
||||
options.releaseGroup.versionPlans.forEach((p) => {
|
||||
deleteVersionPlanCallbacks.push(async (dryRun?: boolean) => {
|
||||
if (!dryRun) {
|
||||
await remove(p.absolutePath);
|
||||
// the relative path is easier to digest, so use that for
|
||||
// git operations and logging
|
||||
return [p.relativePath];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(
|
||||
`Invalid specifierSource "${specifierSource}" provided. Must be one of "prompt" or "conventional-commits"`
|
||||
`Invalid specifierSource "${specifierSource}" provided. Must be one of "prompt", "conventional-commits" or "version-plans".`
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -489,9 +588,22 @@ To fix this you will either need to add a package.json file at that location, or
|
||||
);
|
||||
}
|
||||
|
||||
const isInCurrentBatch = options.projects.some(
|
||||
let isInCurrentBatch = options.projects.some(
|
||||
(project) => project.name === dependentProject.source
|
||||
);
|
||||
|
||||
// For version-plans, we don't just need to consider the current batch of projects, but also the ones that are actually being updated as part of the plan file(s)
|
||||
if (isInCurrentBatch && options.specifierSource === 'version-plans') {
|
||||
isInCurrentBatch = (options.releaseGroup.versionPlans || []).some(
|
||||
(plan) => {
|
||||
if ('projectVersionBumps' in plan) {
|
||||
return plan.projectVersionBumps[dependentProject.source];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!isInCurrentBatch) {
|
||||
dependentProjectsOutsideCurrentBatch.push(dependentProject);
|
||||
} else {
|
||||
@ -503,10 +615,14 @@ To fix this you will either need to add a package.json file at that location, or
|
||||
if (updateDependents === 'never') {
|
||||
if (dependentProjectsOutsideCurrentBatch.length > 0) {
|
||||
let logMsg = `⚠️ Warning, the following packages depend on "${project.name}"`;
|
||||
const reason =
|
||||
options.specifierSource === 'version-plans'
|
||||
? 'because they are not referenced in any version plans'
|
||||
: 'via --projects';
|
||||
if (options.releaseGroup.name === IMPLICIT_DEFAULT_RELEASE_GROUP) {
|
||||
logMsg += ` but have been filtered out via --projects, and therefore will not be updated:`;
|
||||
logMsg += ` but have been filtered out ${reason}, and therefore will not be updated:`;
|
||||
} else {
|
||||
logMsg += ` but are either not part of the current release group "${options.releaseGroup.name}", or have been filtered out via --projects, and therefore will not be updated:`;
|
||||
logMsg += ` but are either not part of the current release group "${options.releaseGroup.name}", or have been filtered out ${reason}, and therefore will not be updated:`;
|
||||
}
|
||||
const indent = Array.from(new Array(projectName.length + 4))
|
||||
.map(() => ' ')
|
||||
@ -664,12 +780,27 @@ To fix this you will either need to add a package.json file at that location, or
|
||||
|
||||
if (updateDependents === 'auto') {
|
||||
for (const dependentProject of dependentProjectsOutsideCurrentBatch) {
|
||||
if (
|
||||
options.specifierSource === 'version-plans' &&
|
||||
!projectToDependencyBumps.has(dependentProject.source)
|
||||
) {
|
||||
projectToDependencyBumps.set(
|
||||
dependentProject.source,
|
||||
new Set([projectName])
|
||||
);
|
||||
}
|
||||
|
||||
updateDependentProjectAndAddToVersionData({
|
||||
dependentProject,
|
||||
dependencyPackageName: packageName,
|
||||
newDependencyVersion: newVersion,
|
||||
// For these additional dependents, we need to update their package.json version as well because we know they will not come later in the topologically sorted projects loop
|
||||
forceVersionBump: updateDependentsBump,
|
||||
// (Unless using version plans and the dependent is not filtered out by --projects)
|
||||
forceVersionBump:
|
||||
options.specifierSource === 'version-plans' &&
|
||||
projects.find((p) => p.name === dependentProject.source)
|
||||
? false
|
||||
: updateDependentsBump,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -719,9 +850,16 @@ To fix this you will either need to add a package.json file at that location, or
|
||||
return {
|
||||
data: versionData,
|
||||
callback: async (tree, opts) => {
|
||||
const changedFiles: string[] = [];
|
||||
const deletedFiles: string[] = [];
|
||||
|
||||
for (const cb of deleteVersionPlanCallbacks) {
|
||||
deletedFiles.push(...(await cb(opts.dryRun)));
|
||||
}
|
||||
|
||||
const cwd = tree.root;
|
||||
const updatedFiles = await updateLockFile(cwd, opts);
|
||||
return updatedFiles;
|
||||
changedFiles.push(...(await updateLockFile(cwd, opts)));
|
||||
return { changedFiles, deletedFiles };
|
||||
},
|
||||
};
|
||||
} catch (e: any) {
|
||||
|
||||
@ -29,7 +29,7 @@
|
||||
"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"]
|
||||
"enum": ["prompt", "conventional-commits", "version-plans"]
|
||||
},
|
||||
"preid": {
|
||||
"type": "string",
|
||||
|
||||
@ -50,6 +50,7 @@
|
||||
"enquirer": "~2.3.6",
|
||||
"figures": "3.2.0",
|
||||
"flat": "^5.0.2",
|
||||
"front-matter": "^4.0.2",
|
||||
"fs-extra": "^11.1.0",
|
||||
"ignore": "^5.0.4",
|
||||
"jest-diff": "^29.4.1",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { ChangelogChange } from '../../src/command-line/release/changelog';
|
||||
import { DEFAULT_CONVENTIONAL_COMMITS_CONFIG } from '../../src/command-line/release/config/conventional-commits';
|
||||
import type { GitCommit } from '../../src/command-line/release/utils/git';
|
||||
import defaultChangelogRenderer from './index';
|
||||
|
||||
jest.mock('../../src/project-graph/file-map-utils', () => ({
|
||||
@ -29,25 +29,18 @@ describe('defaultChangelogRenderer()', () => {
|
||||
const projectGraph = {
|
||||
nodes: {},
|
||||
} as any;
|
||||
const commits: GitCommit[] = [
|
||||
const changes: ChangelogChange[] = [
|
||||
{
|
||||
message: 'fix: all packages fixed',
|
||||
shortHash: '4130f65',
|
||||
author: {
|
||||
name: 'James Henry',
|
||||
email: 'jh@example.com',
|
||||
},
|
||||
body: '"\n\nM\tpackages/pkg-a/src/index.ts\nM\tpackages/pkg-b/src/index.ts\n"',
|
||||
authors: [
|
||||
{
|
||||
name: 'James Henry',
|
||||
email: 'jh@example.com',
|
||||
},
|
||||
],
|
||||
description: 'all packages fixed',
|
||||
type: 'fix',
|
||||
scope: '',
|
||||
references: [
|
||||
githubReferences: [
|
||||
{
|
||||
value: '4130f65',
|
||||
type: 'hash',
|
||||
@ -55,29 +48,19 @@ describe('defaultChangelogRenderer()', () => {
|
||||
],
|
||||
isBreaking: false,
|
||||
revertedHashes: [],
|
||||
affectedFiles: [
|
||||
'packages/pkg-a/src/index.ts',
|
||||
'packages/pkg-b/src/index.ts',
|
||||
],
|
||||
affectedProjects: ['pkg-a', 'pkg-b'],
|
||||
},
|
||||
{
|
||||
message: 'feat(pkg-b): and another new capability',
|
||||
shortHash: '7dc5ec3',
|
||||
author: {
|
||||
name: 'James Henry',
|
||||
email: 'jh@example.com',
|
||||
},
|
||||
body: '"\n\nM\tpackages/pkg-b/src/index.ts\n"',
|
||||
authors: [
|
||||
{
|
||||
name: 'James Henry',
|
||||
email: 'jh@example.com',
|
||||
},
|
||||
],
|
||||
description: 'and another new capability',
|
||||
type: 'feat',
|
||||
scope: 'pkg-b',
|
||||
references: [
|
||||
githubReferences: [
|
||||
{
|
||||
value: '7dc5ec3',
|
||||
type: 'hash',
|
||||
@ -85,26 +68,19 @@ describe('defaultChangelogRenderer()', () => {
|
||||
],
|
||||
isBreaking: false,
|
||||
revertedHashes: [],
|
||||
affectedFiles: ['packages/pkg-b/src/index.ts'],
|
||||
affectedProjects: ['pkg-b'],
|
||||
},
|
||||
{
|
||||
message: 'feat(pkg-a): new hotness',
|
||||
shortHash: 'd7a58a2',
|
||||
author: {
|
||||
name: 'James Henry',
|
||||
email: 'jh@example.com',
|
||||
},
|
||||
body: '"\n\nM\tpackages/pkg-a/src/index.ts\n"',
|
||||
authors: [
|
||||
{
|
||||
name: 'James Henry',
|
||||
email: 'jh@example.com',
|
||||
},
|
||||
],
|
||||
description: 'new hotness',
|
||||
type: 'feat',
|
||||
scope: 'pkg-a',
|
||||
references: [
|
||||
githubReferences: [
|
||||
{
|
||||
value: 'd7a58a2',
|
||||
type: 'hash',
|
||||
@ -112,26 +88,19 @@ describe('defaultChangelogRenderer()', () => {
|
||||
],
|
||||
isBreaking: false,
|
||||
revertedHashes: [],
|
||||
affectedFiles: ['packages/pkg-a/src/index.ts'],
|
||||
affectedProjects: ['pkg-a'],
|
||||
},
|
||||
{
|
||||
message: 'feat(pkg-b): brand new thing',
|
||||
shortHash: 'feace4a',
|
||||
author: {
|
||||
name: 'James Henry',
|
||||
email: 'jh@example.com',
|
||||
},
|
||||
body: '"\n\nM\tpackages/pkg-b/src/index.ts\n"',
|
||||
authors: [
|
||||
{
|
||||
name: 'James Henry',
|
||||
email: 'jh@example.com',
|
||||
},
|
||||
],
|
||||
description: 'brand new thing',
|
||||
type: 'feat',
|
||||
scope: 'pkg-b',
|
||||
references: [
|
||||
githubReferences: [
|
||||
{
|
||||
value: 'feace4a',
|
||||
type: 'hash',
|
||||
@ -139,26 +108,19 @@ describe('defaultChangelogRenderer()', () => {
|
||||
],
|
||||
isBreaking: false,
|
||||
revertedHashes: [],
|
||||
affectedFiles: ['packages/pkg-b/src/index.ts'],
|
||||
affectedProjects: ['pkg-b'],
|
||||
},
|
||||
{
|
||||
message: 'fix(pkg-a): squashing bugs',
|
||||
shortHash: '6301405',
|
||||
author: {
|
||||
name: 'James Henry',
|
||||
email: 'jh@example.com',
|
||||
},
|
||||
body: '"\n\nM\tpackages/pkg-a/src/index.ts\n',
|
||||
authors: [
|
||||
{
|
||||
name: 'James Henry',
|
||||
email: 'jh@example.com',
|
||||
},
|
||||
],
|
||||
description: 'squashing bugs',
|
||||
type: 'fix',
|
||||
scope: 'pkg-a',
|
||||
references: [
|
||||
githubReferences: [
|
||||
{
|
||||
value: '6301405',
|
||||
type: 'hash',
|
||||
@ -166,7 +128,7 @@ describe('defaultChangelogRenderer()', () => {
|
||||
],
|
||||
isBreaking: false,
|
||||
revertedHashes: [],
|
||||
affectedFiles: ['packages/pkg-a/src/index.ts'],
|
||||
affectedProjects: ['pkg-a'],
|
||||
},
|
||||
];
|
||||
|
||||
@ -174,7 +136,7 @@ describe('defaultChangelogRenderer()', () => {
|
||||
it('should generate markdown for all projects by organizing commits by type, then grouped by scope within the type (sorted alphabetically), then chronologically within the scope group', async () => {
|
||||
const markdown = await defaultChangelogRenderer({
|
||||
projectGraph,
|
||||
commits,
|
||||
changes,
|
||||
releaseVersion: 'v1.1.0',
|
||||
project: null,
|
||||
entryWhenNoChanges: false,
|
||||
@ -207,7 +169,7 @@ describe('defaultChangelogRenderer()', () => {
|
||||
it('should not generate a Thank You section when changelogRenderOptions.authors is false', async () => {
|
||||
const markdown = await defaultChangelogRenderer({
|
||||
projectGraph,
|
||||
commits,
|
||||
changes,
|
||||
// Major version, should use single # for generated heading
|
||||
releaseVersion: 'v1.0.0',
|
||||
project: null,
|
||||
@ -239,7 +201,7 @@ describe('defaultChangelogRenderer()', () => {
|
||||
it('should generate markdown for the given project by organizing commits by type, then chronologically', async () => {
|
||||
const otherOpts = {
|
||||
projectGraph,
|
||||
commits,
|
||||
changes,
|
||||
releaseVersion: 'v1.1.0',
|
||||
entryWhenNoChanges: false as const,
|
||||
changelogRenderOptions: {
|
||||
@ -331,7 +293,7 @@ describe('defaultChangelogRenderer()', () => {
|
||||
it('should respect the entryWhenNoChanges option for the workspace changelog', async () => {
|
||||
const otherOpts = {
|
||||
projectGraph,
|
||||
commits: [],
|
||||
changes: [],
|
||||
releaseVersion: 'v1.1.0',
|
||||
project: null, // workspace changelog
|
||||
changelogRenderOptions: {
|
||||
@ -362,7 +324,7 @@ describe('defaultChangelogRenderer()', () => {
|
||||
it('should respect the entryWhenNoChanges option for project changelogs', async () => {
|
||||
const otherOpts = {
|
||||
projectGraph,
|
||||
commits: [],
|
||||
changes: [],
|
||||
releaseVersion: 'v1.1.0',
|
||||
project: 'pkg-a',
|
||||
changelogRenderOptions: {
|
||||
@ -393,27 +355,19 @@ describe('defaultChangelogRenderer()', () => {
|
||||
|
||||
describe('revert commits', () => {
|
||||
it('should generate a Revert section for the changelog if the reverted commit is not part of the same release', async () => {
|
||||
const commitsWithOnlyRevert: GitCommit[] = [
|
||||
const changesWithOnlyRevert: ChangelogChange[] = [
|
||||
{
|
||||
message:
|
||||
'Revert "fix(release): do not update dependents when they already use "*" (#20607)"',
|
||||
shortHash: '6528e88aa',
|
||||
author: {
|
||||
name: 'James Henry',
|
||||
email: 'jh@example.com',
|
||||
},
|
||||
body: 'This reverts commit 6d68236d467812aba4557a2bc7f667157de80fdb.\n"\n\nM\tpackages/js/src/generators/release-version/release-version.spec.ts\nM\tpackages/js/src/generators/release-version/release-version.ts\n',
|
||||
authors: [
|
||||
{
|
||||
name: 'James Henry',
|
||||
email: 'jh@example.com',
|
||||
},
|
||||
],
|
||||
description:
|
||||
'Revert "fix(release): do not update dependents when they already use "*" (#20607)"',
|
||||
type: 'revert',
|
||||
scope: 'release',
|
||||
references: [
|
||||
githubReferences: [
|
||||
{
|
||||
type: 'pull-request',
|
||||
value: '#20607',
|
||||
@ -425,16 +379,13 @@ describe('defaultChangelogRenderer()', () => {
|
||||
],
|
||||
isBreaking: false,
|
||||
revertedHashes: ['6d68236d467812aba4557a2bc7f667157de80fdb'],
|
||||
affectedFiles: [
|
||||
'packages/js/src/generators/release-version/release-version.spec.ts',
|
||||
'packages/js/src/generators/release-version/release-version.ts',
|
||||
],
|
||||
affectedProjects: ['js'],
|
||||
},
|
||||
];
|
||||
|
||||
const markdown = await defaultChangelogRenderer({
|
||||
projectGraph,
|
||||
commits: commitsWithOnlyRevert,
|
||||
changes: changesWithOnlyRevert,
|
||||
releaseVersion: 'v1.1.0',
|
||||
project: null,
|
||||
entryWhenNoChanges: false,
|
||||
@ -459,27 +410,19 @@ describe('defaultChangelogRenderer()', () => {
|
||||
});
|
||||
|
||||
it('should strip both the original commit and its revert if they are both included in the current range of commits', async () => {
|
||||
const commitsWithRevertAndOriginal: GitCommit[] = [
|
||||
const changesWithRevertAndOriginal: ChangelogChange[] = [
|
||||
{
|
||||
message:
|
||||
'Revert "fix(release): do not update dependents when they already use "*" (#20607)"',
|
||||
shortHash: '6528e88aa',
|
||||
author: {
|
||||
name: 'James Henry',
|
||||
email: 'jh@example.com',
|
||||
},
|
||||
body: 'This reverts commit 6d68236d467812aba4557a2bc7f667157de80fdb.\n"\n\nM\tpackages/js/src/generators/release-version/release-version.spec.ts\nM\tpackages/js/src/generators/release-version/release-version.ts\n',
|
||||
authors: [
|
||||
{
|
||||
name: 'James Henry',
|
||||
email: 'jh@example.com',
|
||||
},
|
||||
],
|
||||
description:
|
||||
'Revert "fix(release): do not update dependents when they already use "*" (#20607)"',
|
||||
type: 'revert',
|
||||
scope: 'release',
|
||||
references: [
|
||||
githubReferences: [
|
||||
{
|
||||
type: 'pull-request',
|
||||
value: '#20607',
|
||||
@ -491,30 +434,19 @@ describe('defaultChangelogRenderer()', () => {
|
||||
],
|
||||
isBreaking: false,
|
||||
revertedHashes: ['6d68236d467812aba4557a2bc7f667157de80fdb'],
|
||||
affectedFiles: [
|
||||
'packages/js/src/generators/release-version/release-version.spec.ts',
|
||||
'packages/js/src/generators/release-version/release-version.ts',
|
||||
],
|
||||
affectedProjects: ['js'],
|
||||
},
|
||||
{
|
||||
message:
|
||||
'fix(release): do not update dependents when they already use "*" (#20607)',
|
||||
shortHash: '6d68236d4',
|
||||
author: {
|
||||
name: 'James Henry',
|
||||
email: 'jh@example.com',
|
||||
},
|
||||
body: '"\n\nM\tpackages/js/src/generators/release-version/release-version.spec.ts\nM\tpackages/js/src/generators/release-version/release-version.ts\n',
|
||||
authors: [
|
||||
{
|
||||
name: 'James Henry',
|
||||
email: 'jh@example.com',
|
||||
},
|
||||
],
|
||||
description: 'do not update dependents when they already use "*"',
|
||||
type: 'fix',
|
||||
scope: 'release',
|
||||
references: [
|
||||
githubReferences: [
|
||||
{
|
||||
type: 'pull-request',
|
||||
value: '#20607',
|
||||
@ -526,16 +458,13 @@ describe('defaultChangelogRenderer()', () => {
|
||||
],
|
||||
isBreaking: false,
|
||||
revertedHashes: [],
|
||||
affectedFiles: [
|
||||
'packages/js/src/generators/release-version/release-version.spec.ts',
|
||||
'packages/js/src/generators/release-version/release-version.ts',
|
||||
],
|
||||
affectedProjects: ['js'],
|
||||
},
|
||||
];
|
||||
|
||||
const markdown = await defaultChangelogRenderer({
|
||||
projectGraph,
|
||||
commits: commitsWithRevertAndOriginal,
|
||||
changes: changesWithRevertAndOriginal,
|
||||
releaseVersion: 'v1.1.0',
|
||||
project: null,
|
||||
entryWhenNoChanges: false,
|
||||
@ -551,9 +480,7 @@ describe('defaultChangelogRenderer()', () => {
|
||||
|
||||
describe('breaking changes', () => {
|
||||
it('should work for breaking changes with just the ! and no explanation', async () => {
|
||||
const breakingChangeCommitWithExplanation: GitCommit = {
|
||||
// ! after the type, no BREAKING CHANGE: in the body
|
||||
message: 'feat(WebSocketSubject)!: no longer extends `Subject`.',
|
||||
const breakingChangeWithExplanation: ChangelogChange = {
|
||||
shortHash: '54f2f6ed1',
|
||||
author: {
|
||||
name: 'James Henry',
|
||||
@ -562,26 +489,18 @@ describe('defaultChangelogRenderer()', () => {
|
||||
body:
|
||||
'M\tpackages/rxjs/src/internal/observable/dom/WebSocketSubject.ts\n' +
|
||||
'"',
|
||||
authors: [
|
||||
{
|
||||
name: 'James Henry',
|
||||
email: 'jh@example.com',
|
||||
},
|
||||
],
|
||||
description: 'no longer extends `Subject`.',
|
||||
type: 'feat',
|
||||
scope: 'WebSocketSubject',
|
||||
references: [{ value: '54f2f6ed1', type: 'hash' }],
|
||||
githubReferences: [{ value: '54f2f6ed1', type: 'hash' }],
|
||||
isBreaking: true,
|
||||
revertedHashes: [],
|
||||
affectedFiles: [
|
||||
'packages/rxjs/src/internal/observable/dom/WebSocketSubject.ts',
|
||||
],
|
||||
affectedProjects: ['rxjs'],
|
||||
};
|
||||
|
||||
const markdown = await defaultChangelogRenderer({
|
||||
projectGraph,
|
||||
commits: [breakingChangeCommitWithExplanation],
|
||||
changes: [breakingChangeWithExplanation],
|
||||
releaseVersion: 'v1.1.0',
|
||||
project: null,
|
||||
entryWhenNoChanges: false,
|
||||
@ -610,9 +529,7 @@ describe('defaultChangelogRenderer()', () => {
|
||||
});
|
||||
|
||||
it('should extract the explanation of a breaking change and render it preferentially', async () => {
|
||||
const breakingChangeCommitWithExplanation: GitCommit = {
|
||||
// No ! after the type, but BREAKING CHANGE: in the body
|
||||
message: 'feat(WebSocketSubject): no longer extends `Subject`.',
|
||||
const breakingChangeWithExplanation: ChangelogChange = {
|
||||
shortHash: '54f2f6ed1',
|
||||
author: {
|
||||
name: 'James Henry',
|
||||
@ -624,26 +541,18 @@ describe('defaultChangelogRenderer()', () => {
|
||||
'\n' +
|
||||
'M\tpackages/rxjs/src/internal/observable/dom/WebSocketSubject.ts\n' +
|
||||
'"',
|
||||
authors: [
|
||||
{
|
||||
name: 'James Henry',
|
||||
email: 'jh@example.com',
|
||||
},
|
||||
],
|
||||
description: 'no longer extends `Subject`.',
|
||||
type: 'feat',
|
||||
scope: 'WebSocketSubject',
|
||||
references: [{ value: '54f2f6ed1', type: 'hash' }],
|
||||
githubReferences: [{ value: '54f2f6ed1', type: 'hash' }],
|
||||
isBreaking: true,
|
||||
revertedHashes: [],
|
||||
affectedFiles: [
|
||||
'packages/rxjs/src/internal/observable/dom/WebSocketSubject.ts',
|
||||
],
|
||||
affectedProjects: ['rxjs'],
|
||||
};
|
||||
|
||||
const markdown = await defaultChangelogRenderer({
|
||||
projectGraph,
|
||||
commits: [breakingChangeCommitWithExplanation],
|
||||
changes: [breakingChangeWithExplanation],
|
||||
releaseVersion: 'v1.1.0',
|
||||
project: null,
|
||||
entryWhenNoChanges: false,
|
||||
@ -673,11 +582,11 @@ describe('defaultChangelogRenderer()', () => {
|
||||
});
|
||||
|
||||
describe('dependency bumps', () => {
|
||||
it('should render the dependency bumps in addition to the commits', async () => {
|
||||
it('should render the dependency bumps in addition to the changes', async () => {
|
||||
expect(
|
||||
await defaultChangelogRenderer({
|
||||
projectGraph,
|
||||
commits,
|
||||
changes,
|
||||
releaseVersion: 'v1.1.0',
|
||||
entryWhenNoChanges: false as const,
|
||||
changelogRenderOptions: {
|
||||
@ -719,11 +628,11 @@ describe('defaultChangelogRenderer()', () => {
|
||||
`);
|
||||
});
|
||||
|
||||
it('should render the dependency bumps and release version title even when there are no commits', async () => {
|
||||
it('should render the dependency bumps and release version title even when there are no changes', async () => {
|
||||
expect(
|
||||
await defaultChangelogRenderer({
|
||||
projectGraph,
|
||||
commits: [],
|
||||
changes: [],
|
||||
releaseVersion: 'v3.1.0',
|
||||
entryWhenNoChanges:
|
||||
'should not be printed because we have dependency bumps',
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { major } from 'semver';
|
||||
import { ChangelogChange } from '../../src/command-line/release/changelog';
|
||||
import { NxReleaseConfig } from '../../src/command-line/release/config/config';
|
||||
import type { GitCommit } from '../../src/command-line/release/utils/git';
|
||||
import { GitCommit } from '../../src/command-line/release/utils/git';
|
||||
import {
|
||||
RepoSlug,
|
||||
formatReferences,
|
||||
} from '../../src/command-line/release/utils/github';
|
||||
import { getCommitsRelevantToProjects } from '../../src/command-line/release/utils/shared';
|
||||
import type { ProjectGraph } from '../../src/config/project-graph';
|
||||
|
||||
// axios types and values don't seem to match
|
||||
@ -34,7 +34,8 @@ export type DependencyBump = {
|
||||
*
|
||||
* @param {Object} config The configuration object for the ChangelogRenderer
|
||||
* @param {ProjectGraph} config.projectGraph The project graph for the workspace
|
||||
* @param {GitCommit[]} config.commits The collection of extracted commits to generate a changelog for
|
||||
* @param {GitCommit[]} config.commits DEPRECATED [Use 'config.changes' instead] - The collection of extracted commits to generate a changelog for
|
||||
* @param {ChangelogChange[]} config.changes The collection of changes to show in the changelog
|
||||
* @param {string} config.releaseVersion The version that is being released
|
||||
* @param {string | null} config.project The name of specific project to generate a changelog for, or `null` if the overall workspace changelog
|
||||
* @param {string | false} config.entryWhenNoChanges The (already interpolated) string to use as the changelog entry when there are no changes, or `false` if no entry should be generated
|
||||
@ -43,7 +44,9 @@ export type DependencyBump = {
|
||||
*/
|
||||
export type ChangelogRenderer = (config: {
|
||||
projectGraph: ProjectGraph;
|
||||
commits: GitCommit[];
|
||||
// TODO: remove 'commits' and make 'changes' whenever we make the next breaking change to this API
|
||||
commits?: GitCommit[];
|
||||
changes?: ChangelogChange[];
|
||||
releaseVersion: string;
|
||||
project: string | null;
|
||||
entryWhenNoChanges: string | false;
|
||||
@ -81,7 +84,7 @@ export interface DefaultChangelogRenderOptions extends ChangelogRenderOptions {
|
||||
*/
|
||||
const defaultChangelogRenderer: ChangelogRenderer = async ({
|
||||
projectGraph,
|
||||
commits,
|
||||
changes,
|
||||
releaseVersion,
|
||||
project,
|
||||
entryWhenNoChanges,
|
||||
@ -90,20 +93,20 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({
|
||||
repoSlug,
|
||||
conventionalCommitsConfig,
|
||||
}): Promise<string> => {
|
||||
const commitTypes = conventionalCommitsConfig.types;
|
||||
const changeTypes = conventionalCommitsConfig.types;
|
||||
const markdownLines: string[] = [];
|
||||
const breakingChanges = [];
|
||||
|
||||
// If the current range of commits contains both a commit and its revert, we strip them both from the final list
|
||||
for (const commit of commits) {
|
||||
if (commit.type === 'revert') {
|
||||
for (const revertedHash of commit.revertedHashes) {
|
||||
const revertedCommit = commits.find((c) =>
|
||||
revertedHash.startsWith(c.shortHash)
|
||||
// If the current range of changes contains both a commit and its revert, we strip them both from the final list. Changes from version plans are unaffected, as they have no hashes.
|
||||
for (const change of changes) {
|
||||
if (change.type === 'revert' && change.revertedHashes) {
|
||||
for (const revertedHash of change.revertedHashes) {
|
||||
const revertedCommit = changes.find(
|
||||
(c) => c.shortHash && revertedHash.startsWith(c.shortHash)
|
||||
);
|
||||
if (revertedCommit) {
|
||||
commits.splice(commits.indexOf(revertedCommit), 1);
|
||||
commits.splice(commits.indexOf(commit), 1);
|
||||
changes.splice(changes.indexOf(revertedCommit), 1);
|
||||
changes.splice(changes.indexOf(change), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -112,7 +115,7 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({
|
||||
// workspace root level changelog
|
||||
if (project === null) {
|
||||
// No changes for the workspace
|
||||
if (commits.length === 0) {
|
||||
if (changes.length === 0) {
|
||||
if (dependencyBumps?.length) {
|
||||
applyAdditionalDependencyBumps({
|
||||
markdownLines,
|
||||
@ -133,7 +136,10 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({
|
||||
return markdownLines.join('\n').trim();
|
||||
}
|
||||
|
||||
const typeGroups = groupBy(commits, 'type');
|
||||
const typeGroups: Record<string, ChangelogChange[]> = groupBy(
|
||||
changes,
|
||||
'type'
|
||||
);
|
||||
|
||||
markdownLines.push(
|
||||
'',
|
||||
@ -141,41 +147,41 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({
|
||||
''
|
||||
);
|
||||
|
||||
for (const type of Object.keys(commitTypes)) {
|
||||
for (const type of Object.keys(changeTypes)) {
|
||||
const group = typeGroups[type];
|
||||
if (!group || group.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
markdownLines.push('', '### ' + commitTypes[type].changelog.title, '');
|
||||
markdownLines.push('', '### ' + changeTypes[type].changelog.title, '');
|
||||
|
||||
/**
|
||||
* In order to make the final changelog most readable, we organize commits as follows:
|
||||
* - By scope, where scopes are in alphabetical order (commits with no scope are listed first)
|
||||
* - Within a particular scope grouping, we list commits in chronological order
|
||||
* In order to make the final changelog most readable, we organize changes as follows:
|
||||
* - By scope, where scopes are in alphabetical order (changes with no scope are listed first)
|
||||
* - Within a particular scope grouping, we list changes in chronological order
|
||||
*/
|
||||
const commitsInChronologicalOrder = group.reverse();
|
||||
const commitsGroupedByScope = groupBy(
|
||||
commitsInChronologicalOrder,
|
||||
const changesInChronologicalOrder = group.reverse();
|
||||
const changesGroupedByScope: Record<string, ChangelogChange[]> = groupBy(
|
||||
changesInChronologicalOrder,
|
||||
'scope'
|
||||
);
|
||||
const scopesSortedAlphabetically = Object.keys(
|
||||
commitsGroupedByScope
|
||||
changesGroupedByScope
|
||||
).sort();
|
||||
|
||||
for (const scope of scopesSortedAlphabetically) {
|
||||
const commits = commitsGroupedByScope[scope];
|
||||
for (const commit of commits) {
|
||||
const line = formatCommit(commit, changelogRenderOptions, repoSlug);
|
||||
const changes = changesGroupedByScope[scope];
|
||||
for (const change of changes) {
|
||||
const line = formatChange(change, changelogRenderOptions, repoSlug);
|
||||
markdownLines.push(line);
|
||||
if (commit.isBreaking) {
|
||||
if (change.isBreaking) {
|
||||
const breakingChangeExplanation = extractBreakingChangeExplanation(
|
||||
commit.body
|
||||
change.body
|
||||
);
|
||||
breakingChanges.push(
|
||||
breakingChangeExplanation
|
||||
? `- ${
|
||||
commit.scope ? `**${commit.scope.trim()}:** ` : ''
|
||||
change.scope ? `**${change.scope.trim()}:** ` : ''
|
||||
}${breakingChangeExplanation}`
|
||||
: line
|
||||
);
|
||||
@ -185,14 +191,14 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({
|
||||
}
|
||||
} else {
|
||||
// project level changelog
|
||||
const relevantCommits = await getCommitsRelevantToProjects(
|
||||
projectGraph,
|
||||
commits,
|
||||
[project]
|
||||
const relevantChanges = changes.filter(
|
||||
(c) =>
|
||||
c.affectedProjects &&
|
||||
(c.affectedProjects === '*' || c.affectedProjects.includes(project))
|
||||
);
|
||||
|
||||
// Generating for a named project, but that project has no relevant changes in the current set of commits, exit early
|
||||
if (relevantCommits.length === 0) {
|
||||
if (relevantChanges.length === 0) {
|
||||
if (dependencyBumps?.length) {
|
||||
applyAdditionalDependencyBumps({
|
||||
markdownLines,
|
||||
@ -219,31 +225,31 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({
|
||||
''
|
||||
);
|
||||
|
||||
const typeGroups = groupBy(
|
||||
// Sort the relevant commits to have the unscoped commits first, before grouping by type
|
||||
relevantCommits.sort((a, b) => (b.scope ? 1 : 0) - (a.scope ? 1 : 0)),
|
||||
const typeGroups: Record<string, ChangelogChange[]> = groupBy(
|
||||
// Sort the relevant changes to have the unscoped changes first, before grouping by type
|
||||
relevantChanges.sort((a, b) => (b.scope ? 1 : 0) - (a.scope ? 1 : 0)),
|
||||
'type'
|
||||
);
|
||||
for (const type of Object.keys(commitTypes)) {
|
||||
for (const type of Object.keys(changeTypes)) {
|
||||
const group = typeGroups[type];
|
||||
if (!group || group.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
markdownLines.push('', `### ${commitTypes[type].changelog.title}`, '');
|
||||
markdownLines.push('', `### ${changeTypes[type].changelog.title}`, '');
|
||||
|
||||
const commitsInChronologicalOrder = group.reverse();
|
||||
for (const commit of commitsInChronologicalOrder) {
|
||||
const line = formatCommit(commit, changelogRenderOptions, repoSlug);
|
||||
const changesInChronologicalOrder = group.reverse();
|
||||
for (const change of changesInChronologicalOrder) {
|
||||
const line = formatChange(change, changelogRenderOptions, repoSlug);
|
||||
markdownLines.push(line + '\n');
|
||||
if (commit.isBreaking) {
|
||||
if (change.isBreaking) {
|
||||
const breakingChangeExplanation = extractBreakingChangeExplanation(
|
||||
commit.body
|
||||
change.body
|
||||
);
|
||||
breakingChanges.push(
|
||||
breakingChangeExplanation
|
||||
? `- ${
|
||||
commit.scope ? `**${commit.scope.trim()}:** ` : ''
|
||||
change.scope ? `**${change.scope.trim()}:** ` : ''
|
||||
}${breakingChangeExplanation}`
|
||||
: line
|
||||
);
|
||||
@ -267,19 +273,19 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({
|
||||
|
||||
if (changelogRenderOptions.authors) {
|
||||
const _authors = new Map<string, { email: Set<string>; github?: string }>();
|
||||
for (const commit of commits) {
|
||||
if (!commit.author) {
|
||||
for (const change of changes) {
|
||||
if (!change.author) {
|
||||
continue;
|
||||
}
|
||||
const name = formatName(commit.author.name);
|
||||
const name = formatName(change.author.name);
|
||||
if (!name || name.includes('[bot]')) {
|
||||
continue;
|
||||
}
|
||||
if (_authors.has(name)) {
|
||||
const entry = _authors.get(name);
|
||||
entry.email.add(commit.author.email);
|
||||
entry.email.add(change.author.email);
|
||||
} else {
|
||||
_authors.set(name, { email: new Set([commit.author.email]) });
|
||||
_authors.set(name, { email: new Set([change.author.email]) });
|
||||
}
|
||||
}
|
||||
|
||||
@ -385,20 +391,20 @@ function groupBy(items: any[], key: string) {
|
||||
return groups;
|
||||
}
|
||||
|
||||
function formatCommit(
|
||||
commit: GitCommit,
|
||||
function formatChange(
|
||||
change: ChangelogChange,
|
||||
changelogRenderOptions: DefaultChangelogRenderOptions,
|
||||
repoSlug?: RepoSlug
|
||||
): string {
|
||||
let commitLine =
|
||||
let changeLine =
|
||||
'- ' +
|
||||
(commit.isBreaking ? '⚠️ ' : '') +
|
||||
(commit.scope ? `**${commit.scope.trim()}:** ` : '') +
|
||||
commit.description;
|
||||
(change.isBreaking ? '⚠️ ' : '') +
|
||||
(change.scope ? `**${change.scope.trim()}:** ` : '') +
|
||||
change.description;
|
||||
if (repoSlug && changelogRenderOptions.commitReferences) {
|
||||
commitLine += formatReferences(commit.references, repoSlug);
|
||||
changeLine += formatReferences(change.githubReferences, repoSlug);
|
||||
}
|
||||
return commitLine;
|
||||
return changeLine;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -407,6 +413,10 @@ function formatCommit(
|
||||
* section of changelog, rather than repeating the commit title/description.
|
||||
*/
|
||||
function extractBreakingChangeExplanation(message: string): string | null {
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const breakingChangeIdentifier = 'BREAKING CHANGE:';
|
||||
const startIndex = message.indexOf(breakingChangeIdentifier);
|
||||
|
||||
|
||||
@ -184,6 +184,10 @@
|
||||
},
|
||||
"releaseTagPattern": {
|
||||
"type": "string"
|
||||
},
|
||||
"versionPlans": {
|
||||
"type": "boolean",
|
||||
"description": "Enables using version plans as a specifier source for versioning and to determine changes for changelog generation."
|
||||
}
|
||||
},
|
||||
"required": ["projects"]
|
||||
@ -234,6 +238,10 @@
|
||||
"version": {
|
||||
"$ref": "#/definitions/NxReleaseVersionConfiguration"
|
||||
},
|
||||
"versionPlans": {
|
||||
"type": "boolean",
|
||||
"description": "Enables using version plans as a specifier source for versioning and to determine changes for changelog generation."
|
||||
},
|
||||
"releaseTagPattern": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import * as chalk from 'chalk';
|
||||
import { prompt } from 'enquirer';
|
||||
import { removeSync } from 'fs-extra';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { valid } from 'semver';
|
||||
import { dirSync } from 'tmp';
|
||||
@ -9,11 +10,16 @@ import {
|
||||
readNxJson,
|
||||
} from '../../config/nx-json';
|
||||
import {
|
||||
FileData,
|
||||
ProjectFileMap,
|
||||
ProjectGraph,
|
||||
ProjectGraphProjectNode,
|
||||
} from '../../config/project-graph';
|
||||
import { FsTree, Tree } from '../../generators/tree';
|
||||
import { createProjectFileMapUsingProjectGraph } from '../../project-graph/file-map-utils';
|
||||
import {
|
||||
createFileMapUsingProjectGraph,
|
||||
createProjectFileMapUsingProjectGraph,
|
||||
} from '../../project-graph/file-map-utils';
|
||||
import { createProjectGraphAsync } from '../../project-graph/project-graph';
|
||||
import { interpolate } from '../../tasks-runner/utils';
|
||||
import { isCI } from '../../utils/is-ci';
|
||||
@ -31,8 +37,15 @@ import {
|
||||
ReleaseGroupWithName,
|
||||
filterReleaseGroups,
|
||||
} from './config/filter-release-groups';
|
||||
import {
|
||||
GroupVersionPlan,
|
||||
ProjectsVersionPlan,
|
||||
readRawVersionPlans,
|
||||
setVersionPlansOnGroups,
|
||||
} from './config/version-plans';
|
||||
import {
|
||||
GitCommit,
|
||||
Reference,
|
||||
getCommitHash,
|
||||
getFirstGitCommit,
|
||||
getGitDiff,
|
||||
@ -41,6 +54,7 @@ import {
|
||||
gitPush,
|
||||
gitTag,
|
||||
parseCommits,
|
||||
parseConventionalCommitsMessage,
|
||||
} from './utils/git';
|
||||
import { createOrUpdateGithubRelease, getGitHubRepoSlug } from './utils/github';
|
||||
import { launchEditor } from './utils/launch-editor';
|
||||
@ -71,6 +85,19 @@ export interface NxReleaseChangelogResult {
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChangelogChange {
|
||||
type: string;
|
||||
scope: string;
|
||||
description: string;
|
||||
affectedProjects: string[] | '*';
|
||||
body?: string;
|
||||
isBreaking?: boolean;
|
||||
githubReferences?: Reference[];
|
||||
author?: { name: string; email: string };
|
||||
shortHash?: string;
|
||||
revertedHashes?: string[];
|
||||
}
|
||||
|
||||
type PostGitTask = (latestCommit: string) => Promise<void>;
|
||||
|
||||
export const releaseChangelogCLIHandler = (args: ChangelogOptions) =>
|
||||
@ -136,6 +163,17 @@ export async function releaseChangelog(
|
||||
output.error(filterError);
|
||||
process.exit(1);
|
||||
}
|
||||
const rawVersionPlans = await readRawVersionPlans();
|
||||
setVersionPlansOnGroups(
|
||||
rawVersionPlans,
|
||||
releaseGroups,
|
||||
Object.keys(projectGraph.nodes)
|
||||
);
|
||||
|
||||
if (args.deleteVersionPlans === undefined) {
|
||||
// default to deleting version plans in this command instead of after versioning
|
||||
args.deleteVersionPlans = true;
|
||||
}
|
||||
|
||||
const changelogGenerationEnabled =
|
||||
!!nxReleaseConfig.changelog.workspaceChangelog ||
|
||||
@ -150,6 +188,8 @@ export async function releaseChangelog(
|
||||
return {};
|
||||
}
|
||||
|
||||
const tree = new FsTree(workspaceRoot, args.verbose);
|
||||
|
||||
const useAutomaticFromRef =
|
||||
nxReleaseConfig.changelog?.automaticFromRef || args.firstRelease;
|
||||
|
||||
@ -190,8 +230,6 @@ export async function releaseChangelog(
|
||||
);
|
||||
}
|
||||
|
||||
const tree = new FsTree(workspaceRoot, args.verbose);
|
||||
|
||||
const commitMessage: string | undefined =
|
||||
args.gitCommitMessage || nxReleaseConfig.changelog.git.commitMessage;
|
||||
|
||||
@ -215,43 +253,103 @@ export async function releaseChangelog(
|
||||
|
||||
const postGitTasks: PostGitTask[] = [];
|
||||
|
||||
let workspaceChangelogFromRef =
|
||||
args.from ||
|
||||
(await getLatestGitTagForPattern(nxReleaseConfig.releaseTagPattern))?.tag;
|
||||
if (!workspaceChangelogFromRef) {
|
||||
if (useAutomaticFromRef) {
|
||||
workspaceChangelogFromRef = await getFirstGitCommit();
|
||||
if (args.verbose) {
|
||||
console.log(
|
||||
`Determined workspace --from ref from the first commit in the workspace: ${workspaceChangelogFromRef}`
|
||||
let workspaceChangelogChanges: ChangelogChange[] = [];
|
||||
// TODO: remove this after the changelog renderer is refactored to remove coupling with git commits
|
||||
let workspaceChangelogCommits: GitCommit[] = [];
|
||||
|
||||
// If there are multiple release groups, we'll just skip the workspace changelog anyway.
|
||||
const versionPlansEnabledForWorkspaceChangelog =
|
||||
releaseGroups[0].versionPlans;
|
||||
if (versionPlansEnabledForWorkspaceChangelog) {
|
||||
if (releaseGroups.length === 1) {
|
||||
const releaseGroup = releaseGroups[0];
|
||||
if (releaseGroup.projectsRelationship === 'fixed') {
|
||||
const versionPlans = releaseGroup.versionPlans as GroupVersionPlan[];
|
||||
workspaceChangelogChanges = filterHiddenChanges(
|
||||
versionPlans
|
||||
.map((vp) => {
|
||||
const parsedMessage = parseConventionalCommitsMessage(vp.message);
|
||||
|
||||
// only properly formatted conventional commits messages will be included in the changelog
|
||||
if (!parsedMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <ChangelogChange>{
|
||||
type: parsedMessage.type,
|
||||
scope: parsedMessage.scope,
|
||||
description: parsedMessage.description,
|
||||
body: '',
|
||||
isBreaking: parsedMessage.breaking,
|
||||
githubReferences: [],
|
||||
};
|
||||
})
|
||||
.filter(Boolean),
|
||||
nxReleaseConfig.conventionalCommits
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unable to determine the previous git tag. If this is the first release of your workspace, use the --first-release option or set the "release.changelog.automaticFromRef" config property in nx.json to generate a changelog from the first commit. Otherwise, be sure to configure the "release.releaseTagPattern" property in nx.json to match the structure of your repository's git tags.`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let workspaceChangelogFromRef =
|
||||
args.from ||
|
||||
(await getLatestGitTagForPattern(nxReleaseConfig.releaseTagPattern))?.tag;
|
||||
if (!workspaceChangelogFromRef) {
|
||||
if (useAutomaticFromRef) {
|
||||
workspaceChangelogFromRef = await getFirstGitCommit();
|
||||
if (args.verbose) {
|
||||
console.log(
|
||||
`Determined workspace --from ref from the first commit in the workspace: ${workspaceChangelogFromRef}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unable to determine the previous git tag. If this is the first release of your workspace, use the --first-release option or set the "release.changelog.automaticFromRef" config property in nx.json to generate a changelog from the first commit. Otherwise, be sure to configure the "release.releaseTagPattern" property in nx.json to match the structure of your repository's git tags.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure that the fromRef is actually resolvable
|
||||
const workspaceChangelogFromSHA = await getCommitHash(
|
||||
workspaceChangelogFromRef
|
||||
);
|
||||
|
||||
workspaceChangelogCommits = await getCommits(
|
||||
workspaceChangelogFromSHA,
|
||||
toSHA
|
||||
);
|
||||
|
||||
workspaceChangelogChanges = filterHiddenChanges(
|
||||
workspaceChangelogCommits.map((c) => {
|
||||
return {
|
||||
type: c.type,
|
||||
scope: c.scope,
|
||||
description: c.description,
|
||||
body: c.body,
|
||||
isBreaking: c.isBreaking,
|
||||
githubReferences: c.references,
|
||||
author: c.author,
|
||||
shortHash: c.shortHash,
|
||||
revertedHashes: c.revertedHashes,
|
||||
affectedProjects: '*',
|
||||
};
|
||||
}),
|
||||
nxReleaseConfig.conventionalCommits
|
||||
);
|
||||
}
|
||||
|
||||
// Make sure that the fromRef is actually resolvable
|
||||
const workspaceChangelogFromSHA = await getCommitHash(
|
||||
workspaceChangelogFromRef
|
||||
);
|
||||
|
||||
const workspaceChangelogCommits = await getCommits(
|
||||
workspaceChangelogFromSHA,
|
||||
toSHA,
|
||||
nxReleaseConfig.conventionalCommits
|
||||
);
|
||||
|
||||
const workspaceChangelog = await generateChangelogForWorkspace(
|
||||
const workspaceChangelog = await generateChangelogForWorkspace({
|
||||
tree,
|
||||
args,
|
||||
projectGraph,
|
||||
nxReleaseConfig,
|
||||
workspaceChangelogVersion,
|
||||
workspaceChangelogCommits
|
||||
);
|
||||
changes: workspaceChangelogChanges,
|
||||
// TODO: remove this after the changelog renderer is refactored to remove coupling with git commits
|
||||
commits: filterHiddenCommits(
|
||||
workspaceChangelogCommits,
|
||||
nxReleaseConfig.conventionalCommits
|
||||
),
|
||||
});
|
||||
|
||||
if (
|
||||
workspaceChangelog &&
|
||||
@ -299,9 +397,6 @@ export async function releaseChangelog(
|
||||
continue;
|
||||
}
|
||||
for (const project of releaseGroup.projects) {
|
||||
if (projectToAdditionalDependencyBumps.has(project)) {
|
||||
continue;
|
||||
}
|
||||
const dependentProjects = (
|
||||
projectsVersionData[project]?.dependentProjects || []
|
||||
)
|
||||
@ -312,7 +407,21 @@ export async function releaseChangelog(
|
||||
};
|
||||
})
|
||||
.filter((b) => b.newVersion !== null);
|
||||
projectToAdditionalDependencyBumps.set(project, dependentProjects);
|
||||
|
||||
for (const dependent of dependentProjects) {
|
||||
const additionalDependencyBumpsForProject =
|
||||
projectToAdditionalDependencyBumps.has(dependent.dependencyName)
|
||||
? projectToAdditionalDependencyBumps.get(dependent.dependencyName)
|
||||
: [];
|
||||
additionalDependencyBumpsForProject.push({
|
||||
dependencyName: project,
|
||||
newVersion: projectsVersionData[project].newVersion,
|
||||
});
|
||||
projectToAdditionalDependencyBumps.set(
|
||||
dependent.dependencyName,
|
||||
additionalDependencyBumpsForProject
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -332,9 +441,9 @@ export async function releaseChangelog(
|
||||
(project) => {
|
||||
return [
|
||||
project,
|
||||
...(projectToAdditionalDependencyBumps.get(project) || []).map(
|
||||
(d) => d.dependencyName
|
||||
),
|
||||
...(projectsVersionData[project]?.dependentProjects.map(
|
||||
(dep) => dep.source
|
||||
) || []),
|
||||
];
|
||||
}
|
||||
)
|
||||
@ -344,63 +453,116 @@ export async function releaseChangelog(
|
||||
|
||||
if (releaseGroup.projectsRelationship === 'independent') {
|
||||
for (const project of projectNodes) {
|
||||
let fromRef =
|
||||
args.from ||
|
||||
(
|
||||
await getLatestGitTagForPattern(releaseGroup.releaseTagPattern, {
|
||||
projectName: project.name,
|
||||
})
|
||||
)?.tag;
|
||||
let changes: ChangelogChange[] | null = null;
|
||||
// TODO: remove this after the changelog renderer is refactored to remove coupling with git commits
|
||||
let commits: GitCommit[];
|
||||
|
||||
let commits: GitCommit[] | null = null;
|
||||
if (releaseGroup.versionPlans) {
|
||||
changes = filterHiddenChanges(
|
||||
(releaseGroup.versionPlans as ProjectsVersionPlan[])
|
||||
.map((vp) => {
|
||||
const parsedMessage = parseConventionalCommitsMessage(
|
||||
vp.message
|
||||
);
|
||||
|
||||
if (!fromRef && useAutomaticFromRef) {
|
||||
const firstCommit = await getFirstGitCommit();
|
||||
const allRawCommits = await getGitDiff(firstCommit, toSHA);
|
||||
const allParsedCommits = parseCommits(allRawCommits);
|
||||
const commitsForProject = allParsedCommits.filter((c) =>
|
||||
c.affectedFiles.find((f) => f.startsWith(project.data.root))
|
||||
// only properly formatted conventional commits messages will be included in the changelog
|
||||
if (!parsedMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: parsedMessage.type,
|
||||
scope: parsedMessage.scope,
|
||||
description: parsedMessage.description,
|
||||
body: '',
|
||||
isBreaking: parsedMessage.breaking,
|
||||
affectedProjects: Object.keys(vp.projectVersionBumps),
|
||||
githubReferences: [],
|
||||
};
|
||||
})
|
||||
.filter(Boolean),
|
||||
nxReleaseConfig.conventionalCommits
|
||||
);
|
||||
} else {
|
||||
let fromRef =
|
||||
args.from ||
|
||||
(
|
||||
await getLatestGitTagForPattern(releaseGroup.releaseTagPattern, {
|
||||
projectName: project.name,
|
||||
})
|
||||
)?.tag;
|
||||
|
||||
fromRef = commitsForProject[0]?.shortHash;
|
||||
if (args.verbose) {
|
||||
console.log(
|
||||
`Determined --from ref for ${project.name} from the first commit in which it exists: ${fromRef}`
|
||||
if (!fromRef && useAutomaticFromRef) {
|
||||
const firstCommit = await getFirstGitCommit();
|
||||
const allCommits = await getCommits(firstCommit, toSHA);
|
||||
const commitsForProject = allCommits.filter((c) =>
|
||||
c.affectedFiles.find((f) => f.startsWith(project.data.root))
|
||||
);
|
||||
|
||||
fromRef = commitsForProject[0]?.shortHash;
|
||||
if (args.verbose) {
|
||||
console.log(
|
||||
`Determined --from ref for ${project.name} from the first commit in which it exists: ${fromRef}`
|
||||
);
|
||||
}
|
||||
commits = commitsForProject;
|
||||
}
|
||||
|
||||
if (!fromRef && !commits) {
|
||||
throw new Error(
|
||||
`Unable to determine the previous git tag. If this is the first release of your workspace, use the --first-release option or set the "release.changelog.automaticFromRef" config property in nx.json to generate a changelog from the first commit. Otherwise, be sure to configure the "release.releaseTagPattern" property in nx.json to match the structure of your repository's git tags.`
|
||||
);
|
||||
}
|
||||
commits = commitsForProject.filter((c) =>
|
||||
applyConventionalCommitsConfigFilter(
|
||||
c,
|
||||
nxReleaseConfig.conventionalCommits
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!fromRef && !commits) {
|
||||
throw new Error(
|
||||
`Unable to determine the previous git tag. If this is the first release of your workspace, use the --first-release option or set the "release.changelog.automaticFromRef" config property in nx.json to generate a changelog from the first commit. Otherwise, be sure to configure the "release.releaseTagPattern" property in nx.json to match the structure of your repository's git tags.`
|
||||
);
|
||||
}
|
||||
if (!commits) {
|
||||
commits = await getCommits(fromRef, toSHA);
|
||||
}
|
||||
|
||||
if (!commits) {
|
||||
commits = await getCommits(
|
||||
fromRef,
|
||||
toSHA,
|
||||
const { fileMap } = await createFileMapUsingProjectGraph(
|
||||
projectGraph
|
||||
);
|
||||
const fileToProjectMap = createFileToProjectMap(
|
||||
fileMap.projectFileMap
|
||||
);
|
||||
|
||||
changes = filterHiddenChanges(
|
||||
commits.map((c) => ({
|
||||
type: c.type,
|
||||
scope: c.scope,
|
||||
description: c.description,
|
||||
body: c.body,
|
||||
isBreaking: c.isBreaking,
|
||||
githubReferences: c.references,
|
||||
author: c.author,
|
||||
shortHash: c.shortHash,
|
||||
revertedHashes: c.revertedHashes,
|
||||
affectedProjects: commitChangesNonProjectFiles(
|
||||
c,
|
||||
fileMap.nonProjectFiles
|
||||
)
|
||||
? '*'
|
||||
: getProjectsAffectedByCommit(c, fileToProjectMap),
|
||||
})),
|
||||
nxReleaseConfig.conventionalCommits
|
||||
);
|
||||
}
|
||||
|
||||
const projectChangelogs = await generateChangelogForProjects(
|
||||
const projectChangelogs = await generateChangelogForProjects({
|
||||
tree,
|
||||
args,
|
||||
projectGraph,
|
||||
commits,
|
||||
changes,
|
||||
projectsVersionData,
|
||||
releaseGroup,
|
||||
[project],
|
||||
projects: [project],
|
||||
nxReleaseConfig,
|
||||
projectToAdditionalDependencyBumps
|
||||
);
|
||||
projectToAdditionalDependencyBumps,
|
||||
// TODO: remove this after the changelog renderer is refactored to remove coupling with git commits
|
||||
commits: filterHiddenCommits(
|
||||
commits,
|
||||
nxReleaseConfig.conventionalCommits
|
||||
),
|
||||
});
|
||||
|
||||
let hasPushed = false;
|
||||
for (const [projectName, projectChangelog] of Object.entries(
|
||||
@ -440,44 +602,98 @@ export async function releaseChangelog(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let fromRef =
|
||||
args.from ||
|
||||
(await getLatestGitTagForPattern(releaseGroup.releaseTagPattern))?.tag;
|
||||
if (!fromRef) {
|
||||
if (useAutomaticFromRef) {
|
||||
fromRef = await getFirstGitCommit();
|
||||
if (args.verbose) {
|
||||
console.log(
|
||||
`Determined release group --from ref from the first commit in the workspace: ${fromRef}`
|
||||
let changes: ChangelogChange[] = [];
|
||||
// TODO: remove this after the changelog renderer is refactored to remove coupling with git commits
|
||||
let commits: GitCommit[] = [];
|
||||
if (releaseGroup.versionPlans) {
|
||||
changes = filterHiddenChanges(
|
||||
(releaseGroup.versionPlans as GroupVersionPlan[])
|
||||
.map((vp) => {
|
||||
const parsedMessage = parseConventionalCommitsMessage(vp.message);
|
||||
|
||||
// only properly formatted conventional commits messages will be included in the changelog
|
||||
if (!parsedMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <ChangelogChange>{
|
||||
type: parsedMessage.type,
|
||||
scope: parsedMessage.scope,
|
||||
description: parsedMessage.description,
|
||||
body: '',
|
||||
isBreaking: parsedMessage.breaking,
|
||||
githubReferences: [],
|
||||
affectedProjects: '*',
|
||||
};
|
||||
})
|
||||
.filter(Boolean),
|
||||
nxReleaseConfig.conventionalCommits
|
||||
);
|
||||
} else {
|
||||
let fromRef =
|
||||
args.from ||
|
||||
(await getLatestGitTagForPattern(releaseGroup.releaseTagPattern))
|
||||
?.tag;
|
||||
if (!fromRef) {
|
||||
if (useAutomaticFromRef) {
|
||||
fromRef = await getFirstGitCommit();
|
||||
if (args.verbose) {
|
||||
console.log(
|
||||
`Determined release group --from ref from the first commit in the workspace: ${fromRef}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unable to determine the previous git tag. If this is the first release of your release group, use the --first-release option or set the "release.changelog.automaticFromRef" config property in nx.json to generate a changelog from the first commit. Otherwise, be sure to configure the "release.releaseTagPattern" property in nx.json to match the structure of your repository's git tags.`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unable to determine the previous git tag. If this is the first release of your release group, use the --first-release option or set the "release.changelog.automaticFromRef" config property in nx.json to generate a changelog from the first commit. Otherwise, be sure to configure the "release.releaseTagPattern" property in nx.json to match the structure of your repository's git tags.`
|
||||
);
|
||||
}
|
||||
|
||||
// Make sure that the fromRef is actually resolvable
|
||||
const fromSHA = await getCommitHash(fromRef);
|
||||
|
||||
const { fileMap } = await createFileMapUsingProjectGraph(projectGraph);
|
||||
const fileToProjectMap = createFileToProjectMap(fileMap.projectFileMap);
|
||||
|
||||
commits = await getCommits(fromSHA, toSHA);
|
||||
changes = filterHiddenChanges(
|
||||
commits.map((c) => ({
|
||||
type: c.type,
|
||||
scope: c.scope,
|
||||
description: c.description,
|
||||
body: c.body,
|
||||
isBreaking: c.isBreaking,
|
||||
githubReferences: c.references,
|
||||
author: c.author,
|
||||
shortHash: c.shortHash,
|
||||
revertedHashes: c.revertedHashes,
|
||||
affectedProjects: commitChangesNonProjectFiles(
|
||||
c,
|
||||
fileMap.nonProjectFiles
|
||||
)
|
||||
? '*'
|
||||
: getProjectsAffectedByCommit(c, fileToProjectMap),
|
||||
})),
|
||||
nxReleaseConfig.conventionalCommits
|
||||
);
|
||||
}
|
||||
|
||||
// Make sure that the fromRef is actually resolvable
|
||||
const fromSHA = await getCommitHash(fromRef);
|
||||
|
||||
const commits = await getCommits(
|
||||
fromSHA,
|
||||
toSHA,
|
||||
nxReleaseConfig.conventionalCommits
|
||||
);
|
||||
|
||||
const projectChangelogs = await generateChangelogForProjects(
|
||||
const projectChangelogs = await generateChangelogForProjects({
|
||||
tree,
|
||||
args,
|
||||
projectGraph,
|
||||
commits,
|
||||
changes,
|
||||
projectsVersionData,
|
||||
releaseGroup,
|
||||
projectNodes,
|
||||
projects: projectNodes,
|
||||
nxReleaseConfig,
|
||||
projectToAdditionalDependencyBumps
|
||||
);
|
||||
projectToAdditionalDependencyBumps,
|
||||
// TODO: remove this after the changelog renderer is refactored to remove coupling with git commits
|
||||
commits: filterHiddenCommits(
|
||||
commits,
|
||||
nxReleaseConfig.conventionalCommits
|
||||
),
|
||||
});
|
||||
|
||||
let hasPushed = false;
|
||||
for (const [projectName, projectChangelog] of Object.entries(
|
||||
@ -522,7 +738,8 @@ export async function releaseChangelog(
|
||||
toSHA,
|
||||
postGitTasks,
|
||||
commitMessageValues,
|
||||
gitTagValues
|
||||
gitTagValues,
|
||||
releaseGroups
|
||||
);
|
||||
|
||||
return {
|
||||
@ -599,7 +816,8 @@ async function applyChangesAndExit(
|
||||
toSHA: string,
|
||||
postGitTasks: PostGitTask[],
|
||||
commitMessageValues: string[],
|
||||
gitTagValues: string[]
|
||||
gitTagValues: string[],
|
||||
releaseGroups: ReleaseGroupWithName[]
|
||||
) {
|
||||
let latestCommit = toSHA;
|
||||
|
||||
@ -646,15 +864,33 @@ async function applyChangesAndExit(
|
||||
return;
|
||||
}
|
||||
|
||||
const changedFiles: string[] = changes.map((f) => f.path);
|
||||
|
||||
let deletedFiles: string[] = [];
|
||||
if (args.deleteVersionPlans && !args.dryRun) {
|
||||
const planFiles = new Set<string>();
|
||||
releaseGroups.forEach((group) => {
|
||||
if (group.versionPlans) {
|
||||
group.versionPlans.forEach((plan) => {
|
||||
removeSync(plan.absolutePath);
|
||||
planFiles.add(plan.relativePath);
|
||||
});
|
||||
}
|
||||
});
|
||||
deletedFiles = Array.from(planFiles);
|
||||
}
|
||||
|
||||
// Generate a new commit for the changes, if configured to do so
|
||||
if (args.gitCommit ?? nxReleaseConfig.changelog.git.commit) {
|
||||
await commitChanges(
|
||||
changes.map((f) => f.path),
|
||||
!!args.dryRun,
|
||||
!!args.verbose,
|
||||
commitMessageValues,
|
||||
args.gitCommitArgs || nxReleaseConfig.changelog.git.commitArgs
|
||||
);
|
||||
await commitChanges({
|
||||
changedFiles,
|
||||
deletedFiles,
|
||||
isDryRun: !!args.dryRun,
|
||||
isVerbose: !!args.verbose,
|
||||
gitCommitMessages: commitMessageValues,
|
||||
gitCommitArgs:
|
||||
args.gitCommitArgs || nxReleaseConfig.changelog.git.commitArgs,
|
||||
});
|
||||
// Resolve the commit we just made
|
||||
latestCommit = await getCommitHash('HEAD');
|
||||
} else if (
|
||||
@ -663,7 +899,8 @@ async function applyChangesAndExit(
|
||||
) {
|
||||
output.logSingleLine(`Staging changed files with git`);
|
||||
await gitAdd({
|
||||
changedFiles: changes.map((f) => f.path),
|
||||
changedFiles,
|
||||
deletedFiles,
|
||||
dryRun: args.dryRun,
|
||||
verbose: args.verbose,
|
||||
});
|
||||
@ -692,14 +929,23 @@ async function applyChangesAndExit(
|
||||
return;
|
||||
}
|
||||
|
||||
async function generateChangelogForWorkspace(
|
||||
tree: Tree,
|
||||
args: ChangelogOptions,
|
||||
projectGraph: ProjectGraph,
|
||||
nxReleaseConfig: NxReleaseConfig,
|
||||
workspaceChangelogVersion: (string | null) | undefined,
|
||||
commits: GitCommit[]
|
||||
): Promise<NxReleaseChangelogResult['workspaceChangelog']> {
|
||||
async function generateChangelogForWorkspace({
|
||||
tree,
|
||||
args,
|
||||
projectGraph,
|
||||
nxReleaseConfig,
|
||||
workspaceChangelogVersion,
|
||||
changes,
|
||||
commits,
|
||||
}: {
|
||||
tree: Tree;
|
||||
args: ChangelogOptions;
|
||||
projectGraph: ProjectGraph;
|
||||
nxReleaseConfig: NxReleaseConfig;
|
||||
workspaceChangelogVersion: (string | null) | undefined;
|
||||
changes: ChangelogChange[];
|
||||
commits: GitCommit[];
|
||||
}): Promise<NxReleaseChangelogResult['workspaceChangelog']> {
|
||||
const config = nxReleaseConfig.changelog.workspaceChangelog;
|
||||
// The entire feature is disabled at the workspace level, exit early
|
||||
if (config === false) {
|
||||
@ -778,6 +1024,7 @@ async function generateChangelogForWorkspace(
|
||||
|
||||
let contents = await changelogRenderer({
|
||||
projectGraph,
|
||||
changes,
|
||||
commits,
|
||||
releaseVersion: releaseVersion.rawVersion,
|
||||
project: null,
|
||||
@ -841,17 +1088,29 @@ async function generateChangelogForWorkspace(
|
||||
};
|
||||
}
|
||||
|
||||
async function generateChangelogForProjects(
|
||||
tree: Tree,
|
||||
args: ChangelogOptions,
|
||||
projectGraph: ProjectGraph,
|
||||
commits: GitCommit[],
|
||||
projectsVersionData: VersionData,
|
||||
releaseGroup: ReleaseGroupWithName,
|
||||
projects: ProjectGraphProjectNode[],
|
||||
nxReleaseConfig: NxReleaseConfig,
|
||||
projectToAdditionalDependencyBumps: Map<string, DependencyBump[]>
|
||||
): Promise<NxReleaseChangelogResult['projectChangelogs']> {
|
||||
async function generateChangelogForProjects({
|
||||
tree,
|
||||
args,
|
||||
projectGraph,
|
||||
changes,
|
||||
commits,
|
||||
projectsVersionData,
|
||||
releaseGroup,
|
||||
projects,
|
||||
nxReleaseConfig,
|
||||
projectToAdditionalDependencyBumps,
|
||||
}: {
|
||||
tree: Tree;
|
||||
args: ChangelogOptions;
|
||||
projectGraph: ProjectGraph;
|
||||
changes: ChangelogChange[];
|
||||
commits: GitCommit[];
|
||||
projectsVersionData: VersionData;
|
||||
releaseGroup: ReleaseGroupWithName;
|
||||
projects: ProjectGraphProjectNode[];
|
||||
nxReleaseConfig: NxReleaseConfig;
|
||||
projectToAdditionalDependencyBumps: Map<string, DependencyBump[]>;
|
||||
}): Promise<NxReleaseChangelogResult['projectChangelogs']> {
|
||||
const config = releaseGroup.changelog;
|
||||
// The entire feature is disabled at the release group level, exit early
|
||||
if (config === false) {
|
||||
@ -908,6 +1167,7 @@ async function generateChangelogForProjects(
|
||||
|
||||
let contents = await changelogRenderer({
|
||||
projectGraph,
|
||||
changes,
|
||||
commits,
|
||||
releaseVersion: releaseVersion.rawVersion,
|
||||
project: project.name,
|
||||
@ -1006,32 +1266,47 @@ function checkChangelogFilesEnabled(nxReleaseConfig: NxReleaseConfig): boolean {
|
||||
|
||||
async function getCommits(
|
||||
fromSHA: string,
|
||||
toSHA: string,
|
||||
conventionalCommitsConfig: NxReleaseConfig['conventionalCommits']
|
||||
toSHA: string
|
||||
): Promise<GitCommit[]> {
|
||||
const rawCommits = await getGitDiff(fromSHA, toSHA);
|
||||
// Parse as conventional commits
|
||||
const parsedCommits = parseCommits(rawCommits);
|
||||
if (conventionalCommitsConfig === null) {
|
||||
return parsedCommits;
|
||||
}
|
||||
// Apply filtering based on the conventional commits configuration
|
||||
return parsedCommits.filter((c) =>
|
||||
applyConventionalCommitsConfigFilter(c, conventionalCommitsConfig)
|
||||
);
|
||||
return parseCommits(rawCommits);
|
||||
}
|
||||
|
||||
function applyConventionalCommitsConfigFilter(
|
||||
commit: GitCommit,
|
||||
function filterHiddenChanges(
|
||||
changes: ChangelogChange[],
|
||||
conventionalCommitsConfig: NxReleaseConfig['conventionalCommits']
|
||||
): boolean {
|
||||
const type = commit.type;
|
||||
const typeConfig = conventionalCommitsConfig.types[type];
|
||||
if (!typeConfig) {
|
||||
// don't include commits with unknown types
|
||||
return false;
|
||||
): ChangelogChange[] {
|
||||
return changes.filter((change) => {
|
||||
const type = change.type;
|
||||
|
||||
const typeConfig = conventionalCommitsConfig.types[type];
|
||||
if (!typeConfig) {
|
||||
// don't include changes with unknown types
|
||||
return false;
|
||||
}
|
||||
return !typeConfig.changelog.hidden;
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: remove this after the changelog renderer is refactored to remove coupling with git commits
|
||||
function filterHiddenCommits(
|
||||
commits: GitCommit[],
|
||||
conventionalCommitsConfig: NxReleaseConfig['conventionalCommits']
|
||||
): GitCommit[] {
|
||||
if (!commits) {
|
||||
return [];
|
||||
}
|
||||
return !typeConfig.changelog.hidden;
|
||||
return commits.filter((commit) => {
|
||||
const type = commit.type;
|
||||
|
||||
const typeConfig = conventionalCommitsConfig.types[type];
|
||||
if (!typeConfig) {
|
||||
// don't include commits with unknown types
|
||||
return false;
|
||||
}
|
||||
return !typeConfig.changelog.hidden;
|
||||
});
|
||||
}
|
||||
|
||||
export function shouldCreateGitHubRelease(
|
||||
@ -1060,3 +1335,35 @@ async function promptForGitHubRelease(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getProjectsAffectedByCommit(
|
||||
commit: GitCommit,
|
||||
fileToProjectMap: Record<string, string>
|
||||
): string[] {
|
||||
const affectedProjects = new Set<string>();
|
||||
for (const file of commit.affectedFiles) {
|
||||
affectedProjects.add(fileToProjectMap[file]);
|
||||
}
|
||||
return Array.from(affectedProjects);
|
||||
}
|
||||
|
||||
function commitChangesNonProjectFiles(
|
||||
commit: GitCommit,
|
||||
nonProjectFiles: FileData[]
|
||||
): boolean {
|
||||
return nonProjectFiles.some((fileData) =>
|
||||
commit.affectedFiles.includes(fileData.file)
|
||||
);
|
||||
}
|
||||
|
||||
function createFileToProjectMap(
|
||||
projectFileMap: ProjectFileMap
|
||||
): Record<string, string> {
|
||||
const fileToProjectMap = {};
|
||||
for (const [projectName, projectFiles] of Object.entries(projectFileMap)) {
|
||||
for (const file of projectFiles) {
|
||||
fileToProjectMap[file.file] = projectName;
|
||||
}
|
||||
}
|
||||
return fileToProjectMap;
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Argv, CommandModule, showHelp } from 'yargs';
|
||||
import { readNxJson } from '../../config/nx-json';
|
||||
import { logger } from '../../utils/logger';
|
||||
import {
|
||||
OutputStyle,
|
||||
@ -9,14 +10,12 @@ import {
|
||||
withRunManyOptions,
|
||||
} from '../yargs-utils/shared-options';
|
||||
import { VersionData } from './utils/shared';
|
||||
import { readNxJson } from '../../config/nx-json';
|
||||
|
||||
export interface NxReleaseArgs {
|
||||
groups?: string[];
|
||||
projects?: string[];
|
||||
dryRun?: boolean;
|
||||
verbose?: boolean;
|
||||
firstRelease?: boolean;
|
||||
}
|
||||
|
||||
interface GitCommitAndTagOptions {
|
||||
@ -30,7 +29,9 @@ interface GitCommitAndTagOptions {
|
||||
}
|
||||
|
||||
export type VersionOptions = NxReleaseArgs &
|
||||
GitCommitAndTagOptions & {
|
||||
GitCommitAndTagOptions &
|
||||
VersionPlanArgs &
|
||||
FirstReleaseArgs & {
|
||||
specifier?: string;
|
||||
preid?: string;
|
||||
stageChanges?: boolean;
|
||||
@ -38,7 +39,9 @@ export type VersionOptions = NxReleaseArgs &
|
||||
};
|
||||
|
||||
export type ChangelogOptions = NxReleaseArgs &
|
||||
GitCommitAndTagOptions & {
|
||||
GitCommitAndTagOptions &
|
||||
VersionPlanArgs &
|
||||
FirstReleaseArgs & {
|
||||
// version and/or versionData must be set
|
||||
version?: string | null;
|
||||
versionData?: VersionData;
|
||||
@ -50,15 +53,29 @@ export type ChangelogOptions = NxReleaseArgs &
|
||||
};
|
||||
|
||||
export type PublishOptions = NxReleaseArgs &
|
||||
Partial<RunManyOptions> & { outputStyle?: OutputStyle } & {
|
||||
Partial<RunManyOptions> & { outputStyle?: OutputStyle } & FirstReleaseArgs & {
|
||||
registry?: string;
|
||||
tag?: string;
|
||||
otp?: number;
|
||||
};
|
||||
|
||||
export type ReleaseOptions = NxReleaseArgs & {
|
||||
yes?: boolean;
|
||||
skipPublish?: boolean;
|
||||
export type PlanOptions = NxReleaseArgs & {
|
||||
bump?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type ReleaseOptions = NxReleaseArgs &
|
||||
FirstReleaseArgs & {
|
||||
yes?: boolean;
|
||||
skipPublish?: boolean;
|
||||
};
|
||||
|
||||
export type VersionPlanArgs = {
|
||||
deleteVersionPlans?: boolean;
|
||||
};
|
||||
|
||||
export type FirstReleaseArgs = {
|
||||
firstRelease?: boolean;
|
||||
};
|
||||
|
||||
export const yargsReleaseCommand: CommandModule<
|
||||
@ -74,6 +91,7 @@ export const yargsReleaseCommand: CommandModule<
|
||||
.command(versionCommand)
|
||||
.command(changelogCommand)
|
||||
.command(publishCommand)
|
||||
.command(planCommand)
|
||||
.demandCommand()
|
||||
// Error on typos/mistyped CLI args, there is no reason to support arbitrary unknown args for these commands
|
||||
.strictOptions()
|
||||
@ -103,11 +121,6 @@ export const yargsReleaseCommand: CommandModule<
|
||||
describe:
|
||||
'Prints additional information about the commands (e.g., stack traces)',
|
||||
})
|
||||
.option('first-release', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Indicates that this is the first release for the selected release group. If the current version cannot be determined as usual, the version on disk will be used as a fallback. This is useful when using git or the registry to determine the current version of packages, since those sources are only available after the first release. Also indicates that changelog generation should not assume a previous git tag exists and that publishing should not check for the existence of the package before running.',
|
||||
})
|
||||
.check((argv) => {
|
||||
if (argv.groups && argv.projects) {
|
||||
throw new Error(
|
||||
@ -137,7 +150,7 @@ const releaseCommand: CommandModule<NxReleaseArgs, ReleaseOptions> = {
|
||||
describe:
|
||||
'Create a version and release for the workspace, generate a changelog, and optionally publish the packages',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
withFirstReleaseOptions(yargs)
|
||||
.positional('specifier', {
|
||||
type: 'string',
|
||||
describe:
|
||||
@ -182,24 +195,26 @@ const versionCommand: CommandModule<NxReleaseArgs, VersionOptions> = {
|
||||
describe:
|
||||
'Create a version and release for one or more applications and libraries',
|
||||
builder: (yargs) =>
|
||||
withGitCommitAndGitTagOptions(
|
||||
yargs
|
||||
.positional('specifier', {
|
||||
type: 'string',
|
||||
describe:
|
||||
'Exact version or semver keyword to apply to the selected release group.',
|
||||
})
|
||||
.option('preid', {
|
||||
type: 'string',
|
||||
describe:
|
||||
'The optional prerelease identifier to apply to the version, in the case that the specifier argument has been set to `prerelease`.',
|
||||
default: '',
|
||||
})
|
||||
.option('stage-changes', {
|
||||
type: 'boolean',
|
||||
describe:
|
||||
'Whether or not to stage the changes made by this command. Useful when combining this command with changelog generation.',
|
||||
})
|
||||
withFirstReleaseOptions(
|
||||
withGitCommitAndGitTagOptions(
|
||||
yargs
|
||||
.positional('specifier', {
|
||||
type: 'string',
|
||||
describe:
|
||||
'Exact version or semver keyword to apply to the selected release group.',
|
||||
})
|
||||
.option('preid', {
|
||||
type: 'string',
|
||||
describe:
|
||||
'The optional prerelease identifier to apply to the version, in the case that the specifier argument has been set to `prerelease`.',
|
||||
default: '',
|
||||
})
|
||||
.option('stage-changes', {
|
||||
type: 'boolean',
|
||||
describe:
|
||||
'Whether or not to stage the changes made by this command. Useful when combining this command with changelog generation.',
|
||||
})
|
||||
)
|
||||
),
|
||||
handler: async (args) => {
|
||||
const release = await import('./version');
|
||||
@ -221,46 +236,48 @@ const changelogCommand: CommandModule<NxReleaseArgs, ChangelogOptions> = {
|
||||
describe:
|
||||
'Generate a changelog for one or more projects, and optionally push to Github',
|
||||
builder: (yargs) =>
|
||||
withGitCommitAndGitTagOptions(
|
||||
yargs
|
||||
// Disable default meaning of yargs version for this command
|
||||
.version(false)
|
||||
.positional('version', {
|
||||
type: 'string',
|
||||
description:
|
||||
'The version to create a Github release and changelog for',
|
||||
})
|
||||
.option('from', {
|
||||
type: 'string',
|
||||
description:
|
||||
'The git reference to use as the start of the changelog. If not set it will attempt to resolve the latest tag and use that',
|
||||
})
|
||||
.option('to', {
|
||||
type: 'string',
|
||||
description: 'The git reference to use as the end of the changelog',
|
||||
default: 'HEAD',
|
||||
})
|
||||
.option('interactive', {
|
||||
alias: 'i',
|
||||
type: 'string',
|
||||
description:
|
||||
'Interactively modify changelog markdown contents in your code editor before applying the changes. You can set it to be interactive for all changelogs, or only the workspace level, or only the project level',
|
||||
choices: ['all', 'workspace', 'projects'],
|
||||
})
|
||||
.option('git-remote', {
|
||||
type: 'string',
|
||||
description:
|
||||
'Alternate git remote in the form {user}/{repo} on which to create the Github release (useful for testing)',
|
||||
default: 'origin',
|
||||
})
|
||||
.check((argv) => {
|
||||
if (!argv.version) {
|
||||
throw new Error(
|
||||
'An explicit target version must be specified when using the changelog command directly'
|
||||
);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
withFirstReleaseOptions(
|
||||
withGitCommitAndGitTagOptions(
|
||||
yargs
|
||||
// Disable default meaning of yargs version for this command
|
||||
.version(false)
|
||||
.positional('version', {
|
||||
type: 'string',
|
||||
description:
|
||||
'The version to create a Github release and changelog for',
|
||||
})
|
||||
.option('from', {
|
||||
type: 'string',
|
||||
description:
|
||||
'The git reference to use as the start of the changelog. If not set it will attempt to resolve the latest tag and use that',
|
||||
})
|
||||
.option('to', {
|
||||
type: 'string',
|
||||
description: 'The git reference to use as the end of the changelog',
|
||||
default: 'HEAD',
|
||||
})
|
||||
.option('interactive', {
|
||||
alias: 'i',
|
||||
type: 'string',
|
||||
description:
|
||||
'Interactively modify changelog markdown contents in your code editor before applying the changes. You can set it to be interactive for all changelogs, or only the workspace level, or only the project level',
|
||||
choices: ['all', 'workspace', 'projects'],
|
||||
})
|
||||
.option('git-remote', {
|
||||
type: 'string',
|
||||
description:
|
||||
'Alternate git remote in the form {user}/{repo} on which to create the Github release (useful for testing)',
|
||||
default: 'origin',
|
||||
})
|
||||
.check((argv) => {
|
||||
if (!argv.version) {
|
||||
throw new Error(
|
||||
'An explicit target version must be specified when using the changelog command directly'
|
||||
);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
)
|
||||
),
|
||||
handler: async (args) => {
|
||||
const release = await import('./changelog');
|
||||
@ -281,20 +298,22 @@ const publishCommand: CommandModule<NxReleaseArgs, PublishOptions> = {
|
||||
aliases: ['p'],
|
||||
describe: 'Publish a versioned project to a registry',
|
||||
builder: (yargs) =>
|
||||
withRunManyOptions(withOutputStyleOption(yargs))
|
||||
.option('registry', {
|
||||
type: 'string',
|
||||
description: 'The registry to publish to',
|
||||
})
|
||||
.option('tag', {
|
||||
type: 'string',
|
||||
description: 'The distribution tag to apply to the published package',
|
||||
})
|
||||
.option('otp', {
|
||||
type: 'number',
|
||||
description:
|
||||
'A one-time password for publishing to a registry that requires 2FA',
|
||||
}),
|
||||
withFirstReleaseOptions(
|
||||
withRunManyOptions(withOutputStyleOption(yargs))
|
||||
.option('registry', {
|
||||
type: 'string',
|
||||
description: 'The registry to publish to',
|
||||
})
|
||||
.option('tag', {
|
||||
type: 'string',
|
||||
description: 'The distribution tag to apply to the published package',
|
||||
})
|
||||
.option('otp', {
|
||||
type: 'number',
|
||||
description:
|
||||
'A one-time password for publishing to a registry that requires 2FA',
|
||||
})
|
||||
),
|
||||
handler: async (args) => {
|
||||
const status = await (
|
||||
await import('./publish')
|
||||
@ -307,6 +326,46 @@ const publishCommand: CommandModule<NxReleaseArgs, PublishOptions> = {
|
||||
},
|
||||
};
|
||||
|
||||
const planCommand: CommandModule<NxReleaseArgs, PlanOptions> = {
|
||||
command: 'plan [bump]',
|
||||
aliases: ['pl'],
|
||||
// Create a plan to pick a new version and generate a changelog entry.
|
||||
// Hidden for now until the feature is more stable
|
||||
describe: false,
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('bump', {
|
||||
type: 'string',
|
||||
describe: 'Semver keyword to use for the selected release group.',
|
||||
choices: [
|
||||
'major',
|
||||
'premajor',
|
||||
'minor',
|
||||
'preminor',
|
||||
'patch',
|
||||
'prepatch',
|
||||
'prerelease',
|
||||
],
|
||||
})
|
||||
.option('message', {
|
||||
type: 'string',
|
||||
alias: 'm',
|
||||
describe: 'Custom message to use for the changelog entry',
|
||||
}),
|
||||
handler: async (args) => {
|
||||
const release = await import('./plan');
|
||||
const result = await release.releasePlanCLIHandler(args);
|
||||
if (args.dryRun) {
|
||||
logger.warn(`\nNOTE: The "dryRun" flag means no changes were made.`);
|
||||
}
|
||||
|
||||
if (typeof result === 'number') {
|
||||
process.exit(result);
|
||||
}
|
||||
process.exit(0);
|
||||
},
|
||||
};
|
||||
|
||||
function coerceParallelOption(args: any) {
|
||||
if (args['parallel'] === 'false' || args['parallel'] === false) {
|
||||
return {
|
||||
@ -371,3 +430,13 @@ function withGitCommitAndGitTagOptions<T>(
|
||||
type: 'boolean',
|
||||
});
|
||||
}
|
||||
|
||||
function withFirstReleaseOptions<T>(
|
||||
yargs: Argv<T>
|
||||
): Argv<T & FirstReleaseArgs> {
|
||||
return yargs.option('first-release', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Indicates that this is the first release for the selected release group. If the current version cannot be determined as usual, the version on disk will be used as a fallback. This is useful when using git or the registry to determine the current version of packages, since those sources are only available after the first release. Also indicates that changelog generation should not assume a previous git tag exists and that publishing should not check for the existence of the package before running.',
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -178,12 +178,18 @@ export async function createNxReleaseConfig(
|
||||
const workspaceProjectsRelationship =
|
||||
userConfig.projectsRelationship || 'fixed';
|
||||
|
||||
const defaultGeneratorOptions = userConfig.version?.conventionalCommits
|
||||
? {
|
||||
currentVersionResolver: 'git-tag',
|
||||
specifierSource: 'conventional-commits',
|
||||
}
|
||||
: {};
|
||||
const defaultGeneratorOptions: {
|
||||
currentVersionResolver?: string;
|
||||
specifierSource?: string;
|
||||
} = {};
|
||||
|
||||
if (userConfig.version?.conventionalCommits) {
|
||||
defaultGeneratorOptions.currentVersionResolver = 'git-tag';
|
||||
defaultGeneratorOptions.specifierSource = 'conventional-commits';
|
||||
}
|
||||
if (userConfig.versionPlans) {
|
||||
defaultGeneratorOptions.specifierSource = 'version-plans';
|
||||
}
|
||||
|
||||
const userGroups = Object.values(userConfig.groups ?? {});
|
||||
const disableWorkspaceChangelog =
|
||||
@ -248,6 +254,7 @@ export async function createNxReleaseConfig(
|
||||
? defaultIndependentReleaseTagPattern
|
||||
: defaultFixedReleaseTagPattern),
|
||||
conventionalCommits: DEFAULT_CONVENTIONAL_COMMITS_CONFIG,
|
||||
versionPlans: false,
|
||||
};
|
||||
|
||||
const groupProjectsRelationship =
|
||||
@ -277,6 +284,7 @@ export async function createNxReleaseConfig(
|
||||
groupProjectsRelationship === 'independent'
|
||||
? defaultIndependentReleaseTagPattern
|
||||
: WORKSPACE_DEFAULTS.releaseTagPattern,
|
||||
versionPlans: false,
|
||||
};
|
||||
|
||||
/**
|
||||
@ -324,6 +332,9 @@ export async function createNxReleaseConfig(
|
||||
>
|
||||
);
|
||||
|
||||
const rootVersionPlansConfig: NxReleaseConfig['versionPlans'] =
|
||||
userConfig.versionPlans ?? WORKSPACE_DEFAULTS.versionPlans;
|
||||
|
||||
const rootConventionalCommitsConfig: NxReleaseConfig['conventionalCommits'] =
|
||||
deepMergeDefaults(
|
||||
[WORKSPACE_DEFAULTS.conventionalCommits],
|
||||
@ -353,6 +364,17 @@ export async function createNxReleaseConfig(
|
||||
delete rootVersionWithoutGlobalOptions.generatorOptions.specifierSource;
|
||||
}
|
||||
|
||||
// Apply versionPlans shorthand to the final group defaults if explicitly configured in the original user config
|
||||
if (userConfig.versionPlans) {
|
||||
rootVersionWithoutGlobalOptions.generatorOptions = {
|
||||
...rootVersionWithoutGlobalOptions.generatorOptions,
|
||||
specifierSource: 'version-plans',
|
||||
};
|
||||
}
|
||||
if (userConfig.versionPlans === false) {
|
||||
delete rootVersionWithoutGlobalOptions.generatorOptions.specifierSource;
|
||||
}
|
||||
|
||||
const groups: NxReleaseConfig['groups'] =
|
||||
userConfig.groups && Object.keys(userConfig.groups).length
|
||||
? ensureProjectsConfigIsArray(userConfig.groups)
|
||||
@ -361,7 +383,7 @@ export async function createNxReleaseConfig(
|
||||
* as being in one release group together in which the projects are released in lock step.
|
||||
*/
|
||||
{
|
||||
[IMPLICIT_DEFAULT_RELEASE_GROUP]: {
|
||||
[IMPLICIT_DEFAULT_RELEASE_GROUP]: <NxReleaseConfig['groups'][string]>{
|
||||
projectsRelationship: GROUP_DEFAULTS.projectsRelationship,
|
||||
projects: userConfig.projects
|
||||
? // user-defined top level "projects" config takes priority if set
|
||||
@ -385,6 +407,7 @@ export async function createNxReleaseConfig(
|
||||
userConfig.releaseTagPattern || GROUP_DEFAULTS.releaseTagPattern,
|
||||
// Directly inherit the root level config for projectChangelogs, if set
|
||||
changelog: rootChangelogConfig.projectChangelogs || false,
|
||||
versionPlans: rootVersionPlansConfig || GROUP_DEFAULTS.versionPlans,
|
||||
},
|
||||
};
|
||||
|
||||
@ -482,6 +505,7 @@ export async function createNxReleaseConfig(
|
||||
(projectsRelationship === 'independent'
|
||||
? defaultIndependentReleaseTagPattern
|
||||
: userConfig.releaseTagPattern || defaultFixedReleaseTagPattern),
|
||||
versionPlans: releaseGroup.versionPlans ?? rootVersionPlansConfig,
|
||||
};
|
||||
|
||||
const finalReleaseGroup = deepMergeDefaults([groupDefaults], {
|
||||
@ -506,6 +530,23 @@ export async function createNxReleaseConfig(
|
||||
delete finalReleaseGroup.version.generatorOptions.specifierSource;
|
||||
}
|
||||
|
||||
// Apply versionPlans shorthand to the final group if explicitly configured in the original group
|
||||
if (releaseGroup.versionPlans) {
|
||||
finalReleaseGroup.version = {
|
||||
...finalReleaseGroup.version,
|
||||
generatorOptions: {
|
||||
...finalReleaseGroup.version?.generatorOptions,
|
||||
specifierSource: 'version-plans',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (
|
||||
releaseGroup.versionPlans === false &&
|
||||
releaseGroupName !== IMPLICIT_DEFAULT_RELEASE_GROUP
|
||||
) {
|
||||
delete finalReleaseGroup.version.generatorOptions.specifierSource;
|
||||
}
|
||||
|
||||
releaseGroups[releaseGroupName] = finalReleaseGroup;
|
||||
}
|
||||
|
||||
@ -521,6 +562,7 @@ export async function createNxReleaseConfig(
|
||||
changelog: rootChangelogConfig,
|
||||
groups: releaseGroups,
|
||||
conventionalCommits: rootConventionalCommitsConfig,
|
||||
versionPlans: rootVersionPlansConfig,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -51,6 +51,7 @@ describe('filterReleaseGroups()', () => {
|
||||
stageChanges: false,
|
||||
},
|
||||
conventionalCommits: DEFAULT_CONVENTIONAL_COMMITS_CONFIG,
|
||||
versionPlans: false,
|
||||
};
|
||||
projectGraph = {
|
||||
nodes: {
|
||||
@ -103,6 +104,7 @@ describe('filterReleaseGroups()', () => {
|
||||
generatorOptions: {},
|
||||
},
|
||||
releaseTagPattern: '',
|
||||
versionPlans: false,
|
||||
},
|
||||
};
|
||||
const { error } = filterReleaseGroups(projectGraph, nxReleaseConfig, [
|
||||
@ -130,6 +132,7 @@ describe('filterReleaseGroups()', () => {
|
||||
generatorOptions: {},
|
||||
},
|
||||
releaseTagPattern: '',
|
||||
versionPlans: false,
|
||||
},
|
||||
bar: {
|
||||
projectsRelationship: 'fixed',
|
||||
@ -141,6 +144,7 @@ describe('filterReleaseGroups()', () => {
|
||||
generatorOptions: {},
|
||||
},
|
||||
releaseTagPattern: '',
|
||||
versionPlans: false,
|
||||
},
|
||||
};
|
||||
const { error, releaseGroups, releaseGroupToFilteredProjects } =
|
||||
@ -161,6 +165,7 @@ describe('filterReleaseGroups()', () => {
|
||||
"generator": "",
|
||||
"generatorOptions": {},
|
||||
},
|
||||
"versionPlans": false,
|
||||
},
|
||||
{
|
||||
"changelog": false,
|
||||
@ -175,6 +180,7 @@ describe('filterReleaseGroups()', () => {
|
||||
"generator": "",
|
||||
"generatorOptions": {},
|
||||
},
|
||||
"versionPlans": false,
|
||||
},
|
||||
]
|
||||
`);
|
||||
@ -193,6 +199,7 @@ describe('filterReleaseGroups()', () => {
|
||||
"generator": "",
|
||||
"generatorOptions": {},
|
||||
},
|
||||
"versionPlans": false,
|
||||
} => Set {
|
||||
"lib-a",
|
||||
},
|
||||
@ -209,6 +216,7 @@ describe('filterReleaseGroups()', () => {
|
||||
"generator": "",
|
||||
"generatorOptions": {},
|
||||
},
|
||||
"versionPlans": false,
|
||||
} => Set {
|
||||
"lib-b",
|
||||
},
|
||||
@ -228,6 +236,7 @@ describe('filterReleaseGroups()', () => {
|
||||
generatorOptions: {},
|
||||
},
|
||||
releaseTagPattern: '',
|
||||
versionPlans: false,
|
||||
},
|
||||
};
|
||||
const { error } = filterReleaseGroups(projectGraph, nxReleaseConfig, [
|
||||
@ -254,6 +263,7 @@ describe('filterReleaseGroups()', () => {
|
||||
generatorOptions: {},
|
||||
},
|
||||
releaseTagPattern: '',
|
||||
versionPlans: false,
|
||||
},
|
||||
bar: {
|
||||
projectsRelationship: 'independent',
|
||||
@ -265,6 +275,7 @@ describe('filterReleaseGroups()', () => {
|
||||
generatorOptions: {},
|
||||
},
|
||||
releaseTagPattern: '',
|
||||
versionPlans: false,
|
||||
},
|
||||
};
|
||||
const { error } = filterReleaseGroups(projectGraph, nxReleaseConfig, [
|
||||
@ -292,6 +303,7 @@ describe('filterReleaseGroups()', () => {
|
||||
generatorOptions: {},
|
||||
},
|
||||
releaseTagPattern: '',
|
||||
versionPlans: false,
|
||||
},
|
||||
bar: {
|
||||
projectsRelationship: 'fixed',
|
||||
@ -303,6 +315,7 @@ describe('filterReleaseGroups()', () => {
|
||||
generatorOptions: {},
|
||||
},
|
||||
releaseTagPattern: '',
|
||||
versionPlans: false,
|
||||
},
|
||||
};
|
||||
const { error, releaseGroups, releaseGroupToFilteredProjects } =
|
||||
@ -323,6 +336,7 @@ describe('filterReleaseGroups()', () => {
|
||||
"generator": "",
|
||||
"generatorOptions": {},
|
||||
},
|
||||
"versionPlans": false,
|
||||
},
|
||||
]
|
||||
`);
|
||||
@ -341,6 +355,7 @@ describe('filterReleaseGroups()', () => {
|
||||
"generator": "",
|
||||
"generatorOptions": {},
|
||||
},
|
||||
"versionPlans": false,
|
||||
} => Set {
|
||||
"lib-a",
|
||||
},
|
||||
@ -376,6 +391,7 @@ describe('filterReleaseGroups()', () => {
|
||||
generatorOptions: {},
|
||||
},
|
||||
releaseTagPattern: '',
|
||||
versionPlans: false,
|
||||
},
|
||||
bar: {
|
||||
projectsRelationship: 'fixed',
|
||||
@ -387,6 +403,7 @@ describe('filterReleaseGroups()', () => {
|
||||
generatorOptions: {},
|
||||
},
|
||||
releaseTagPattern: '',
|
||||
versionPlans: false,
|
||||
},
|
||||
};
|
||||
const { error, releaseGroups, releaseGroupToFilteredProjects } =
|
||||
@ -407,6 +424,7 @@ describe('filterReleaseGroups()', () => {
|
||||
"generator": "",
|
||||
"generatorOptions": {},
|
||||
},
|
||||
"versionPlans": false,
|
||||
},
|
||||
]
|
||||
`);
|
||||
@ -425,6 +443,7 @@ describe('filterReleaseGroups()', () => {
|
||||
"generator": "",
|
||||
"generatorOptions": {},
|
||||
},
|
||||
"versionPlans": false,
|
||||
} => Set {
|
||||
"lib-a",
|
||||
},
|
||||
|
||||
@ -2,9 +2,14 @@ import { ProjectGraph } from '../../../config/project-graph';
|
||||
import { findMatchingProjects } from '../../../utils/find-matching-projects';
|
||||
import { output } from '../../../utils/output';
|
||||
import { IMPLICIT_DEFAULT_RELEASE_GROUP, NxReleaseConfig } from './config';
|
||||
import { GroupVersionPlan, ProjectsVersionPlan } from './version-plans';
|
||||
|
||||
export type ReleaseGroupWithName = NxReleaseConfig['groups'][string] & {
|
||||
export type ReleaseGroupWithName = Omit<
|
||||
NxReleaseConfig['groups'][string],
|
||||
'versionPlans'
|
||||
> & {
|
||||
name: string;
|
||||
versionPlans: (ProjectsVersionPlan | GroupVersionPlan)[] | false;
|
||||
};
|
||||
|
||||
export function filterReleaseGroups(
|
||||
@ -23,6 +28,7 @@ export function filterReleaseGroups(
|
||||
return {
|
||||
...group,
|
||||
name,
|
||||
versionPlans: group.versionPlans ? [] : false,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
---
|
||||
pkg1: patch
|
||||
---
|
||||
|
||||
This is a change to just package 1
|
||||
@ -0,0 +1,6 @@
|
||||
---
|
||||
pkg1: minor
|
||||
pkg2: patch
|
||||
---
|
||||
|
||||
This is a change to package 1 and package 2
|
||||
@ -0,0 +1,6 @@
|
||||
---
|
||||
pkg3: major
|
||||
pkg4: minor
|
||||
---
|
||||
|
||||
This is a change to packages 3 and 4
|
||||
@ -0,0 +1,8 @@
|
||||
---
|
||||
pkg3: patch
|
||||
pkg4: minor
|
||||
pkg5: prerelease
|
||||
pkg6: preminor
|
||||
---
|
||||
|
||||
This is a change to packages 3, 4, 5, and 6
|
||||
@ -0,0 +1,5 @@
|
||||
---
|
||||
fixed-group-1: minor
|
||||
---
|
||||
|
||||
This is a change to fixed-group-1
|
||||
@ -0,0 +1,7 @@
|
||||
---
|
||||
fixed-group-1: major
|
||||
fixed-group-2: minor
|
||||
pkg3: major
|
||||
---
|
||||
|
||||
This is a major change to fixed-group-1 and pkg3 and a minor change to fixed-group-2
|
||||
1127
packages/nx/src/command-line/release/config/version-plans.spec.ts
Normal file
1127
packages/nx/src/command-line/release/config/version-plans.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
280
packages/nx/src/command-line/release/config/version-plans.ts
Normal file
280
packages/nx/src/command-line/release/config/version-plans.ts
Normal file
@ -0,0 +1,280 @@
|
||||
import { readFileSync, readdirSync } from 'fs';
|
||||
import { pathExists, stat } from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
import { RELEASE_TYPES, ReleaseType } from 'semver';
|
||||
import { workspaceRoot } from '../../../utils/workspace-root';
|
||||
import { IMPLICIT_DEFAULT_RELEASE_GROUP } from './config';
|
||||
import { ReleaseGroupWithName } from './filter-release-groups';
|
||||
const fm = require('front-matter');
|
||||
|
||||
export interface VersionPlanFile {
|
||||
absolutePath: string;
|
||||
relativePath: string;
|
||||
fileName: string;
|
||||
createdOnMs: number;
|
||||
}
|
||||
|
||||
export interface RawVersionPlan extends VersionPlanFile {
|
||||
content: Record<string, string>;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface VersionPlan extends VersionPlanFile {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface GroupVersionPlan extends VersionPlan {
|
||||
groupVersionBump: ReleaseType;
|
||||
}
|
||||
|
||||
export interface ProjectsVersionPlan extends VersionPlan {
|
||||
projectVersionBumps: Record<string, ReleaseType>;
|
||||
}
|
||||
|
||||
const versionPlansDirectory = join('.nx', 'version-plans');
|
||||
|
||||
export async function readRawVersionPlans(): Promise<RawVersionPlan[]> {
|
||||
const versionPlansPath = getVersionPlansAbsolutePath();
|
||||
const versionPlansPathExists = await pathExists(versionPlansPath);
|
||||
if (!versionPlansPathExists) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const versionPlans: RawVersionPlan[] = [];
|
||||
|
||||
const versionPlanFiles = readdirSync(versionPlansPath);
|
||||
for (const versionPlanFile of versionPlanFiles) {
|
||||
const filePath = join(versionPlansPath, versionPlanFile);
|
||||
const versionPlanContent = readFileSync(filePath).toString();
|
||||
const versionPlanStats = await stat(filePath);
|
||||
|
||||
const parsedContent = fm(versionPlanContent);
|
||||
versionPlans.push({
|
||||
absolutePath: filePath,
|
||||
relativePath: join(versionPlansDirectory, versionPlanFile),
|
||||
fileName: versionPlanFile,
|
||||
content: parsedContent.attributes,
|
||||
message: getSingleLineMessage(parsedContent.body),
|
||||
createdOnMs: versionPlanStats.birthtimeMs,
|
||||
});
|
||||
}
|
||||
|
||||
return versionPlans;
|
||||
}
|
||||
|
||||
export function setVersionPlansOnGroups(
|
||||
rawVersionPlans: RawVersionPlan[],
|
||||
releaseGroups: ReleaseGroupWithName[],
|
||||
allProjectNamesInWorkspace: string[]
|
||||
): ReleaseGroupWithName[] {
|
||||
const groupsByName = releaseGroups.reduce(
|
||||
(acc, group) => acc.set(group.name, group),
|
||||
new Map<string, ReleaseGroupWithName>()
|
||||
);
|
||||
const isDefaultGroup = isDefault(releaseGroups);
|
||||
|
||||
for (const rawVersionPlan of rawVersionPlans) {
|
||||
for (const [key, value] of Object.entries(rawVersionPlan.content)) {
|
||||
if (groupsByName.has(key)) {
|
||||
const group = groupsByName.get(key);
|
||||
|
||||
if (!group.versionPlans) {
|
||||
if (isDefaultGroup) {
|
||||
throw new Error(
|
||||
`Found a version bump in '${rawVersionPlan.fileName}' but version plans are not enabled.`
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Found a version bump for group '${key}' in '${rawVersionPlan.fileName}' but the group does not have version plans enabled.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (group.projectsRelationship === 'independent') {
|
||||
if (isDefaultGroup) {
|
||||
throw new Error(
|
||||
`Found a version bump in '${rawVersionPlan.fileName}' but projects are configured to be independently versioned. Individual projects should be bumped instead.`
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Found a version bump for group '${key}' in '${rawVersionPlan.fileName}' but the group's projects are independently versioned. Individual projects of '${key}' should be bumped instead.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isReleaseType(value)) {
|
||||
if (isDefaultGroup) {
|
||||
throw new Error(
|
||||
`Found a version bump in '${
|
||||
rawVersionPlan.fileName
|
||||
}' with an invalid release type. Please specify one of ${RELEASE_TYPES.join(
|
||||
', '
|
||||
)}.`
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Found a version bump for group '${key}' in '${
|
||||
rawVersionPlan.fileName
|
||||
}' with an invalid release type. Please specify one of ${RELEASE_TYPES.join(
|
||||
', '
|
||||
)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const existingPlan = <GroupVersionPlan>(
|
||||
group.versionPlans.find(
|
||||
(plan) => plan.fileName === rawVersionPlan.fileName
|
||||
)
|
||||
);
|
||||
if (existingPlan) {
|
||||
if (existingPlan.groupVersionBump !== value) {
|
||||
if (isDefaultGroup) {
|
||||
throw new Error(
|
||||
`Found a version bump in '${rawVersionPlan.fileName}' that conflicts with another version bump. When in fixed versioning mode, all version bumps must match.`
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Found a version bump for group '${key}' in '${rawVersionPlan.fileName}' that conflicts with another version bump for this group. When the group is in fixed versioning mode, all groups' version bumps within the same version plan must match.`
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
group.versionPlans.push(<GroupVersionPlan>{
|
||||
absolutePath: rawVersionPlan.absolutePath,
|
||||
relativePath: rawVersionPlan.relativePath,
|
||||
fileName: rawVersionPlan.fileName,
|
||||
createdOnMs: rawVersionPlan.createdOnMs,
|
||||
message: rawVersionPlan.message,
|
||||
groupVersionBump: value,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const groupForProject = releaseGroups.find((group) =>
|
||||
group.projects.includes(key)
|
||||
);
|
||||
if (!groupForProject) {
|
||||
if (!allProjectNamesInWorkspace.includes(key)) {
|
||||
throw new Error(
|
||||
`Found a version bump for project '${key}' in '${rawVersionPlan.fileName}' but the project does not exist in the workspace.`
|
||||
);
|
||||
}
|
||||
|
||||
if (isDefaultGroup) {
|
||||
throw new Error(
|
||||
`Found a version bump for project '${key}' in '${rawVersionPlan.fileName}' but the project is not configured for release. Ensure it is included by the 'release.projects' globs in nx.json.`
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Found a version bump for project '${key}' in '${rawVersionPlan.fileName}' but the project is not in any configured release groups.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!groupForProject.versionPlans) {
|
||||
if (isDefaultGroup) {
|
||||
throw new Error(
|
||||
`Found a version bump for project '${key}' in '${rawVersionPlan.fileName}' but version plans are not enabled.`
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Found a version bump for project '${key}' in '${rawVersionPlan.fileName}' but the project's group '${groupForProject.name}' does not have version plans enabled.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isReleaseType(value)) {
|
||||
throw new Error(
|
||||
`Found a version bump for project '${key}' in '${
|
||||
rawVersionPlan.fileName
|
||||
}' with an invalid release type. Please specify one of ${RELEASE_TYPES.join(
|
||||
', '
|
||||
)}.`
|
||||
);
|
||||
}
|
||||
|
||||
if (groupForProject.projectsRelationship === 'independent') {
|
||||
const existingPlan = <ProjectsVersionPlan>(
|
||||
groupForProject.versionPlans.find(
|
||||
(plan) => plan.fileName === rawVersionPlan.fileName
|
||||
)
|
||||
);
|
||||
if (existingPlan) {
|
||||
existingPlan.projectVersionBumps[key] = value;
|
||||
} else {
|
||||
groupForProject.versionPlans.push(<ProjectsVersionPlan>{
|
||||
absolutePath: rawVersionPlan.absolutePath,
|
||||
relativePath: rawVersionPlan.relativePath,
|
||||
fileName: rawVersionPlan.fileName,
|
||||
createdOnMs: rawVersionPlan.createdOnMs,
|
||||
message: rawVersionPlan.message,
|
||||
projectVersionBumps: {
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const existingPlan = <GroupVersionPlan>(
|
||||
groupForProject.versionPlans.find(
|
||||
(plan) => plan.fileName === rawVersionPlan.fileName
|
||||
)
|
||||
);
|
||||
// This can occur if the same fixed release group has multiple entries for different projects within
|
||||
// the same version plan file. This will be the case when users are using the default release group.
|
||||
if (existingPlan) {
|
||||
if (existingPlan.groupVersionBump !== value) {
|
||||
if (isDefaultGroup) {
|
||||
throw new Error(
|
||||
`Found a version bump for project '${key}' in '${rawVersionPlan.fileName}' that conflicts with another version bump. When in fixed versioning mode, all version bumps must match.`
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Found a version bump for project '${key}' in '${rawVersionPlan.fileName}' that conflicts with another project's version bump in the same release group '${groupForProject.name}'. When the group is in fixed versioning mode, all projects' version bumps within the same group must match.`
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
groupForProject.versionPlans.push(<GroupVersionPlan>{
|
||||
absolutePath: rawVersionPlan.absolutePath,
|
||||
relativePath: rawVersionPlan.relativePath,
|
||||
fileName: rawVersionPlan.fileName,
|
||||
createdOnMs: rawVersionPlan.createdOnMs,
|
||||
message: rawVersionPlan.message,
|
||||
// This is a fixed group, so the version bump is for the group, even if a project within it was specified
|
||||
groupVersionBump: value,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Order the plans from newest to oldest
|
||||
releaseGroups.forEach((group) => {
|
||||
if (group.versionPlans) {
|
||||
group.versionPlans.sort((a, b) => b.createdOnMs - a.createdOnMs);
|
||||
}
|
||||
});
|
||||
|
||||
return releaseGroups;
|
||||
}
|
||||
|
||||
function isDefault(releaseGroups: ReleaseGroupWithName[]) {
|
||||
return (
|
||||
releaseGroups.length === 1 &&
|
||||
releaseGroups.some((group) => group.name === IMPLICIT_DEFAULT_RELEASE_GROUP)
|
||||
);
|
||||
}
|
||||
|
||||
export function getVersionPlansAbsolutePath() {
|
||||
return join(workspaceRoot, versionPlansDirectory);
|
||||
}
|
||||
|
||||
function isReleaseType(value: string): value is ReleaseType {
|
||||
return RELEASE_TYPES.includes(value as ReleaseType);
|
||||
}
|
||||
|
||||
// changelog messages may only be a single line long, so ignore anything else
|
||||
function getSingleLineMessage(message: string) {
|
||||
return message.trim().split('\n')[0];
|
||||
}
|
||||
246
packages/nx/src/command-line/release/plan.ts
Normal file
246
packages/nx/src/command-line/release/plan.ts
Normal file
@ -0,0 +1,246 @@
|
||||
import { prompt } from 'enquirer';
|
||||
import { ensureDir, writeFile } from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
import { RELEASE_TYPES } from 'semver';
|
||||
import { readNxJson } from '../../config/nx-json';
|
||||
import { createProjectFileMapUsingProjectGraph } from '../../project-graph/file-map-utils';
|
||||
import { createProjectGraphAsync } from '../../project-graph/project-graph';
|
||||
import { output } from '../../utils/output';
|
||||
import { handleErrors } from '../../utils/params';
|
||||
import { PlanOptions } from './command-object';
|
||||
import {
|
||||
IMPLICIT_DEFAULT_RELEASE_GROUP,
|
||||
createNxReleaseConfig,
|
||||
handleNxReleaseConfigError,
|
||||
} from './config/config';
|
||||
import { filterReleaseGroups } from './config/filter-release-groups';
|
||||
import { getVersionPlansAbsolutePath } from './config/version-plans';
|
||||
import { parseConventionalCommitsMessage } from './utils/git';
|
||||
import { printDiff } from './utils/print-changes';
|
||||
|
||||
export const releasePlanCLIHandler = (args: PlanOptions) =>
|
||||
handleErrors(args.verbose, () => releasePlan(args));
|
||||
|
||||
export async function releasePlan(args: PlanOptions): Promise<string | number> {
|
||||
const projectGraph = await createProjectGraphAsync({ exitOnError: true });
|
||||
const nxJson = readNxJson();
|
||||
|
||||
if (args.verbose) {
|
||||
process.env.NX_VERBOSE_LOGGING = 'true';
|
||||
}
|
||||
|
||||
// Apply default configuration to any optional user configuration
|
||||
const { error: configError, nxReleaseConfig } = await createNxReleaseConfig(
|
||||
projectGraph,
|
||||
await createProjectFileMapUsingProjectGraph(projectGraph),
|
||||
nxJson.release
|
||||
);
|
||||
if (configError) {
|
||||
return await handleNxReleaseConfigError(configError);
|
||||
}
|
||||
|
||||
const {
|
||||
error: filterError,
|
||||
releaseGroups,
|
||||
releaseGroupToFilteredProjects,
|
||||
} = filterReleaseGroups(
|
||||
projectGraph,
|
||||
nxReleaseConfig,
|
||||
args.projects,
|
||||
args.groups
|
||||
);
|
||||
if (filterError) {
|
||||
output.error(filterError);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const versionPlanBumps: Record<string, string> = {};
|
||||
const setBumpIfNotNone = (projectOrGroup: string, version: string) => {
|
||||
if (version !== 'none') {
|
||||
versionPlanBumps[projectOrGroup] = version;
|
||||
}
|
||||
};
|
||||
|
||||
if (args.message) {
|
||||
const message = parseConventionalCommitsMessage(args.message);
|
||||
if (!message) {
|
||||
output.error({
|
||||
title: 'Changelog message is not in conventional commits format.',
|
||||
bodyLines: [
|
||||
'Please ensure your message is in the form of:',
|
||||
' type(optional scope): description',
|
||||
'',
|
||||
'For example:',
|
||||
' feat(pkg-b): add new feature',
|
||||
' fix(pkg-a): correct a bug',
|
||||
' chore: update build process',
|
||||
' fix(core)!: breaking change in core package',
|
||||
],
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (releaseGroups[0].name === IMPLICIT_DEFAULT_RELEASE_GROUP) {
|
||||
const group = releaseGroups[0];
|
||||
if (group.projectsRelationship === 'independent') {
|
||||
for (const project of group.projects) {
|
||||
setBumpIfNotNone(
|
||||
project,
|
||||
args.bump ||
|
||||
(await promptForVersion(
|
||||
`How do you want to bump the version of the project "${project}"?`
|
||||
))
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// TODO: use project names instead of the implicit default release group name? (though this might be confusing, as users might think they can just delete one of the project bumps to change the behavior to independent versioning)
|
||||
setBumpIfNotNone(
|
||||
group.name,
|
||||
args.bump ||
|
||||
(await promptForVersion(
|
||||
`How do you want to bump the versions of all projects?`
|
||||
))
|
||||
);
|
||||
}
|
||||
} else {
|
||||
for (const group of releaseGroups) {
|
||||
if (group.projectsRelationship === 'independent') {
|
||||
for (const project of releaseGroupToFilteredProjects.get(group)) {
|
||||
setBumpIfNotNone(
|
||||
project,
|
||||
args.bump ||
|
||||
(await promptForVersion(
|
||||
`How do you want to bump the version of the project "${project}" within group "${group.name}"?`
|
||||
))
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setBumpIfNotNone(
|
||||
group.name,
|
||||
args.bump ||
|
||||
(await promptForVersion(
|
||||
`How do you want to bump the versions of the projects in the group "${group.name}"?`
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!Object.keys(versionPlanBumps).length) {
|
||||
output.warn({
|
||||
title:
|
||||
'No version bumps were selected so no version plan file was created.',
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
|
||||
const versionPlanMessage = args.message || (await promptForMessage());
|
||||
const versionPlanFileContent = getVersionPlanFileContent(
|
||||
versionPlanBumps,
|
||||
versionPlanMessage
|
||||
);
|
||||
const versionPlanFileName = `version-plan-${new Date().getTime()}.md`;
|
||||
|
||||
if (args.dryRun) {
|
||||
output.logSingleLine(
|
||||
`Would create version plan file "${versionPlanFileName}", but --dry-run was set.`
|
||||
);
|
||||
printDiff('', versionPlanFileContent, 1);
|
||||
} else {
|
||||
output.logSingleLine(`Creating version plan file "${versionPlanFileName}"`);
|
||||
printDiff('', versionPlanFileContent, 1);
|
||||
|
||||
const versionPlansAbsolutePath = getVersionPlansAbsolutePath();
|
||||
await ensureDir(versionPlansAbsolutePath);
|
||||
await writeFile(
|
||||
join(versionPlansAbsolutePath, versionPlanFileName),
|
||||
versionPlanFileContent
|
||||
);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function promptForVersion(message: string): Promise<string> {
|
||||
try {
|
||||
const reply = await prompt<{ version: string }>([
|
||||
{
|
||||
name: 'version',
|
||||
message,
|
||||
type: 'select',
|
||||
choices: [...RELEASE_TYPES, 'none'],
|
||||
},
|
||||
]);
|
||||
return reply.version;
|
||||
} catch (e) {
|
||||
output.log({
|
||||
title: 'Cancelled version plan creation.',
|
||||
});
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
async function promptForMessage(): Promise<string> {
|
||||
let message: string;
|
||||
do {
|
||||
message = await _promptForMessage();
|
||||
} while (!message);
|
||||
return message;
|
||||
}
|
||||
|
||||
// TODO: support non-conventional commits messages (will require significant changelog renderer changes)
|
||||
async function _promptForMessage(): Promise<string> {
|
||||
try {
|
||||
const reply = await prompt<{ message: string }>([
|
||||
{
|
||||
name: 'message',
|
||||
message:
|
||||
'What changelog message would you like associated with this change?',
|
||||
type: 'input',
|
||||
},
|
||||
]);
|
||||
|
||||
const conventionalCommitsMessage = parseConventionalCommitsMessage(
|
||||
reply.message
|
||||
);
|
||||
if (!conventionalCommitsMessage) {
|
||||
output.warn({
|
||||
title: 'Changelog message is not in conventional commits format.',
|
||||
bodyLines: [
|
||||
'Please ensure your message is in the form of:',
|
||||
' type(optional scope): description',
|
||||
'',
|
||||
'For example:',
|
||||
' feat(pkg-b): add new feature',
|
||||
' fix(pkg-a): correct a bug',
|
||||
' chore: update build process',
|
||||
' fix(core)!: breaking change in core package',
|
||||
],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return reply.message;
|
||||
} catch (e) {
|
||||
output.log({
|
||||
title: 'Cancelled version plan creation.',
|
||||
});
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
function getVersionPlanFileContent(
|
||||
bumps: Record<string, string>,
|
||||
message: string
|
||||
): string {
|
||||
return `---
|
||||
${Object.entries(bumps)
|
||||
.filter(([_, version]) => version !== 'none')
|
||||
.map(([projectOrGroup, version]) => `${projectOrGroup}: ${version}`)
|
||||
.join('\n')}
|
||||
---
|
||||
|
||||
${message}
|
||||
`;
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import { prompt } from 'enquirer';
|
||||
import { removeSync } from 'fs-extra';
|
||||
import { readNxJson } from '../../config/nx-json';
|
||||
import { createProjectFileMapUsingProjectGraph } from '../../project-graph/file-map-utils';
|
||||
import { createProjectGraphAsync } from '../../project-graph/project-graph';
|
||||
@ -7,12 +8,17 @@ import { handleErrors } from '../../utils/params';
|
||||
import { releaseChangelog, shouldCreateGitHubRelease } from './changelog';
|
||||
import { ReleaseOptions, VersionOptions } from './command-object';
|
||||
import {
|
||||
IMPLICIT_DEFAULT_RELEASE_GROUP,
|
||||
createNxReleaseConfig,
|
||||
handleNxReleaseConfigError,
|
||||
} from './config/config';
|
||||
import { filterReleaseGroups } from './config/filter-release-groups';
|
||||
import {
|
||||
readRawVersionPlans,
|
||||
setVersionPlansOnGroups,
|
||||
} from './config/version-plans';
|
||||
import { releasePublish } from './publish';
|
||||
import { getCommitHash, gitCommit, gitPush, gitTag } from './utils/git';
|
||||
import { getCommitHash, gitAdd, gitCommit, gitPush, gitTag } from './utils/git';
|
||||
import { createOrUpdateGithubRelease } from './utils/github';
|
||||
import { resolveNxJsonConfigErrorMessage } from './utils/resolve-nx-json-error-message';
|
||||
import {
|
||||
@ -75,6 +81,7 @@ export async function release(
|
||||
stageChanges: shouldStage,
|
||||
gitCommit: false,
|
||||
gitTag: false,
|
||||
deleteVersionPlans: false,
|
||||
});
|
||||
|
||||
const changelogResult = await releaseChangelog({
|
||||
@ -85,6 +92,7 @@ export async function release(
|
||||
gitCommit: false,
|
||||
gitTag: false,
|
||||
createRelease: false,
|
||||
deleteVersionPlans: false,
|
||||
});
|
||||
|
||||
const {
|
||||
@ -101,6 +109,49 @@ export async function release(
|
||||
output.error(filterError);
|
||||
process.exit(1);
|
||||
}
|
||||
const rawVersionPlans = await readRawVersionPlans();
|
||||
setVersionPlansOnGroups(
|
||||
rawVersionPlans,
|
||||
releaseGroups,
|
||||
Object.keys(projectGraph.nodes)
|
||||
);
|
||||
|
||||
const planFiles = new Set<string>();
|
||||
releaseGroups.forEach((group) => {
|
||||
if (group.versionPlans) {
|
||||
if (group.name === IMPLICIT_DEFAULT_RELEASE_GROUP) {
|
||||
output.logSingleLine(`Removing version plan files`);
|
||||
} else {
|
||||
output.logSingleLine(
|
||||
`Removing version plan files for group ${group.name}`
|
||||
);
|
||||
}
|
||||
group.versionPlans.forEach((plan) => {
|
||||
if (!args.dryRun) {
|
||||
removeSync(plan.absolutePath);
|
||||
if (args.verbose) {
|
||||
console.log(`Removing ${plan.relativePath}`);
|
||||
}
|
||||
} else {
|
||||
if (args.verbose) {
|
||||
console.log(
|
||||
`Would remove ${plan.relativePath}, but --dry-run was set`
|
||||
);
|
||||
}
|
||||
}
|
||||
planFiles.add(plan.relativePath);
|
||||
});
|
||||
}
|
||||
});
|
||||
const deletedFiles = Array.from(planFiles);
|
||||
if (deletedFiles.length > 0) {
|
||||
await gitAdd({
|
||||
changedFiles: [],
|
||||
deletedFiles,
|
||||
dryRun: args.dryRun,
|
||||
verbose: args.verbose,
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldCommit) {
|
||||
output.logSingleLine(`Committing changes with git`);
|
||||
|
||||
@ -147,13 +147,21 @@ export async function getGitDiff(
|
||||
});
|
||||
}
|
||||
|
||||
export async function getChangedTrackedFiles(): Promise<Set<string>> {
|
||||
const result = await execCommand('git', ['status', '--porcelain']);
|
||||
const lines = result.split('\n').filter((l) => l.trim().length > 0);
|
||||
return new Set(lines.map((l) => l.substring(3)));
|
||||
}
|
||||
|
||||
export async function gitAdd({
|
||||
changedFiles,
|
||||
deletedFiles,
|
||||
dryRun,
|
||||
verbose,
|
||||
logFn,
|
||||
}: {
|
||||
changedFiles: string[];
|
||||
changedFiles?: string[];
|
||||
deletedFiles?: string[];
|
||||
dryRun?: boolean;
|
||||
verbose?: boolean;
|
||||
logFn?: (...messages: string[]) => void;
|
||||
@ -162,7 +170,7 @@ export async function gitAdd({
|
||||
|
||||
let ignoredFiles: string[] = [];
|
||||
let filesToAdd: string[] = [];
|
||||
for (const f of changedFiles) {
|
||||
for (const f of changedFiles ?? []) {
|
||||
const isFileIgnored = await isIgnored(f);
|
||||
if (isFileIgnored) {
|
||||
ignoredFiles.push(f);
|
||||
@ -171,13 +179,29 @@ export async function gitAdd({
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedFiles?.length > 0) {
|
||||
const changedTrackedFiles = await getChangedTrackedFiles();
|
||||
for (const f of deletedFiles ?? []) {
|
||||
const isFileIgnored = await isIgnored(f);
|
||||
if (isFileIgnored) {
|
||||
ignoredFiles.push(f);
|
||||
// git add will fail if trying to add an untracked file that doesn't exist
|
||||
} else if (changedTrackedFiles.has(f)) {
|
||||
filesToAdd.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (verbose && ignoredFiles.length) {
|
||||
logFn(`Will not add the following files because they are ignored by git:`);
|
||||
ignoredFiles.forEach((f) => logFn(f));
|
||||
}
|
||||
|
||||
if (!filesToAdd.length) {
|
||||
logFn('\nNo files to stage. Skipping git add.');
|
||||
if (!dryRun) {
|
||||
logFn('\nNo files to stage. Skipping git add.');
|
||||
}
|
||||
// if this is a dry run, it's possible that there would have been actual files to add, so it's deceptive to say "No files to stage".
|
||||
return;
|
||||
}
|
||||
|
||||
@ -349,6 +373,25 @@ export function parseCommits(commits: RawGitCommit[]): GitCommit[] {
|
||||
return commits.map((commit) => parseGitCommit(commit)).filter(Boolean);
|
||||
}
|
||||
|
||||
export function parseConventionalCommitsMessage(message: string): {
|
||||
type: string;
|
||||
scope: string;
|
||||
description: string;
|
||||
breaking: boolean;
|
||||
} | null {
|
||||
const match = message.match(ConventionalCommitRegex);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: match.groups.type || '',
|
||||
scope: match.groups.scope || '',
|
||||
description: match.groups.description || '',
|
||||
breaking: Boolean(match.groups.breaking),
|
||||
};
|
||||
}
|
||||
|
||||
// https://www.conventionalcommits.org/en/v1.0.0/
|
||||
// https://regex101.com/r/FSfNvA/1
|
||||
const ConventionalCommitRegex =
|
||||
@ -360,16 +403,15 @@ const ChangedFileRegex = /(A|M|D|R\d*|C\d*)\t([^\t\n]*)\t?(.*)?/gm;
|
||||
const RevertHashRE = /This reverts commit (?<hash>[\da-f]{40})./gm;
|
||||
|
||||
export function parseGitCommit(commit: RawGitCommit): GitCommit | null {
|
||||
const match = commit.message.match(ConventionalCommitRegex);
|
||||
if (!match) {
|
||||
const parsedMessage = parseConventionalCommitsMessage(commit.message);
|
||||
if (!parsedMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scope = match.groups.scope || '';
|
||||
|
||||
const scope = parsedMessage.scope;
|
||||
const isBreaking =
|
||||
Boolean(match.groups.breaking) || commit.body.includes('BREAKING CHANGE:');
|
||||
let description = match.groups.description;
|
||||
parsedMessage.breaking || commit.body.includes('BREAKING CHANGE:');
|
||||
let description = parsedMessage.description;
|
||||
|
||||
// Extract references from message
|
||||
const references: Reference[] = [];
|
||||
@ -386,7 +428,7 @@ export function parseGitCommit(commit: RawGitCommit): GitCommit | null {
|
||||
// Remove references and normalize
|
||||
description = description.replace(PullRequestRE, '').trim();
|
||||
|
||||
let type = match.groups.type;
|
||||
let type = parsedMessage.type;
|
||||
// Extract any reverted hashes, if applicable
|
||||
const revertedHashes = [];
|
||||
const matchedHashes = commit.body.matchAll(RevertHashRE);
|
||||
|
||||
@ -17,6 +17,7 @@ describe('shared', () => {
|
||||
},
|
||||
changelog: false,
|
||||
releaseTagPattern: '{projectName}-{version}',
|
||||
versionPlans: false,
|
||||
},
|
||||
{
|
||||
name: 'two',
|
||||
@ -29,6 +30,7 @@ describe('shared', () => {
|
||||
},
|
||||
changelog: false,
|
||||
releaseTagPattern: '{projectName}-{version}',
|
||||
versionPlans: false,
|
||||
},
|
||||
];
|
||||
const releaseGroupToFilteredProjects = new Map()
|
||||
@ -101,6 +103,7 @@ describe('shared', () => {
|
||||
},
|
||||
releaseTagPattern: '{projectName}-{version}',
|
||||
name: '__default__',
|
||||
versionPlans: false,
|
||||
},
|
||||
];
|
||||
|
||||
@ -190,6 +193,7 @@ describe('shared', () => {
|
||||
releaseTagPattern: 'my-group-{version}',
|
||||
changelog: undefined,
|
||||
version: undefined,
|
||||
versionPlans: false,
|
||||
};
|
||||
const releaseGroupToFilteredProjects = new Map().set(
|
||||
releaseGroup,
|
||||
|
||||
@ -21,7 +21,13 @@ export type ReleaseVersionGeneratorResult = {
|
||||
verbose?: boolean;
|
||||
generatorOptions?: Record<string, unknown>;
|
||||
}
|
||||
) => Promise<string[]>;
|
||||
) => Promise<
|
||||
| string[]
|
||||
| {
|
||||
changedFiles: string[];
|
||||
deletedFiles: string[];
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
export type VersionData = Record<
|
||||
@ -69,20 +75,29 @@ export class ReleaseVersion {
|
||||
}
|
||||
}
|
||||
|
||||
export async function commitChanges(
|
||||
changedFiles: string[],
|
||||
isDryRun: boolean,
|
||||
isVerbose: boolean,
|
||||
gitCommitMessages: string[],
|
||||
gitCommitArgs?: string
|
||||
) {
|
||||
if (!changedFiles.length) {
|
||||
export async function commitChanges({
|
||||
changedFiles,
|
||||
deletedFiles,
|
||||
isDryRun,
|
||||
isVerbose,
|
||||
gitCommitMessages,
|
||||
gitCommitArgs,
|
||||
}: {
|
||||
changedFiles?: string[];
|
||||
deletedFiles?: string[];
|
||||
isDryRun?: boolean;
|
||||
isVerbose?: boolean;
|
||||
gitCommitMessages?: string[];
|
||||
gitCommitArgs?: string;
|
||||
}) {
|
||||
if (!changedFiles?.length && !deletedFiles?.length) {
|
||||
throw new Error('Error: No changed files to commit');
|
||||
}
|
||||
|
||||
output.logSingleLine(`Committing changes with git`);
|
||||
await gitAdd({
|
||||
changedFiles,
|
||||
deletedFiles,
|
||||
dryRun: isDryRun,
|
||||
verbose: isVerbose,
|
||||
});
|
||||
|
||||
@ -30,6 +30,10 @@ import {
|
||||
ReleaseGroupWithName,
|
||||
filterReleaseGroups,
|
||||
} from './config/filter-release-groups';
|
||||
import {
|
||||
readRawVersionPlans,
|
||||
setVersionPlansOnGroups,
|
||||
} from './config/version-plans';
|
||||
import { batchProjectsByGeneratorConfig } from './utils/batch-projects-by-generator-config';
|
||||
import { gitAdd, gitTag } from './utils/git';
|
||||
import { printDiff } from './utils/print-changes';
|
||||
@ -60,7 +64,7 @@ export interface ReleaseVersionGeneratorSchema {
|
||||
releaseGroup: ReleaseGroupWithName;
|
||||
projectGraph: ProjectGraph;
|
||||
specifier?: string;
|
||||
specifierSource?: 'prompt' | 'conventional-commits';
|
||||
specifierSource?: 'prompt' | 'conventional-commits' | 'version-plans';
|
||||
preid?: string;
|
||||
packageRoot?: string;
|
||||
currentVersionResolver?: 'registry' | 'disk' | 'git-tag';
|
||||
@ -73,6 +77,7 @@ export interface ReleaseVersionGeneratorSchema {
|
||||
installArgs?: string;
|
||||
installIgnoreScripts?: boolean;
|
||||
conventionalCommitsConfig?: NxReleaseConfig['conventionalCommits'];
|
||||
deleteVersionPlans?: boolean;
|
||||
/**
|
||||
* 'auto' allows users to opt into dependents being updated (a patch version bump) when a dependency is versioned.
|
||||
* This is only applicable to independently released projects.
|
||||
@ -159,6 +164,17 @@ export async function releaseVersion(
|
||||
output.error(filterError);
|
||||
process.exit(1);
|
||||
}
|
||||
const rawVersionPlans = await readRawVersionPlans();
|
||||
setVersionPlansOnGroups(
|
||||
rawVersionPlans,
|
||||
releaseGroups,
|
||||
Object.keys(projectGraph.nodes)
|
||||
);
|
||||
|
||||
if (args.deleteVersionPlans === undefined) {
|
||||
// default to not delete version plans after versioning as they may be needed for changelog generation
|
||||
args.deleteVersionPlans = false;
|
||||
}
|
||||
|
||||
runPreVersionCommand(nxReleaseConfig.version.preVersionCommand, {
|
||||
dryRun: args.dryRun,
|
||||
@ -177,6 +193,7 @@ export async function releaseVersion(
|
||||
* and need to get staged and committed as part of the existing commit, if applicable.
|
||||
*/
|
||||
const additionalChangedFiles = new Set<string>();
|
||||
const additionalDeletedFiles = new Set<string>();
|
||||
|
||||
if (args.projects?.length) {
|
||||
/**
|
||||
@ -227,7 +244,7 @@ export async function releaseVersion(
|
||||
);
|
||||
// Capture the callback so that we can run it after flushing the changes to disk
|
||||
generatorCallbacks.push(async () => {
|
||||
const changedFiles = await generatorCallback(tree, {
|
||||
const result = await generatorCallback(tree, {
|
||||
dryRun: !!args.dryRun,
|
||||
verbose: !!args.verbose,
|
||||
generatorOptions: {
|
||||
@ -235,7 +252,10 @@ export async function releaseVersion(
|
||||
...args.generatorOptionsOverrides,
|
||||
},
|
||||
});
|
||||
const { changedFiles, deletedFiles } =
|
||||
parseGeneratorCallbackResult(result);
|
||||
changedFiles.forEach((f) => additionalChangedFiles.add(f));
|
||||
deletedFiles.forEach((f) => additionalDeletedFiles.add(f));
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -272,18 +292,20 @@ export async function releaseVersion(
|
||||
}
|
||||
|
||||
if (args.gitCommit ?? nxReleaseConfig.version.git.commit) {
|
||||
await commitChanges(
|
||||
await commitChanges({
|
||||
changedFiles,
|
||||
!!args.dryRun,
|
||||
!!args.verbose,
|
||||
createCommitMessageValues(
|
||||
deletedFiles: Array.from(additionalDeletedFiles),
|
||||
isDryRun: !!args.dryRun,
|
||||
isVerbose: !!args.verbose,
|
||||
gitCommitMessages: createCommitMessageValues(
|
||||
releaseGroups,
|
||||
releaseGroupToFilteredProjects,
|
||||
versionData,
|
||||
commitMessage
|
||||
),
|
||||
args.gitCommitArgs || nxReleaseConfig.version.git.commitArgs
|
||||
);
|
||||
gitCommitArgs:
|
||||
args.gitCommitArgs || nxReleaseConfig.version.git.commitArgs,
|
||||
});
|
||||
} else if (args.stageChanges ?? nxReleaseConfig.version.git.stageChanges) {
|
||||
output.logSingleLine(`Staging changed files with git`);
|
||||
await gitAdd({
|
||||
@ -359,7 +381,7 @@ export async function releaseVersion(
|
||||
);
|
||||
// Capture the callback so that we can run it after flushing the changes to disk
|
||||
generatorCallbacks.push(async () => {
|
||||
const changedFiles = await generatorCallback(tree, {
|
||||
const result = await generatorCallback(tree, {
|
||||
dryRun: !!args.dryRun,
|
||||
verbose: !!args.verbose,
|
||||
generatorOptions: {
|
||||
@ -367,7 +389,10 @@ export async function releaseVersion(
|
||||
...args.generatorOptionsOverrides,
|
||||
},
|
||||
});
|
||||
const { changedFiles, deletedFiles } =
|
||||
parseGeneratorCallbackResult(result);
|
||||
changedFiles.forEach((f) => additionalChangedFiles.add(f));
|
||||
deletedFiles.forEach((f) => additionalDeletedFiles.add(f));
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -415,18 +440,20 @@ export async function releaseVersion(
|
||||
}
|
||||
|
||||
if (args.gitCommit ?? nxReleaseConfig.version.git.commit) {
|
||||
await commitChanges(
|
||||
await commitChanges({
|
||||
changedFiles,
|
||||
!!args.dryRun,
|
||||
!!args.verbose,
|
||||
createCommitMessageValues(
|
||||
deletedFiles: Array.from(additionalDeletedFiles),
|
||||
isDryRun: !!args.dryRun,
|
||||
isVerbose: !!args.verbose,
|
||||
gitCommitMessages: createCommitMessageValues(
|
||||
releaseGroups,
|
||||
releaseGroupToFilteredProjects,
|
||||
versionData,
|
||||
commitMessage
|
||||
),
|
||||
args.gitCommitArgs || nxReleaseConfig.version.git.commitArgs
|
||||
);
|
||||
gitCommitArgs:
|
||||
args.gitCommitArgs || nxReleaseConfig.version.git.commitArgs,
|
||||
});
|
||||
} else if (args.stageChanges ?? nxReleaseConfig.version.git.stageChanges) {
|
||||
output.logSingleLine(`Staging changed files with git`);
|
||||
await gitAdd({
|
||||
@ -495,6 +522,7 @@ async function runVersionOnProjects(
|
||||
releaseGroup,
|
||||
firstRelease: args.firstRelease ?? false,
|
||||
conventionalCommitsConfig,
|
||||
deleteVersionPlans: args.deleteVersionPlans,
|
||||
};
|
||||
|
||||
// Apply generator defaults from schema.json file etc
|
||||
@ -555,7 +583,7 @@ function printAndFlushChanges(tree: Tree, isDryRun: boolean) {
|
||||
joinPathFragments(tree.root, f.path)
|
||||
).toString();
|
||||
printDiff(currentContentsOnDisk, f.content?.toString() || '');
|
||||
} else if (f.type === 'DELETE') {
|
||||
} else if (f.type === 'DELETE' && !f.path.includes('.nx')) {
|
||||
throw new Error(
|
||||
'Unexpected DELETE change, please report this as an issue'
|
||||
);
|
||||
@ -686,3 +714,16 @@ function runPreVersionCommand(
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function parseGeneratorCallbackResult(
|
||||
result: string[] | { changedFiles: string[]; deletedFiles: string[] }
|
||||
): { changedFiles: string[]; deletedFiles: string[] } {
|
||||
if (Array.isArray(result)) {
|
||||
return {
|
||||
changedFiles: result,
|
||||
deletedFiles: [],
|
||||
};
|
||||
} else {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@ -218,6 +218,11 @@ interface NxReleaseConfiguration {
|
||||
* Optionally override the git/release tag pattern to use for this group.
|
||||
*/
|
||||
releaseTagPattern?: string;
|
||||
/**
|
||||
* Enables using version plans as a specifier source for versioning and
|
||||
* to determine changes for changelog generation.
|
||||
*/
|
||||
versionPlans?: boolean;
|
||||
}
|
||||
>;
|
||||
/**
|
||||
@ -285,6 +290,11 @@ interface NxReleaseConfiguration {
|
||||
*/
|
||||
git?: NxReleaseGitConfiguration;
|
||||
conventionalCommits?: NxReleaseConventionalCommitsConfiguration;
|
||||
/**
|
||||
* Enables using version plans as a specifier source for versioning and
|
||||
* to determine changes for changelog generation.
|
||||
*/
|
||||
versionPlans?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@ -72,6 +72,9 @@ dependencies:
|
||||
framer-motion:
|
||||
specifier: ^11.1.7
|
||||
version: 11.1.7(react-dom@18.3.1)(react@18.3.1)
|
||||
front-matter:
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2
|
||||
glob:
|
||||
specifier: 7.1.4
|
||||
version: 7.1.4
|
||||
@ -22212,6 +22215,12 @@ packages:
|
||||
resolution: {integrity: sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==}
|
||||
dev: true
|
||||
|
||||
/front-matter@4.0.2:
|
||||
resolution: {integrity: sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg==}
|
||||
dependencies:
|
||||
js-yaml: 3.14.1
|
||||
dev: false
|
||||
|
||||
/fs-constants@1.0.0:
|
||||
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
|
||||
dev: true
|
||||
|
||||
@ -105,7 +105,10 @@ description: "${command.description}"
|
||||
(name) =>
|
||||
!sharedCommands.includes(name) &&
|
||||
!hiddenCommands.includes(name) &&
|
||||
nxCommands[name].description
|
||||
// These are all supported yargs fields for description
|
||||
(nxCommands[name].description ||
|
||||
nxCommands[name].describe ||
|
||||
nxCommands[name].desc)
|
||||
)
|
||||
.map((name) => parseCommand(name, nxCommands[name]))
|
||||
.map(async (command) => generateMarkdown(await command))
|
||||
|
||||
@ -3,6 +3,7 @@ import { bold, h, lines as mdLines, strikethrough } from 'markdown-factory';
|
||||
import { join } from 'path';
|
||||
import { format, resolveConfig } from 'prettier';
|
||||
import { MenuItem } from '@nx/nx-dev/models-menu';
|
||||
import yargs, { CommandModule } from 'yargs';
|
||||
|
||||
const stripAnsi = require('strip-ansi');
|
||||
const importFresh = require('import-fresh');
|
||||
@ -198,10 +199,16 @@ export async function parseCommand(
|
||||
return acc;
|
||||
}, {});
|
||||
const subcommands = await Promise.all(
|
||||
Object.entries(getCommands(builder)).map(
|
||||
([subCommandName, subCommandConfig]) =>
|
||||
Object.entries(getCommands(builder))
|
||||
.filter(([, subCommandConfig]) => {
|
||||
const c = subCommandConfig as CommandModule;
|
||||
// These are all supported yargs fields for description, even though the types don't reflect that
|
||||
// @ts-ignore
|
||||
return c.description || c.describe || c.desc;
|
||||
})
|
||||
.map(([subCommandName, subCommandConfig]) =>
|
||||
parseCommand(subCommandName, subCommandConfig)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user