fix(release): release plan command should take filters and touched projects into account (#27706)

This commit is contained in:
Austin Fahsl 2024-08-30 07:38:02 -06:00 committed by GitHub
parent 83a387a105
commit 4c39ad76bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 677 additions and 176 deletions

View File

@ -144,7 +144,9 @@ describe('nx release version plans check command', () => {
});
// Create a version plan
runCLI(`release plan minor --message "This is an awesome change"`);
runCLI(
`release plan minor --message "This is an awesome change" --only-touched=false`
);
// it should show information about the pending bumps and print a success message
expect(runCLI('release plan:check')).toMatchInlineSnapshot(`
@ -318,7 +320,7 @@ describe('nx release version plans check command', () => {
// create a version plan which references fixed-group directly by name
runCLI(
`release plan patch --message "A change for fixed-group" -g fixed-group`
`release plan patch --message "A change for fixed-group" -g fixed-group --only-touched=false`
);
// it should provide logs about the pending bump for fixed-group

View File

@ -0,0 +1,301 @@
import { NxJsonConfiguration } from '@nx/devkit';
import {
cleanupProject,
newProject,
runCLI,
runCommandAsync,
uniq,
updateJson,
} from '@nx/e2e/utils';
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(/version-plan-\d*.md/g, 'version-plan-XXX.md')
.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 only touched', () => {
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 noChangedProjectsResult = runCLI(
'release plan minor -m "Should not happen due to no changed projects" --verbose',
{
silenceError: true,
}
);
expect(noChangedProjectsResult).toMatchInlineSnapshot(`
NX Affected criteria defaulted to --base=main --head=HEAD
NX Changed files based on resolved "base" ({SHASUM}) and "head" (HEAD)
- nx.json
NX No touched projects found based on changed files under release group "fixed-group"
NX No touched projects found based on changed files under release group "independent-group"
NX No version bumps were selected so no version plan file was created.
This might be because no projects have been changed, or projects you expected to release have not been touched
To include all projects, not just those that have been changed, pass --only-touched=false
Alternatively, you can specify alternate --base and --head refs to include only changes from certain commits
`);
await runCommandAsync(`touch ${pkg1}/test.txt`);
const changedProjectsResult = runCLI(
'release plan minor -m "Should happen due to changed project 1" --verbose'
);
expect(changedProjectsResult).toMatchInlineSnapshot(`
NX Affected criteria defaulted to --base=main --head=HEAD
NX Changed files based on resolved "base" ({SHASUM}) and "head" (HEAD)
- nx.json
- {project-name}/test.txt
NX Touched projects based on changed files under release group "fixed-group"
- {project-name}
NOTE: You can adjust your "versionPlans.ignorePatternsForPlanCheck" config to stop certain files from resulting in projects being classed as touched for the purposes of this command.
NX No touched projects found based on changed files under release group "independent-group"
NX Creating version plan file "version-plan-XXX.md"
+ ---
+ fixed-group: minor
+ ---
+
+ Should happen due to changed project 1
+
`);
await runCommandAsync(`git checkout -b branch1`);
await runCommandAsync(`git checkout -b branch2`);
await runCommandAsync(`git add ${pkg1}/test.txt`);
await runCommandAsync(`git commit -m "chore: update pkg1"`);
await runCommandAsync(`git checkout -b branch3`);
await runCommandAsync(`touch ${pkg3}/test.txt`);
await runCommandAsync(`git add ${pkg3}/test.txt`);
await runCommandAsync(`git commit -m "chore: update pkg3"`);
const changedProjectsResult2 = runCLI(
'release plan minor -m "Should happen due to changed project 1 and 3" --verbose'
);
expect(changedProjectsResult2).toMatchInlineSnapshot(`
NX Affected criteria defaulted to --base=main --head=HEAD
NX Changed files based on resolved "base" ({SHASUM}) and "head" (HEAD)
- {project-name}/test.txt
- {project-name}/test.txt
- nx.json
- .nx/version-plans/version-plan-XXX.md
NX Touched projects based on changed files under release group "fixed-group"
- {project-name}
NOTE: You can adjust your "versionPlans.ignorePatternsForPlanCheck" config to stop certain files from resulting in projects being classed as touched for the purposes of this command.
NX Touched projects based on changed files under release group "independent-group"
- {project-name}
NOTE: You can adjust your "versionPlans.ignorePatternsForPlanCheck" config to stop certain files from resulting in projects being classed as touched for the purposes of this command.
NX Creating version plan file "version-plan-XXX.md"
+ ---
+ fixed-group: minor
+ {project-name}: minor
+ ---
+
+ Should happen due to changed project 1 and 3
+
`);
const changedProjectsResult3 = runCLI(
`release plan minor -m "Should happen due to changed project 3 only" --verbose --base=branch2`
);
expect(changedProjectsResult3).toMatchInlineSnapshot(`
NX Changed files based on resolved "base" ({SHASUM}) and "head" (HEAD)
- {project-name}/test.txt
- nx.json
- .nx/version-plans/version-plan-XXX.md
- .nx/version-plans/version-plan-XXX.md
NX No touched projects found based on changed files under release group "fixed-group"
NX Touched projects based on changed files under release group "independent-group"
- {project-name}
NOTE: You can adjust your "versionPlans.ignorePatternsForPlanCheck" config to stop certain files from resulting in projects being classed as touched for the purposes of this command.
NX Creating version plan file "version-plan-XXX.md"
+ ---
+ {project-name}: minor
+ ---
+
+ Should happen due to changed project 3 only
+
`);
const changedProjectsResult4 = runCLI(
`release plan minor -m "Should happen due to changed project 1 only" --verbose --base=branch1 --head=branch2`
);
expect(changedProjectsResult4).toMatchInlineSnapshot(`
NX Changed files based on resolved "base" ({SHASUM}) and "head" (branch2)
- {project-name}/test.txt
NX Touched projects based on changed files under release group "fixed-group"
- {project-name}
NOTE: You can adjust your "versionPlans.ignorePatternsForPlanCheck" config to stop certain files from resulting in projects being classed as touched for the purposes of this command.
NX No touched projects found based on changed files under release group "independent-group"
NX Creating version plan file "version-plan-XXX.md"
+ ---
+ fixed-group: minor
+ ---
+
+ Should happen due to changed project 1 only
+
`);
});
});

View File

@ -111,10 +111,9 @@ describe('nx release version plans', () => {
});
const versionPlansDir = tmpProjPath('.nx/version-plans');
await ensureDir(versionPlansDir);
runCLI(
'release plan minor -g fixed-group -m "Update the fixed packages with a minor release." --verbose',
'release plan minor -g fixed-group -m "Update the fixed packages with a minor release." --verbose --only-touched=false',
{
silenceError: true,
}
@ -713,7 +712,7 @@ Update packages in both groups with a mix #2
await ensureDir(versionPlansDir);
runCLI(
'release plan minor -m "Update the fixed packages with a minor release." --verbose',
'release plan minor -m "Update the fixed packages with a minor release." --verbose --only-touched=false',
{
silenceError: true,
}
@ -796,14 +795,14 @@ Update packages in both groups with a mix #2
});
runCLI(
'release plan minor -g fixed-group -m "Update the fixed packages with another minor release." --verbose',
'release plan minor -g fixed-group -m "Update the fixed packages with another minor release." --verbose --only-touched=false',
{
silenceError: true,
}
);
runCLI(
'release plan minor -g independent-group -m "Update the independent packages with another minor release." --verbose',
'release plan minor -g independent-group -m "Update the independent packages with another minor release." --verbose --only-touched=false',
{
silenceError: true,
}

View File

@ -919,7 +919,18 @@ async function applyChangesAndExit(
releaseGroups.forEach((group) => {
if (group.resolvedVersionPlans) {
group.resolvedVersionPlans.forEach((plan) => {
removeSync(plan.absolutePath);
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);
});
}

View File

@ -70,6 +70,7 @@ export type PublishOptions = NxReleaseArgs &
export type PlanOptions = NxReleaseArgs & {
bump?: string;
message?: string;
onlyTouched?: boolean;
};
export type PlanCheckOptions = BaseNxReleaseArgs & {
@ -353,7 +354,7 @@ const planCommand: CommandModule<NxReleaseArgs, PlanOptions> = {
// Hidden for now until the feature is more stable
describe: false,
builder: (yargs) =>
yargs
withAffectedOptions(yargs)
.positional('bump', {
type: 'string',
describe: 'Semver keyword to use for the selected release group.',
@ -371,6 +372,12 @@ const planCommand: CommandModule<NxReleaseArgs, PlanOptions> = {
type: 'string',
alias: 'm',
describe: 'Custom message to use for the changelog entry',
})
.option('onlyTouched', {
type: 'boolean',
describe:
'Only include projects that have been affected by the current changes',
default: true,
}),
handler: async (args) => {
const release = await import('./plan');

View File

@ -1,14 +1,11 @@
import { NxReleaseConfiguration, readNxJson } from '../../config/nx-json';
import { getTouchedProjects } from '../../project-graph/affected/locators/workspace-projects';
import { createProjectFileMapUsingProjectGraph } from '../../project-graph/file-map-utils';
import { calculateFileChanges } from '../../project-graph/file-utils';
import { createProjectGraphAsync } from '../../project-graph/project-graph';
import { allFileData } from '../../utils/all-file-data';
import {
parseFiles,
splitArgsIntoNxArgsAndOverrides,
} from '../../utils/command-line-utils';
import { getIgnoreObject } from '../../utils/ignore';
import { output } from '../../utils/output';
import { handleErrors } from '../../utils/params';
import { PlanCheckOptions, PlanOptions } from './command-object';
@ -23,6 +20,7 @@ import {
readRawVersionPlans,
setResolvedVersionPlansOnGroups,
} from './config/version-plans';
import { createGetTouchedProjectsForGroup } from './utils/get-touched-projects-for-group';
import { printConfigAndExit } from './utils/print-config';
export const releasePlanCheckCLIHandler = (args: PlanCheckOptions) =>
@ -55,15 +53,10 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) {
});
}
const {
error: filterError,
releaseGroups,
releaseGroupToFilteredProjects,
} = filterReleaseGroups(
// No filtering is applied here, as we want to consider all release groups for plan:check
const { error: filterError, releaseGroups } = filterReleaseGroups(
projectGraph,
nxReleaseConfig,
args.projects,
args.groups
nxReleaseConfig
);
if (filterError) {
output.error(filterError);
@ -116,14 +109,12 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) {
}
const resolvedAllFileData = await allFileData();
/**
* Create a minimal subset of touched projects based on the configured ignore patterns, we only need
* to recompute when the ignorePatternsForPlanCheck differs between release groups.
*/
const serializedIgnorePatternsToTouchedProjects = new Map<
string,
Record<string, true> // project names -> true for O(N) lookup later
>();
const getTouchedProjectsForGroup = createGetTouchedProjectsForGroup(
nxArgs,
projectGraph,
changedFiles,
resolvedAllFileData
);
const NOTE_ABOUT_VERBOSE_LOGGING =
'Run with --verbose to see the full list of changed files used for the touched projects logic.';
@ -158,97 +149,12 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) {
continue;
}
// Exclude patterns from .nxignore, .gitignore and explicit version plan config
let serializedIgnorePatterns = '[]';
const ignore = getIgnoreObject();
if (
typeof releaseGroup.versionPlans !== 'boolean' &&
Array.isArray(releaseGroup.versionPlans.ignorePatternsForPlanCheck) &&
releaseGroup.versionPlans.ignorePatternsForPlanCheck.length
) {
output.note({
title: `Applying configured ignore patterns to changed files${
releaseGroup.name !== IMPLICIT_DEFAULT_RELEASE_GROUP
? ` for release group "${releaseGroup.name}"`
: ''
}`,
bodyLines: [
...releaseGroup.versionPlans.ignorePatternsForPlanCheck.map(
(pattern) => ` - ${pattern}`
),
],
});
ignore.add(releaseGroup.versionPlans.ignorePatternsForPlanCheck);
serializedIgnorePatterns = JSON.stringify(
releaseGroup.versionPlans.ignorePatternsForPlanCheck
);
}
let touchedProjects = {};
if (
serializedIgnorePatternsToTouchedProjects.has(serializedIgnorePatterns)
) {
touchedProjects = serializedIgnorePatternsToTouchedProjects.get(
serializedIgnorePatterns
);
} else {
// We only care about directly touched projects, not implicitly affected ones etc
const touchedProjectsArr = await getTouchedProjects(
calculateFileChanges(
changedFiles,
resolvedAllFileData,
nxArgs,
undefined,
ignore
),
projectGraph.nodes
);
touchedProjects = touchedProjectsArr.reduce(
(acc, project) => ({ ...acc, [project]: true }),
{}
);
serializedIgnorePatternsToTouchedProjects.set(
serializedIgnorePatterns,
touchedProjects
);
}
const touchedProjectsUnderReleaseGroup = releaseGroup.projects.filter(
(project) => touchedProjects[project]
const touchedProjectsUnderReleaseGroup = await getTouchedProjectsForGroup(
releaseGroup,
// We do not take any --projects or --groups filtering into account for plan:check
releaseGroup.projects,
false
);
if (touchedProjectsUnderReleaseGroup.length) {
output.log({
title: `Touched projects based on changed files${
releaseGroup.name !== IMPLICIT_DEFAULT_RELEASE_GROUP
? ` under release group "${releaseGroup.name}"`
: ''
}`,
bodyLines: [
...touchedProjectsUnderReleaseGroup.map(
(project) => ` - ${project}`
),
'',
'NOTE: You can adjust your "versionPlans.ignorePatternsForPlanCheck" config to stop certain files from resulting in projects being classed as touched for the purposes of this command.',
],
});
} else {
output.log({
title: `No touched projects found based on changed files${
typeof releaseGroup.versionPlans !== 'boolean' &&
Array.isArray(
releaseGroup.versionPlans.ignorePatternsForPlanCheck
) &&
releaseGroup.versionPlans.ignorePatternsForPlanCheck.length
? ' combined with configured ignore patterns'
: ''
}${
releaseGroup.name !== IMPLICIT_DEFAULT_RELEASE_GROUP
? ` under release group "${releaseGroup.name}"`
: ''
}`,
});
}
const projectsInResolvedVersionPlans: Record<
string,

View File

@ -6,18 +6,24 @@ import { dirSync } from 'tmp';
import { NxReleaseConfiguration, readNxJson } from '../../config/nx-json';
import { createProjectFileMapUsingProjectGraph } from '../../project-graph/file-map-utils';
import { createProjectGraphAsync } from '../../project-graph/project-graph';
import { allFileData } from '../../utils/all-file-data';
import {
parseFiles,
splitArgsIntoNxArgsAndOverrides,
} from '../../utils/command-line-utils';
import { output } from '../../utils/output';
import { handleErrors } from '../../utils/params';
import { PlanOptions } from './command-object';
import {
IMPLICIT_DEFAULT_RELEASE_GROUP,
createNxReleaseConfig,
handleNxReleaseConfigError,
IMPLICIT_DEFAULT_RELEASE_GROUP,
} from './config/config';
import { deepMergeJson } from './config/deep-merge-json';
import { filterReleaseGroups } from './config/filter-release-groups';
import { getVersionPlansAbsolutePath } from './config/version-plans';
import { generateVersionPlanContent } from './utils/generate-version-plan-content';
import { createGetTouchedProjectsForGroup } from './utils/get-touched-projects-for-group';
import { launchEditor } from './utils/launch-editor';
import { printDiff } from './utils/print-changes';
import { printConfigAndExit } from './utils/print-config';
@ -69,6 +75,36 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) {
process.exit(1);
}
// If no release groups have version plans enabled, it doesn't make sense to use the plan command only to set yourself up for an error at release time
if (!releaseGroups.some((group) => group.versionPlans === true)) {
if (releaseGroups.length === 1) {
output.warn({
title: `Version plans are not enabled in your release configuration`,
bodyLines: [
'To enable version plans, set `"versionPlans": true` at the top level of your `"release"` configuration',
],
});
return 0;
}
output.warn({
title: 'No release groups have version plans enabled',
bodyLines: [
'To enable version plans, set `"versionPlans": true` at the top level of your `"release"` configuration to apply it to all groups, otherwise set it at the release group level',
],
});
return 0;
}
// Resolve the final values for base, head etc to use when resolving the changes to consider
const { nxArgs } = splitArgsIntoNxArgsAndOverrides(
args,
'affected',
{
printWarnings: args.verbose,
},
nxJson
);
const versionPlanBumps: Record<string, string> = {};
const setBumpIfNotNone = (projectOrGroup: string, version: string) => {
if (version !== 'none') {
@ -76,92 +112,201 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) {
}
};
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}"?`
))
// Changed files are only relevant if considering touched projects
let changedFiles: string[] = [];
let getProjectsToVersionForGroup:
| ReturnType<typeof createGetTouchedProjectsForGroup>
| undefined;
if (args.onlyTouched) {
changedFiles = parseFiles(nxArgs).files;
if (nxArgs.verbose) {
if (changedFiles.length) {
output.log({
title: `Changed files based on resolved "base" (${
nxArgs.base
}) and "head" (${nxArgs.head ?? 'HEAD'})`,
bodyLines: changedFiles.map((file) => ` - ${file}`),
});
} else {
output.warn({
title: 'No changed files found based on resolved "base" and "head"',
});
}
}
const resolvedAllFileData = await allFileData();
getProjectsToVersionForGroup = createGetTouchedProjectsForGroup(
nxArgs,
projectGraph,
changedFiles,
resolvedAllFileData
);
}
if (args.projects?.length) {
/**
* Run plan for all remaining release groups and filtered projects within them
*/
for (const releaseGroup of releaseGroups) {
const releaseGroupName = releaseGroup.name;
const releaseGroupProjectNames = Array.from(
releaseGroupToFilteredProjects.get(releaseGroup)
);
let applicableProjects = releaseGroupProjectNames;
if (
args.onlyTouched &&
typeof getProjectsToVersionForGroup === 'function'
) {
applicableProjects = await getProjectsToVersionForGroup(
releaseGroup,
releaseGroupProjectNames,
true
);
}
} 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)) {
if (!applicableProjects.length) {
continue;
}
if (releaseGroup.projectsRelationship === 'independent') {
for (const project of applicableProjects) {
setBumpIfNotNone(
project,
args.bump ||
(await promptForVersion(
`How do you want to bump the version of the project "${project}" within group "${group.name}"?`
`How do you want to bump the version of the project "${project}"${
releaseGroupName === IMPLICIT_DEFAULT_RELEASE_GROUP
? ''
: ` within group "${releaseGroupName}"`
}?`
))
);
}
} else {
setBumpIfNotNone(
group.name,
releaseGroupName,
args.bump ||
(await promptForVersion(
`How do you want to bump the versions of the projects in the group "${group.name}"?`
`How do you want to bump the versions of ${
releaseGroupName === IMPLICIT_DEFAULT_RELEASE_GROUP
? 'all projects'
: `the projects in the group "${releaseGroupName}"`
}?`
))
);
}
}
}
if (!Object.keys(versionPlanBumps).length) {
output.warn({
title:
'No version bumps were selected so no version plan file was created.',
});
// Create a version plan file if applicable
await createVersionPlanFileForBumps(args, versionPlanBumps);
return 0;
}
const versionPlanName = `version-plan-${new Date().getTime()}`;
const versionPlanMessage =
args.message || (await promptForMessage(versionPlanName));
const versionPlanFileContent = generateVersionPlanContent(
versionPlanBumps,
versionPlanMessage
);
const versionPlanFileName = `${versionPlanName}.md`;
/**
* Run plan for all remaining release groups
*/
for (const releaseGroup of releaseGroups) {
const releaseGroupName = releaseGroup.name;
let applicableProjects = releaseGroup.projects;
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);
if (
args.onlyTouched &&
typeof getProjectsToVersionForGroup === 'function'
) {
applicableProjects = await getProjectsToVersionForGroup(
releaseGroup,
releaseGroup.projects,
false
);
}
const versionPlansAbsolutePath = getVersionPlansAbsolutePath();
await ensureDir(versionPlansAbsolutePath);
await writeFile(
join(versionPlansAbsolutePath, versionPlanFileName),
versionPlanFileContent
);
if (!applicableProjects.length) {
continue;
}
if (releaseGroup.projectsRelationship === 'independent') {
for (const project of applicableProjects) {
setBumpIfNotNone(
project,
args.bump ||
(await promptForVersion(
`How do you want to bump the version of the project "${project}"${
releaseGroupName === IMPLICIT_DEFAULT_RELEASE_GROUP
? ''
: ` within group "${releaseGroupName}"`
}?`
))
);
}
} else {
setBumpIfNotNone(
releaseGroupName,
args.bump ||
(await promptForVersion(
`How do you want to bump the versions of ${
releaseGroupName === IMPLICIT_DEFAULT_RELEASE_GROUP
? 'all projects'
: `the projects in the group "${releaseGroupName}"`
}?`
))
);
}
}
// Create a version plan file if applicable
await createVersionPlanFileForBumps(args, versionPlanBumps);
return 0;
};
}
async function createVersionPlanFileForBumps(
args: PlanOptions,
versionPlanBumps: Record<string, string>
) {
if (!Object.keys(versionPlanBumps).length) {
let bodyLines: string[] = [];
if (args.onlyTouched) {
bodyLines = [
'This might be because no projects have been changed, or projects you expected to release have not been touched',
'To include all projects, not just those that have been changed, pass --only-touched=false',
'Alternatively, you can specify alternate --base and --head refs to include only changes from certain commits',
];
}
output.warn({
title:
'No version bumps were selected so no version plan file was created.',
bodyLines,
});
return 0;
}
const versionPlanName = `version-plan-${new Date().getTime()}`;
const versionPlanMessage =
args.message || (await promptForMessage(versionPlanName));
const versionPlanFileContent = generateVersionPlanContent(
versionPlanBumps,
versionPlanMessage
);
const versionPlanFileName = `${versionPlanName}.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
);
}
}
async function promptForVersion(message: string): Promise<string> {
try {
const reply = await prompt<{ version: string }>([

View File

@ -0,0 +1,130 @@
import { FileData, ProjectGraph } from '../../../config/project-graph';
import { getTouchedProjects } from '../../../project-graph/affected/locators/workspace-projects';
import { calculateFileChanges } from '../../../project-graph/file-utils';
import { NxArgs } from '../../../utils/command-line-utils';
import { getIgnoreObject } from '../../../utils/ignore';
import { output } from '../../../utils/output';
import { IMPLICIT_DEFAULT_RELEASE_GROUP } from '../config/config';
import { ReleaseGroupWithName } from '../config/filter-release-groups';
/**
* Create a function that returns the touched projects for a given release group. Only relevant when version plans are enabled.
*/
export function createGetTouchedProjectsForGroup(
nxArgs: NxArgs,
projectGraph: ProjectGraph,
changedFiles: string[],
fileData: FileData[]
) {
/**
* Create a minimal subset of touched projects based on the configured ignore patterns, we only need
* to recompute when the ignorePatternsForPlanCheck differs between release groups.
*/
const serializedIgnorePatternsToTouchedProjects = new Map<
string,
Record<string, true> // project names -> true for O(N) lookup later
>();
return async function getTouchedProjectsForGroup(
releaseGroup: ReleaseGroupWithName,
// We don't access releaseGroups.projects directly, because we need to take the --projects filter into account
releaseGroupFilteredProjectNames: string[],
hasProjectsFilter: boolean
): Promise<string[]> {
// The current release group doesn't leverage version plans
if (!releaseGroup.versionPlans) {
return [];
}
// Exclude patterns from .nxignore, .gitignore and explicit version plan config
let serializedIgnorePatterns = '[]';
const ignore = getIgnoreObject();
if (
typeof releaseGroup.versionPlans !== 'boolean' &&
Array.isArray(releaseGroup.versionPlans.ignorePatternsForPlanCheck) &&
releaseGroup.versionPlans.ignorePatternsForPlanCheck.length
) {
output.note({
title: `Applying configured ignore patterns to changed files${
releaseGroup.name !== IMPLICIT_DEFAULT_RELEASE_GROUP
? ` for release group "${releaseGroup.name}"`
: ''
}`,
bodyLines: [
...releaseGroup.versionPlans.ignorePatternsForPlanCheck.map(
(pattern) => ` - ${pattern}`
),
],
});
ignore.add(releaseGroup.versionPlans.ignorePatternsForPlanCheck);
serializedIgnorePatterns = JSON.stringify(
releaseGroup.versionPlans.ignorePatternsForPlanCheck
);
}
let touchedProjects = {};
if (
serializedIgnorePatternsToTouchedProjects.has(serializedIgnorePatterns)
) {
touchedProjects = serializedIgnorePatternsToTouchedProjects.get(
serializedIgnorePatterns
);
} else {
// We only care about directly touched projects, not implicitly affected ones etc
const touchedProjectsArr = await getTouchedProjects(
calculateFileChanges(changedFiles, fileData, nxArgs, undefined, ignore),
projectGraph.nodes
);
touchedProjects = touchedProjectsArr.reduce(
(acc, project) => ({ ...acc, [project]: true }),
{}
);
serializedIgnorePatternsToTouchedProjects.set(
serializedIgnorePatterns,
touchedProjects
);
}
const touchedProjectsUnderReleaseGroup =
releaseGroupFilteredProjectNames.filter(
(project) => touchedProjects[project]
);
if (touchedProjectsUnderReleaseGroup.length) {
output.log({
title: `Touched projects${
hasProjectsFilter ? ` (after --projects filter applied)` : ''
} based on changed files${
releaseGroup.name !== IMPLICIT_DEFAULT_RELEASE_GROUP
? ` under release group "${releaseGroup.name}"`
: ''
}`,
bodyLines: [
...touchedProjectsUnderReleaseGroup.map(
(project) => ` - ${project}`
),
'',
'NOTE: You can adjust your "versionPlans.ignorePatternsForPlanCheck" config to stop certain files from resulting in projects being classed as touched for the purposes of this command.',
],
});
} else {
output.log({
title: `No touched projects${
hasProjectsFilter ? ` (after --projects filter applied)` : ''
} found based on changed files${
typeof releaseGroup.versionPlans !== 'boolean' &&
Array.isArray(releaseGroup.versionPlans.ignorePatternsForPlanCheck) &&
releaseGroup.versionPlans.ignorePatternsForPlanCheck.length
? ' combined with configured ignore patterns'
: ''
}${
releaseGroup.name !== IMPLICIT_DEFAULT_RELEASE_GROUP
? ` under release group "${releaseGroup.name}"`
: ''
}`,
});
}
return touchedProjectsUnderReleaseGroup;
};
}