diff --git a/docs/generated/cli/release.md b/docs/generated/cli/release.md index d034dc0a2e73b..ff47254e838d8 100644 --- a/docs/generated/cli/release.md +++ b/docs/generated/cli/release.md @@ -37,6 +37,12 @@ Type: `boolean` Show help +### printConfig + +Type: `string` + +Print the resolved nx release configuration that would be used for the current command and then exit + ### projects Type: `string` diff --git a/docs/generated/packages/nx/documents/release.md b/docs/generated/packages/nx/documents/release.md index d034dc0a2e73b..ff47254e838d8 100644 --- a/docs/generated/packages/nx/documents/release.md +++ b/docs/generated/packages/nx/documents/release.md @@ -37,6 +37,12 @@ Type: `boolean` Show help +### printConfig + +Type: `string` + +Print the resolved nx release configuration that would be used for the current command and then exit + ### projects Type: `string` diff --git a/packages/nx/release/index.ts b/packages/nx/release/index.ts index b449fbbf8defc..43a3c6072e3e6 100644 --- a/packages/nx/release/index.ts +++ b/packages/nx/release/index.ts @@ -2,6 +2,7 @@ * @public Programmatic API for nx release */ export { + ReleaseClient, release, releaseChangelog, releasePublish, diff --git a/packages/nx/src/command-line/release/changelog.ts b/packages/nx/src/command-line/release/changelog.ts index 967b0e162a6f5..e2c79396bec92 100644 --- a/packages/nx/src/command-line/release/changelog.ts +++ b/packages/nx/src/command-line/release/changelog.ts @@ -7,6 +7,7 @@ import { dirSync } from 'tmp'; import type { DependencyBump } from '../../../release/changelog-renderer'; import { NxReleaseChangelogConfiguration, + NxReleaseConfiguration, readNxJson, } from '../../config/nx-json'; import { @@ -33,6 +34,7 @@ import { createNxReleaseConfig, handleNxReleaseConfigError, } from './config/config'; +import { deepMergeJson } from './config/deep-merge-json'; import { ReleaseGroupWithName, filterReleaseGroups, @@ -60,6 +62,7 @@ import { createOrUpdateGithubRelease, getGitHubRepoSlug } from './utils/github'; import { launchEditor } from './utils/launch-editor'; import { parseChangelogMarkdown } from './utils/markdown'; import { printAndFlushChanges } from './utils/print-changes'; +import { printConfigAndExit } from './utils/print-config'; import { resolveChangelogRenderer } from './utils/resolve-changelog-renderer'; import { resolveNxJsonConfigErrorMessage } from './utils/resolve-nx-json-error-message'; import { @@ -101,373 +104,541 @@ export interface ChangelogChange { type PostGitTask = (latestCommit: string) => Promise; export const releaseChangelogCLIHandler = (args: ChangelogOptions) => - handleErrors(args.verbose, () => releaseChangelog(args)); - -/** - * NOTE: This function is also exported for programmatic usage and forms part of the public API - * of Nx. We intentionally do not wrap the implementation with handleErrors because users need - * to have control over their own error handling when using the API. - */ -export async function releaseChangelog( - args: ChangelogOptions -): Promise { - 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); - } + handleErrors(args.verbose, () => createAPI({})(args)); - // The nx release top level command will always override these three git args. This is how we can tell - // if the top level release command was used or if the user is using the changelog subcommand. - // If the user explicitly overrides these args, then it doesn't matter if the top level config is set, - // as all of the git options would be overridden anyway. - if ( - (args.gitCommit === undefined || - args.gitTag === undefined || - args.stageChanges === undefined) && - nxJson.release?.git - ) { - const nxJsonMessage = await resolveNxJsonConfigErrorMessage([ - 'release', - 'git', - ]); - output.error({ - title: `The "release.git" property in nx.json may not be used with the "nx release changelog" subcommand or programmatic API. Instead, configure git options for subcommands directly with "release.version.git" and "release.changelog.git".`, - bodyLines: [nxJsonMessage], - }); - process.exit(1); - } - - const { - error: filterError, - releaseGroups, - releaseGroupToFilteredProjects, - } = filterReleaseGroups( - projectGraph, - nxReleaseConfig, - args.projects, - args.groups - ); - if (filterError) { - 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; - } +export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { + /** + * NOTE: This function is also exported for programmatic usage and forms part of the public API + * of Nx. We intentionally do not wrap the implementation with handleErrors because users need + * to have control over their own error handling when using the API. + */ + return async function releaseChangelog( + args: ChangelogOptions + ): Promise { + const projectGraph = await createProjectGraphAsync({ exitOnError: true }); + const nxJson = readNxJson(); + const userProvidedReleaseConfig = deepMergeJson( + nxJson.release ?? {}, + overrideReleaseConfig ?? {} + ); - const changelogGenerationEnabled = - !!nxReleaseConfig.changelog.workspaceChangelog || - Object.values(nxReleaseConfig.groups).some((g) => g.changelog); - if (!changelogGenerationEnabled) { - output.warn({ - title: `Changelogs are disabled. No changelog entries will be generated`, - bodyLines: [ - `To explicitly enable changelog generation, configure "release.changelog.workspaceChangelog" or "release.changelog.projectChangelogs" in nx.json.`, - ], - }); - return {}; - } + if (args.verbose) { + process.env.NX_VERBOSE_LOGGING = 'true'; + } - const tree = new FsTree(workspaceRoot, args.verbose); + // Apply default configuration to any optional user configuration + const { error: configError, nxReleaseConfig } = await createNxReleaseConfig( + projectGraph, + await createProjectFileMapUsingProjectGraph(projectGraph), + userProvidedReleaseConfig + ); + if (configError) { + return await handleNxReleaseConfigError(configError); + } + // --print-config exits directly as it is not designed to be combined with any other programmatic operations + if (args.printConfig) { + return printConfigAndExit({ + userProvidedReleaseConfig, + nxReleaseConfig, + isDebug: args.printConfig === 'debug', + }); + } - const useAutomaticFromRef = - nxReleaseConfig.changelog?.automaticFromRef || args.firstRelease; + // The nx release top level command will always override these three git args. This is how we can tell + // if the top level release command was used or if the user is using the changelog subcommand. + // If the user explicitly overrides these args, then it doesn't matter if the top level config is set, + // as all of the git options would be overridden anyway. + if ( + (args.gitCommit === undefined || + args.gitTag === undefined || + args.stageChanges === undefined) && + userProvidedReleaseConfig.git + ) { + const nxJsonMessage = await resolveNxJsonConfigErrorMessage([ + 'release', + 'git', + ]); + output.error({ + title: `The "release.git" property in nx.json may not be used with the "nx release changelog" subcommand or programmatic API. Instead, configure git options for subcommands directly with "release.version.git" and "release.changelog.git".`, + bodyLines: [nxJsonMessage], + }); + process.exit(1); + } - /** - * For determining the versions to use within changelog files, there are a few different possibilities: - * - the user is using the nx CLI, and therefore passes a single --version argument which represents the version for any and all changelog - * files which will be generated (i.e. both the workspace changelog, and all project changelogs, depending on which of those has been enabled) - * - the user is using the nxReleaseChangelog API programmatically, and: - * - passes only a version property - * - this works in the same way as described above for the CLI - * - passes only a versionData object - * - this is a special case where the user is providing a version for each project, and therefore the version argument is not needed - * - NOTE: it is not possible to generate a workspace level changelog with only a versionData object, and this will produce an error - * - passes both a version and a versionData object - * - in this case, the version property will be used as the reference for the workspace changelog, and the versionData object will be used - * to generate project changelogs - */ - const { workspaceChangelogVersion, projectsVersionData } = - resolveChangelogVersions( - args, + const { + error: filterError, + releaseGroups, + releaseGroupToFilteredProjects, + } = filterReleaseGroups( + projectGraph, + nxReleaseConfig, + args.projects, + args.groups + ); + if (filterError) { + output.error(filterError); + process.exit(1); + } + const rawVersionPlans = await readRawVersionPlans(); + setVersionPlansOnGroups( + rawVersionPlans, releaseGroups, - releaseGroupToFilteredProjects + Object.keys(projectGraph.nodes) ); - const to = args.to || 'HEAD'; - const toSHA = await getCommitHash(to); - const headSHA = to === 'HEAD' ? toSHA : await getCommitHash('HEAD'); + if (args.deleteVersionPlans === undefined) { + // default to deleting version plans in this command instead of after versioning + args.deleteVersionPlans = true; + } - /** - * Protect the user against attempting to create a new commit when recreating an old release changelog, - * this seems like it would always be unintentional. - */ - const autoCommitEnabled = - args.gitCommit ?? nxReleaseConfig.changelog.git.commit; - if (autoCommitEnabled && headSHA !== toSHA) { - throw new Error( - `You are attempting to recreate the changelog for an old release, but you have enabled auto-commit mode. Please disable auto-commit mode by updating your nx.json, or passing --git-commit=false` - ); - } + const changelogGenerationEnabled = + !!nxReleaseConfig.changelog.workspaceChangelog || + Object.values(nxReleaseConfig.groups).some((g) => g.changelog); + if (!changelogGenerationEnabled) { + output.warn({ + title: `Changelogs are disabled. No changelog entries will be generated`, + bodyLines: [ + `To explicitly enable changelog generation, configure "release.changelog.workspaceChangelog" or "release.changelog.projectChangelogs" in nx.json.`, + ], + }); + return {}; + } - const commitMessage: string | undefined = - args.gitCommitMessage || nxReleaseConfig.changelog.git.commitMessage; + const tree = new FsTree(workspaceRoot, args.verbose); - const commitMessageValues: string[] = createCommitMessageValues( - releaseGroups, - releaseGroupToFilteredProjects, - projectsVersionData, - commitMessage - ); + const useAutomaticFromRef = + nxReleaseConfig.changelog?.automaticFromRef || args.firstRelease; - // Resolve any git tags as early as possible so that we can hard error in case of any duplicates before reaching the actual git command - const gitTagValues: string[] = - args.gitTag ?? nxReleaseConfig.changelog.git.tag - ? createGitTagValues( - releaseGroups, - releaseGroupToFilteredProjects, - projectsVersionData - ) - : []; - handleDuplicateGitTags(gitTagValues); - - const postGitTasks: PostGitTask[] = []; - - 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; - } + /** + * For determining the versions to use within changelog files, there are a few different possibilities: + * - the user is using the nx CLI, and therefore passes a single --version argument which represents the version for any and all changelog + * files which will be generated (i.e. both the workspace changelog, and all project changelogs, depending on which of those has been enabled) + * - the user is using the nxReleaseChangelog API programmatically, and: + * - passes only a version property + * - this works in the same way as described above for the CLI + * - passes only a versionData object + * - this is a special case where the user is providing a version for each project, and therefore the version argument is not needed + * - NOTE: it is not possible to generate a workspace level changelog with only a versionData object, and this will produce an error + * - passes both a version and a versionData object + * - in this case, the version property will be used as the reference for the workspace changelog, and the versionData object will be used + * to generate project changelogs + */ + const { workspaceChangelogVersion, projectsVersionData } = + resolveChangelogVersions( + args, + releaseGroups, + releaseGroupToFilteredProjects + ); - return { - type: parsedMessage.type, - scope: parsedMessage.scope, - description: parsedMessage.description, - body: '', - isBreaking: parsedMessage.breaking, - githubReferences: [], - }; - }) - .filter(Boolean), - nxReleaseConfig.conventionalCommits - ); - } - } - } 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.` - ); - } + const to = args.to || 'HEAD'; + const toSHA = await getCommitHash(to); + const headSHA = to === 'HEAD' ? toSHA : await getCommitHash('HEAD'); + + /** + * Protect the user against attempting to create a new commit when recreating an old release changelog, + * this seems like it would always be unintentional. + */ + const autoCommitEnabled = + args.gitCommit ?? nxReleaseConfig.changelog.git.commit; + if (autoCommitEnabled && headSHA !== toSHA) { + throw new Error( + `You are attempting to recreate the changelog for an old release, but you have enabled auto-commit mode. Please disable auto-commit mode by updating your nx.json, or passing --git-commit=false` + ); } - // Make sure that the fromRef is actually resolvable - const workspaceChangelogFromSHA = await getCommitHash( - workspaceChangelogFromRef - ); + const commitMessage: string | undefined = + args.gitCommitMessage || nxReleaseConfig.changelog.git.commitMessage; - workspaceChangelogCommits = await getCommits( - workspaceChangelogFromSHA, - toSHA + const commitMessageValues: string[] = createCommitMessageValues( + releaseGroups, + releaseGroupToFilteredProjects, + projectsVersionData, + commitMessage ); - 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 - ); - } + // Resolve any git tags as early as possible so that we can hard error in case of any duplicates before reaching the actual git command + const gitTagValues: string[] = + args.gitTag ?? nxReleaseConfig.changelog.git.tag + ? createGitTagValues( + releaseGroups, + releaseGroupToFilteredProjects, + projectsVersionData + ) + : []; + handleDuplicateGitTags(gitTagValues); - const workspaceChangelog = await generateChangelogForWorkspace({ - tree, - args, - projectGraph, - nxReleaseConfig, - workspaceChangelogVersion, - changes: workspaceChangelogChanges, - // TODO: remove this after the changelog renderer is refactored to remove coupling with git commits - commits: filterHiddenCommits( - workspaceChangelogCommits, - nxReleaseConfig.conventionalCommits - ), - }); + const postGitTasks: PostGitTask[] = []; - if ( - workspaceChangelog && - shouldCreateGitHubRelease( - nxReleaseConfig.changelog.workspaceChangelog, - args.createRelease - ) - ) { - let hasPushed = false; + 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 + ); - postGitTasks.push(async (latestCommit) => { - if (!hasPushed) { - output.logSingleLine(`Pushing to git remote`); + // only properly formatted conventional commits messages will be included in the changelog + if (!parsedMessage) { + return null; + } - // Before we can create/update the release we need to ensure the commit exists on the remote - await gitPush({ - gitRemote: args.gitRemote, - dryRun: args.dryRun, - verbose: args.verbose, - }); - hasPushed = true; + return { + type: parsedMessage.type, + scope: parsedMessage.scope, + description: parsedMessage.description, + body: '', + isBreaking: parsedMessage.breaking, + githubReferences: [], + }; + }) + .filter(Boolean), + nxReleaseConfig.conventionalCommits + ); + } + } + } 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.` + ); + } } - output.logSingleLine(`Creating GitHub Release`); + // Make sure that the fromRef is actually resolvable + const workspaceChangelogFromSHA = await getCommitHash( + workspaceChangelogFromRef + ); + + workspaceChangelogCommits = await getCommits( + workspaceChangelogFromSHA, + toSHA + ); - await createOrUpdateGithubRelease( - workspaceChangelog.releaseVersion, - workspaceChangelog.contents, - latestCommit, - { dryRun: args.dryRun } + 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 ); + } + + const workspaceChangelog = await generateChangelogForWorkspace({ + tree, + args, + projectGraph, + nxReleaseConfig, + workspaceChangelogVersion, + changes: workspaceChangelogChanges, + // TODO: remove this after the changelog renderer is refactored to remove coupling with git commits + commits: filterHiddenCommits( + workspaceChangelogCommits, + nxReleaseConfig.conventionalCommits + ), }); - } - /** - * Compute any additional dependency bumps up front because there could be cases of circular dependencies, - * and figuring them out during the main iteration would be too late. - */ - const projectToAdditionalDependencyBumps = new Map< - string, - DependencyBump[] - >(); - for (const releaseGroup of releaseGroups) { - if (releaseGroup.projectsRelationship !== 'independent') { - continue; + if ( + workspaceChangelog && + shouldCreateGitHubRelease( + nxReleaseConfig.changelog.workspaceChangelog, + args.createRelease + ) + ) { + let hasPushed = false; + + postGitTasks.push(async (latestCommit) => { + if (!hasPushed) { + output.logSingleLine(`Pushing to git remote`); + + // Before we can create/update the release we need to ensure the commit exists on the remote + await gitPush({ + gitRemote: args.gitRemote, + dryRun: args.dryRun, + verbose: args.verbose, + }); + hasPushed = true; + } + + output.logSingleLine(`Creating GitHub Release`); + + await createOrUpdateGithubRelease( + workspaceChangelog.releaseVersion, + workspaceChangelog.contents, + latestCommit, + { dryRun: args.dryRun } + ); + }); } - for (const project of releaseGroup.projects) { - // If the project does not have any changes, do not process its dependents - if ( - !projectsVersionData[project] || - projectsVersionData[project].newVersion === null - ) { + + /** + * Compute any additional dependency bumps up front because there could be cases of circular dependencies, + * and figuring them out during the main iteration would be too late. + */ + const projectToAdditionalDependencyBumps = new Map< + string, + DependencyBump[] + >(); + for (const releaseGroup of releaseGroups) { + if (releaseGroup.projectsRelationship !== 'independent') { continue; } + for (const project of releaseGroup.projects) { + // If the project does not have any changes, do not process its dependents + if ( + !projectsVersionData[project] || + projectsVersionData[project].newVersion === null + ) { + continue; + } - const dependentProjects = ( - projectsVersionData[project].dependentProjects || [] - ) - .map((dep) => { - return { - dependencyName: dep.source, - newVersion: projectsVersionData[dep.source].newVersion, - }; - }) - .filter((b) => b.newVersion !== null); - - 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 - ); + const dependentProjects = ( + projectsVersionData[project].dependentProjects || [] + ) + .map((dep) => { + return { + dependencyName: dep.source, + newVersion: projectsVersionData[dep.source].newVersion, + }; + }) + .filter((b) => b.newVersion !== null); + + 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 + ); + } } } - } - const allProjectChangelogs: NxReleaseChangelogResult['projectChangelogs'] = - {}; + const allProjectChangelogs: NxReleaseChangelogResult['projectChangelogs'] = + {}; - for (const releaseGroup of releaseGroups) { - const config = releaseGroup.changelog; - // The entire feature is disabled at the release group level, exit early - if (config === false) { - continue; - } + for (const releaseGroup of releaseGroups) { + const config = releaseGroup.changelog; + // The entire feature is disabled at the release group level, exit early + if (config === false) { + continue; + } + + const projects = args.projects?.length + ? // If the user has passed a list of projects, we need to use the filtered list of projects within the release group, plus any dependents + Array.from(releaseGroupToFilteredProjects.get(releaseGroup)).flatMap( + (project) => { + return [ + project, + ...(projectsVersionData[project]?.dependentProjects.map( + (dep) => dep.source + ) || []), + ]; + } + ) + : // Otherwise, we use the full list of projects within the release group + releaseGroup.projects; + const projectNodes = projects.map((name) => projectGraph.nodes[name]); + + if (releaseGroup.projectsRelationship === 'independent') { + for (const project of projectNodes) { + let changes: ChangelogChange[] | null = null; + // 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 ProjectsVersionPlan[]) + .map((vp) => { + const parsedMessage = parseConventionalCommitsMessage( + vp.message + ); + + // 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, + releaseGroupName: releaseGroup.name, + } + ) + )?.tag; + + 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.` + ); + } + + if (!commits) { + commits = await getCommits(fromRef, toSHA); + } - const projects = args.projects?.length - ? // If the user has passed a list of projects, we need to use the filtered list of projects within the release group, plus any dependents - Array.from(releaseGroupToFilteredProjects.get(releaseGroup)).flatMap( - (project) => { - return [ - project, - ...(projectsVersionData[project]?.dependentProjects.map( - (dep) => dep.source - ) || []), - ]; + 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 + ); } - ) - : // Otherwise, we use the full list of projects within the release group - releaseGroup.projects; - const projectNodes = projects.map((name) => projectGraph.nodes[name]); - if (releaseGroup.projectsRelationship === 'independent') { - for (const project of projectNodes) { - let changes: ChangelogChange[] | null = null; - // TODO: remove this after the changelog renderer is refactored to remove coupling with git commits - let commits: GitCommit[]; + const projectChangelogs = await generateChangelogForProjects({ + tree, + args, + projectGraph, + changes, + projectsVersionData, + releaseGroup, + projects: [project], + nxReleaseConfig, + 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( + projectChangelogs + )) { + if ( + projectChangelogs && + shouldCreateGitHubRelease( + releaseGroup.changelog, + args.createRelease + ) + ) { + postGitTasks.push(async (latestCommit) => { + if (!hasPushed) { + output.logSingleLine(`Pushing to git remote`); + + // Before we can create/update the release we need to ensure the commit exists on the remote + await gitPush({ + gitRemote: args.gitRemote, + dryRun: args.dryRun, + verbose: args.verbose, + }); + hasPushed = true; + } + output.logSingleLine(`Creating GitHub Release`); + + await createOrUpdateGithubRelease( + projectChangelog.releaseVersion, + projectChangelog.contents, + latestCommit, + { dryRun: args.dryRun } + ); + }); + } + allProjectChangelogs[projectName] = projectChangelog; + } + } + } else { + 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 ProjectsVersionPlan[]) + (releaseGroup.versionPlans as GroupVersionPlan[]) .map((vp) => { const parsedMessage = parseConventionalCommitsMessage( vp.message @@ -478,14 +649,14 @@ export async function releaseChangelog( return null; } - return { + return { type: parsedMessage.type, scope: parsedMessage.scope, description: parsedMessage.description, body: '', isBreaking: parsedMessage.breaking, - affectedProjects: Object.keys(vp.projectVersionBumps), githubReferences: [], + affectedProjects: '*', }; }) .filter(Boolean), @@ -494,38 +665,25 @@ export async function releaseChangelog( } else { let fromRef = args.from || - ( - await getLatestGitTagForPattern(releaseGroup.releaseTagPattern, { - projectName: project.name, - releaseGroupName: releaseGroup.name, - }) - )?.tag; - - 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}` + (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.` ); } - 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.` - ); - } - - if (!commits) { - commits = await getCommits(fromRef, toSHA); - } + // Make sure that the fromRef is actually resolvable + const fromSHA = await getCommitHash(fromRef); const { fileMap } = await createFileMapUsingProjectGraph( projectGraph @@ -534,6 +692,7 @@ export async function releaseChangelog( fileMap.projectFileMap ); + commits = await getCommits(fromSHA, toSHA); changes = filterHiddenChanges( commits.map((c) => ({ type: c.type, @@ -563,7 +722,7 @@ export async function releaseChangelog( changes, projectsVersionData, releaseGroup, - projects: [project], + projects: projectNodes, nxReleaseConfig, projectToAdditionalDependencyBumps, // TODO: remove this after the changelog renderer is refactored to remove coupling with git commits @@ -610,150 +769,23 @@ export async function releaseChangelog( allProjectChangelogs[projectName] = projectChangelog; } } - } else { - 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 { - 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.` - ); - } - } - - // 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 - ); - } - - const projectChangelogs = await generateChangelogForProjects({ - tree, - args, - projectGraph, - changes, - projectsVersionData, - releaseGroup, - projects: projectNodes, - nxReleaseConfig, - 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( - projectChangelogs - )) { - if ( - projectChangelogs && - shouldCreateGitHubRelease(releaseGroup.changelog, args.createRelease) - ) { - postGitTasks.push(async (latestCommit) => { - if (!hasPushed) { - output.logSingleLine(`Pushing to git remote`); - - // Before we can create/update the release we need to ensure the commit exists on the remote - await gitPush({ - gitRemote: args.gitRemote, - dryRun: args.dryRun, - verbose: args.verbose, - }); - hasPushed = true; - } - - output.logSingleLine(`Creating GitHub Release`); - - await createOrUpdateGithubRelease( - projectChangelog.releaseVersion, - projectChangelog.contents, - latestCommit, - { dryRun: args.dryRun } - ); - }); - } - allProjectChangelogs[projectName] = projectChangelog; - } } - } - await applyChangesAndExit( - args, - nxReleaseConfig, - tree, - toSHA, - postGitTasks, - commitMessageValues, - gitTagValues, - releaseGroups - ); + await applyChangesAndExit( + args, + nxReleaseConfig, + tree, + toSHA, + postGitTasks, + commitMessageValues, + gitTagValues, + releaseGroups + ); - return { - workspaceChangelog, - projectChangelogs: allProjectChangelogs, + return { + workspaceChangelog, + projectChangelogs: allProjectChangelogs, + }; }; } diff --git a/packages/nx/src/command-line/release/command-object.ts b/packages/nx/src/command-line/release/command-object.ts index 7a66e50bfc151..46d39bb388182 100644 --- a/packages/nx/src/command-line/release/command-object.ts +++ b/packages/nx/src/command-line/release/command-object.ts @@ -17,6 +17,7 @@ export interface NxReleaseArgs { projects?: string[]; dryRun?: boolean; verbose?: boolean; + printConfig?: boolean | 'debug'; } interface GitCommitAndTagOptions { @@ -122,6 +123,21 @@ export const yargsReleaseCommand: CommandModule< describe: 'Prints additional information about the commands (e.g., stack traces)', }) + // NOTE: The camel case format is required for the coerce() function to be called correctly. It still supports --print-config casing. + .option('printConfig', { + type: 'string', + describe: + 'Print the resolved nx release configuration that would be used for the current command and then exit', + coerce: (val: string) => { + if (val === '') { + return true; + } + if (val === 'false') { + return false; + } + return val; + }, + }) .check((argv) => { if (argv.groups && argv.projects) { throw new Error( diff --git a/packages/nx/src/command-line/release/config/deep-merge-json.spec.ts b/packages/nx/src/command-line/release/config/deep-merge-json.spec.ts new file mode 100644 index 0000000000000..35558b2146f3f --- /dev/null +++ b/packages/nx/src/command-line/release/config/deep-merge-json.spec.ts @@ -0,0 +1,55 @@ +import { deepMergeJson } from './deep-merge-json'; + +describe('deepMergeJson()', () => { + it('should merge two JSON objects', () => { + const target = { + a: 1, + b: { + c: 2, + d: { + e: [1, 2, 3, 4, 5, 6], + f: true, + g: false, + h: '', + i: null, + }, + }, + }; + const source = { + a: 3, + b: { + c: 4, + d: { + e: [4, 5, 6, 7, 8, 9], + f: false, + g: true, + h: null, + i: '', + }, + }, + }; + const result = deepMergeJson(target, source); + expect(result).toMatchInlineSnapshot(` + { + "a": 3, + "b": { + "c": 4, + "d": { + "e": [ + 4, + 5, + 6, + 7, + 8, + 9, + ], + "f": false, + "g": true, + "h": null, + "i": "", + }, + }, + } + `); + }); +}); diff --git a/packages/nx/src/command-line/release/config/deep-merge-json.ts b/packages/nx/src/command-line/release/config/deep-merge-json.ts new file mode 100644 index 0000000000000..6910da1206936 --- /dev/null +++ b/packages/nx/src/command-line/release/config/deep-merge-json.ts @@ -0,0 +1,28 @@ +function isObject(obj: any): obj is Record { + return obj && typeof obj === 'object' && !Array.isArray(obj); +} + +export function deepMergeJson>( + target: T, + source: T +): T { + try { + // Ensure both objects are valid JSON before attempting to merge values + JSON.parse(JSON.stringify(source)); + JSON.parse(JSON.stringify(target)); + + for (const key in source) { + if (isObject(source[key])) { + if (!target[key]) { + Object.assign(target, { [key]: {} }); + } + deepMergeJson(target[key], source[key]); + } else { + Object.assign(target, { [key]: source[key] }); + } + } + return target; + } catch { + throw new Error('Invalid JSON was provided'); + } +} diff --git a/packages/nx/src/command-line/release/index.ts b/packages/nx/src/command-line/release/index.ts index 069178938852e..382de80f419c0 100644 --- a/packages/nx/src/command-line/release/index.ts +++ b/packages/nx/src/command-line/release/index.ts @@ -1,16 +1,40 @@ +import type { NxReleaseConfiguration } from '../../config/nx-json'; +import { createAPI as createReleaseChangelogAPI } from './changelog'; +import { createAPI as createReleasePublishAPI } from './publish'; +import { createAPI as createReleaseAPI } from './release'; +import { createAPI as createReleaseVersionAPI } from './version'; + /** * @public */ -export { releaseChangelog } from './changelog'; +export class ReleaseClient { + releaseChangelog = createReleaseChangelogAPI(this.overrideReleaseConfig); + releasePublish = createReleasePublishAPI(this.overrideReleaseConfig); + releaseVersion = createReleaseVersionAPI(this.overrideReleaseConfig); + release = createReleaseAPI(this.overrideReleaseConfig); + + constructor(private overrideReleaseConfig: NxReleaseConfiguration) {} +} + +const defaultClient = new ReleaseClient({}); + /** * @public */ -export { releasePublish } from './publish'; +export const releaseChangelog = + defaultClient.releaseChangelog.bind(defaultClient); + /** * @public */ -export { releaseVersion } from './version'; +export const releasePublish = defaultClient.releasePublish.bind(defaultClient); + /** * @public */ -export { release } from './release'; +export const releaseVersion = defaultClient.releaseVersion.bind(defaultClient); + +/** + * @public + */ +export const release = defaultClient.release.bind(defaultClient); diff --git a/packages/nx/src/command-line/release/plan.ts b/packages/nx/src/command-line/release/plan.ts index b8dc176fa38ca..3b12c73c0dec8 100644 --- a/packages/nx/src/command-line/release/plan.ts +++ b/packages/nx/src/command-line/release/plan.ts @@ -2,7 +2,7 @@ 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 { NxReleaseConfiguration, 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'; @@ -18,149 +18,169 @@ import { getVersionPlansAbsolutePath } from './config/version-plans'; import { generateVersionPlanContent } from './utils/generate-version-plan-content'; import { parseConventionalCommitsMessage } from './utils/git'; import { printDiff } from './utils/print-changes'; +import { printConfigAndExit } from './utils/print-config'; +import { deepMergeJson } from './config/deep-merge-json'; export const releasePlanCLIHandler = (args: PlanOptions) => - handleErrors(args.verbose, () => releasePlan(args)); - -export async function releasePlan(args: PlanOptions): Promise { - 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); - } + handleErrors(args.verbose, () => createAPI({})(args)); + +export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { + return async function releasePlan( + args: PlanOptions + ): Promise { + const projectGraph = await createProjectGraphAsync({ exitOnError: true }); + const nxJson = readNxJson(); + const userProvidedReleaseConfig = deepMergeJson( + nxJson.release ?? {}, + overrideReleaseConfig ?? {} + ); - const versionPlanBumps: Record = {}; - const setBumpIfNotNone = (projectOrGroup: string, version: string) => { - if (version !== 'none') { - versionPlanBumps[projectOrGroup] = version; + if (args.verbose) { + process.env.NX_VERBOSE_LOGGING = 'true'; } - }; - 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', - ], + // 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); + } + // --print-config exits directly as it is not designed to be combined with any other programmatic operations + if (args.printConfig) { + return printConfigAndExit({ + userProvidedReleaseConfig, + nxReleaseConfig, + isDebug: args.printConfig === 'debug', }); + } + + const { + error: filterError, + releaseGroups, + releaseGroupToFilteredProjects, + } = filterReleaseGroups( + projectGraph, + nxReleaseConfig, + args.projects, + args.groups + ); + if (filterError) { + output.error(filterError); 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}"?` - )) - ); + const versionPlanBumps: Record = {}; + 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); } - } 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 (releaseGroups[0].name === IMPLICIT_DEFAULT_RELEASE_GROUP) { + const group = releaseGroups[0]; if (group.projectsRelationship === 'independent') { - for (const project of releaseGroupToFilteredProjects.get(group)) { + for (const project of group.projects) { 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}"?` )) ); } } 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 the projects in the group "${group.name}"?` + `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 = generateVersionPlanContent( - versionPlanBumps, - versionPlanMessage - ); - const versionPlanFileName = `version-plan-${new Date().getTime()}.md`; + if (!Object.keys(versionPlanBumps).length) { + output.warn({ + title: + 'No version bumps were selected so no version plan file was created.', + }); + return 0; + } - if (args.dryRun) { - output.logSingleLine( - `Would create version plan file "${versionPlanFileName}", but --dry-run was set.` + const versionPlanMessage = args.message || (await promptForMessage()); + const versionPlanFileContent = generateVersionPlanContent( + versionPlanBumps, + versionPlanMessage ); - 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 - ); - } + const versionPlanFileName = `version-plan-${new Date().getTime()}.md`; - return 0; + 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 { diff --git a/packages/nx/src/command-line/release/publish.ts b/packages/nx/src/command-line/release/publish.ts index 5320407dd245d..080fbf44d77d8 100644 --- a/packages/nx/src/command-line/release/publish.ts +++ b/packages/nx/src/command-line/release/publish.ts @@ -1,4 +1,8 @@ -import { NxJsonConfiguration, readNxJson } from '../../config/nx-json'; +import { + NxJsonConfiguration, + NxReleaseConfiguration, + readNxJson, +} from '../../config/nx-json'; import { ProjectGraph, ProjectGraphProjectNode, @@ -19,82 +23,120 @@ import { createNxReleaseConfig, handleNxReleaseConfigError, } from './config/config'; +import { deepMergeJson } from './config/deep-merge-json'; import { filterReleaseGroups } from './config/filter-release-groups'; +import { printConfigAndExit } from './utils/print-config'; export const releasePublishCLIHandler = (args: PublishOptions) => - handleErrors(args.verbose, () => releasePublish(args, true)); - -/** - * NOTE: This function is also exported for programmatic usage and forms part of the public API - * of Nx. We intentionally do not wrap the implementation with handleErrors because users need - * to have control over their own error handling when using the API. - */ -export async function releasePublish( - args: PublishOptions, - isCLI = false -): Promise { + handleErrors(args.verbose, () => createAPI({})(args, true)); + +export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { /** - * When used via the CLI, the args object will contain a __overrides_unparsed__ property that is - * important for invoking the relevant executor behind the scenes. - * - * We intentionally do not include that in the function signature, however, so as not to cause - * confusing errors for programmatic consumers of this function. + * NOTE: This function is also exported for programmatic usage and forms part of the public API + * of Nx. We intentionally do not wrap the implementation with handleErrors because users need + * to have control over their own error handling when using the API. */ - const _args = args as PublishOptions & { __overrides_unparsed__: string[] }; + return async function releasePublish( + args: PublishOptions, + isCLI = false + ): Promise { + /** + * When used via the CLI, the args object will contain a __overrides_unparsed__ property that is + * important for invoking the relevant executor behind the scenes. + * + * We intentionally do not include that in the function signature, however, so as not to cause + * confusing errors for programmatic consumers of this function. + */ + const _args = args as PublishOptions & { __overrides_unparsed__: string[] }; - const projectGraph = await createProjectGraphAsync({ exitOnError: true }); - const nxJson = readNxJson(); + const projectGraph = await createProjectGraphAsync({ exitOnError: true }); + const nxJson = readNxJson(); + const userProvidedReleaseConfig = deepMergeJson( + nxJson.release ?? {}, + overrideReleaseConfig ?? {} + ); - if (_args.verbose) { - process.env.NX_VERBOSE_LOGGING = 'true'; - } + 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); - } + // Apply default configuration to any optional user configuration + const { error: configError, nxReleaseConfig } = await createNxReleaseConfig( + projectGraph, + await createProjectFileMapUsingProjectGraph(projectGraph), + userProvidedReleaseConfig + ); + if (configError) { + return await handleNxReleaseConfigError(configError); + } + // --print-config exits directly as it is not designed to be combined with any other programmatic operations + if (args.printConfig) { + return printConfigAndExit({ + userProvidedReleaseConfig, + nxReleaseConfig, + isDebug: args.printConfig === 'debug', + }); + } - const { - error: filterError, - releaseGroups, - releaseGroupToFilteredProjects, - } = filterReleaseGroups( - projectGraph, - nxReleaseConfig, - _args.projects, - _args.groups - ); - if (filterError) { - output.error(filterError); - process.exit(1); - } + const { + error: filterError, + releaseGroups, + releaseGroupToFilteredProjects, + } = filterReleaseGroups( + projectGraph, + nxReleaseConfig, + _args.projects, + _args.groups + ); + if (filterError) { + output.error(filterError); + process.exit(1); + } - /** - * If the user is filtering to a subset of projects or groups, we should not run the publish task - * for dependencies, because that could cause projects outset of the filtered set to be published. - */ - const shouldExcludeTaskDependencies = - _args.projects?.length > 0 || - _args.groups?.length > 0 || - args.excludeTaskDependencies; + /** + * If the user is filtering to a subset of projects or groups, we should not run the publish task + * for dependencies, because that could cause projects outset of the filtered set to be published. + */ + const shouldExcludeTaskDependencies = + _args.projects?.length > 0 || + _args.groups?.length > 0 || + args.excludeTaskDependencies; - let overallExitStatus = 0; + let overallExitStatus = 0; + + if (args.projects?.length) { + /** + * Run publishing for all remaining release groups and filtered projects within them + */ + for (const releaseGroup of releaseGroups) { + const status = await runPublishOnProjects( + _args, + projectGraph, + nxJson, + Array.from(releaseGroupToFilteredProjects.get(releaseGroup)), + isCLI, + { + excludeTaskDependencies: shouldExcludeTaskDependencies, + loadDotEnvFiles: process.env.NX_LOAD_DOT_ENV_FILES !== 'false', + } + ); + if (status !== 0) { + overallExitStatus = status || 1; + } + } + + return overallExitStatus; + } - if (args.projects?.length) { /** - * Run publishing for all remaining release groups and filtered projects within them + * Run publishing for all remaining release groups */ for (const releaseGroup of releaseGroups) { const status = await runPublishOnProjects( _args, projectGraph, nxJson, - Array.from(releaseGroupToFilteredProjects.get(releaseGroup)), + releaseGroup.projects, isCLI, { excludeTaskDependencies: shouldExcludeTaskDependencies, @@ -107,29 +149,7 @@ export async function releasePublish( } return overallExitStatus; - } - - /** - * Run publishing for all remaining release groups - */ - for (const releaseGroup of releaseGroups) { - const status = await runPublishOnProjects( - _args, - projectGraph, - nxJson, - releaseGroup.projects, - isCLI, - { - excludeTaskDependencies: shouldExcludeTaskDependencies, - loadDotEnvFiles: process.env.NX_LOAD_DOT_ENV_FILES !== 'false', - } - ); - if (status !== 0) { - overallExitStatus = status || 1; - } - } - - return overallExitStatus; + }; } async function runPublishOnProjects( diff --git a/packages/nx/src/command-line/release/release.ts b/packages/nx/src/command-line/release/release.ts index 7ec8c6f9f4b62..7a4e7e4a18d3b 100644 --- a/packages/nx/src/command-line/release/release.ts +++ b/packages/nx/src/command-line/release/release.ts @@ -1,299 +1,326 @@ import { prompt } from 'enquirer'; import { removeSync } from 'fs-extra'; -import { readNxJson } from '../../config/nx-json'; +import { NxReleaseConfiguration, 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 { releaseChangelog, shouldCreateGitHubRelease } from './changelog'; +import { + createAPI as createReleaseChangelogAPI, + shouldCreateGitHubRelease, +} from './changelog'; import { ReleaseOptions, VersionOptions } from './command-object'; import { IMPLICIT_DEFAULT_RELEASE_GROUP, createNxReleaseConfig, handleNxReleaseConfigError, } from './config/config'; +import { deepMergeJson } from './config/deep-merge-json'; import { filterReleaseGroups } from './config/filter-release-groups'; import { readRawVersionPlans, setVersionPlansOnGroups, } from './config/version-plans'; -import { releasePublish } from './publish'; +import { createAPI as createReleasePublishAPI } from './publish'; import { getCommitHash, gitAdd, gitCommit, gitPush, gitTag } from './utils/git'; import { createOrUpdateGithubRelease } from './utils/github'; +import { printConfigAndExit } from './utils/print-config'; import { resolveNxJsonConfigErrorMessage } from './utils/resolve-nx-json-error-message'; import { createCommitMessageValues, createGitTagValues, handleDuplicateGitTags, } from './utils/shared'; -import { NxReleaseVersionResult, releaseVersion } from './version'; +import { + NxReleaseVersionResult, + createAPI as createReleaseVersionAPI, +} from './version'; export const releaseCLIHandler = (args: VersionOptions) => - handleErrors(args.verbose, () => release(args)); + handleErrors(args.verbose, () => createAPI({})(args)); + +export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { + const releaseVersion = createReleaseVersionAPI(overrideReleaseConfig); + const releaseChangelog = createReleaseChangelogAPI(overrideReleaseConfig); + const releasePublish = createReleasePublishAPI(overrideReleaseConfig); + + return async function release( + args: ReleaseOptions + ): Promise { + const projectGraph = await createProjectGraphAsync({ exitOnError: true }); + const nxJson = readNxJson(); + const userProvidedReleaseConfig = deepMergeJson( + nxJson.release ?? {}, + overrideReleaseConfig ?? {} + ); -export async function release( - args: ReleaseOptions -): Promise { - const projectGraph = await createProjectGraphAsync({ exitOnError: true }); - const nxJson = readNxJson(); + if (args.verbose) { + process.env.NX_VERBOSE_LOGGING = 'true'; + } - if (args.verbose) { - process.env.NX_VERBOSE_LOGGING = 'true'; - } + const hasVersionGitConfig = + Object.keys(userProvidedReleaseConfig.version?.git ?? {}).length > 0; + const hasChangelogGitConfig = + Object.keys(userProvidedReleaseConfig.changelog?.git ?? {}).length > 0; + if (hasVersionGitConfig || hasChangelogGitConfig) { + const jsonConfigErrorPath = hasVersionGitConfig + ? ['release', 'version', 'git'] + : ['release', 'changelog', 'git']; + const nxJsonMessage = await resolveNxJsonConfigErrorMessage( + jsonConfigErrorPath + ); + output.error({ + title: `The "release" top level command cannot be used with granular git configuration. Instead, configure git options in the "release.git" property in nx.json, or use the version, changelog, and publish subcommands or programmatic API directly.`, + bodyLines: [nxJsonMessage], + }); + process.exit(1); + } - const hasVersionGitConfig = - Object.keys(nxJson.release?.version?.git ?? {}).length > 0; - const hasChangelogGitConfig = - Object.keys(nxJson.release?.changelog?.git ?? {}).length > 0; - if (hasVersionGitConfig || hasChangelogGitConfig) { - const jsonConfigErrorPath = hasVersionGitConfig - ? ['release', 'version', 'git'] - : ['release', 'changelog', 'git']; - const nxJsonMessage = await resolveNxJsonConfigErrorMessage( - jsonConfigErrorPath + // Apply default configuration to any optional user configuration + const { error: configError, nxReleaseConfig } = await createNxReleaseConfig( + projectGraph, + await createProjectFileMapUsingProjectGraph(projectGraph), + userProvidedReleaseConfig ); - output.error({ - title: `The "release" top level command cannot be used with granular git configuration. Instead, configure git options in the "release.git" property in nx.json, or use the version, changelog, and publish subcommands or programmatic API directly.`, - bodyLines: [nxJsonMessage], - }); - process.exit(1); - } - - // 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); - } - - // These properties must never be undefined as this command should - // always explicitly override the git operations of the subcommands. - const shouldCommit = nxJson.release?.git?.commit ?? true; - const shouldStage = - (shouldCommit || nxJson.release?.git?.stageChanges) ?? false; - const shouldTag = nxJson.release?.git?.tag ?? true; - - const versionResult: NxReleaseVersionResult = await releaseVersion({ - ...args, - stageChanges: shouldStage, - gitCommit: false, - gitTag: false, - deleteVersionPlans: false, - }); - - const changelogResult = await releaseChangelog({ - ...args, - versionData: versionResult.projectsVersionData, - version: versionResult.workspaceVersion, - stageChanges: shouldStage, - gitCommit: false, - gitTag: false, - createRelease: false, - deleteVersionPlans: false, - }); - - const { - error: filterError, - releaseGroups, - releaseGroupToFilteredProjects, - } = filterReleaseGroups( - projectGraph, - nxReleaseConfig, - args.projects, - args.groups - ); - if (filterError) { - output.error(filterError); - process.exit(1); - } - const rawVersionPlans = await readRawVersionPlans(); - setVersionPlansOnGroups( - rawVersionPlans, - releaseGroups, - Object.keys(projectGraph.nodes) - ); - - const planFiles = new Set(); - 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); + if (configError) { + return await handleNxReleaseConfigError(configError); + } + // --print-config exits directly as it is not designed to be combined with any other programmatic operations + if (args.printConfig) { + return printConfigAndExit({ + userProvidedReleaseConfig, + nxReleaseConfig, + isDebug: args.printConfig === 'debug', }); } - }); - 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`); + // These properties must never be undefined as this command should + // always explicitly override the git operations of the subcommands. + const shouldCommit = userProvidedReleaseConfig.git?.commit ?? true; + const shouldStage = + (shouldCommit || userProvidedReleaseConfig.git?.stageChanges) ?? false; + const shouldTag = userProvidedReleaseConfig.git?.tag ?? true; + + const versionResult: NxReleaseVersionResult = await releaseVersion({ + ...args, + stageChanges: shouldStage, + gitCommit: false, + gitTag: false, + deleteVersionPlans: false, + }); - const commitMessage: string | undefined = nxReleaseConfig.git.commitMessage; + const changelogResult = await releaseChangelog({ + ...args, + versionData: versionResult.projectsVersionData, + version: versionResult.workspaceVersion, + stageChanges: shouldStage, + gitCommit: false, + gitTag: false, + createRelease: false, + deleteVersionPlans: false, + }); - const commitMessageValues: string[] = createCommitMessageValues( + const { + error: filterError, releaseGroups, releaseGroupToFilteredProjects, - versionResult.projectsVersionData, - commitMessage + } = filterReleaseGroups( + projectGraph, + nxReleaseConfig, + args.projects, + args.groups + ); + if (filterError) { + output.error(filterError); + process.exit(1); + } + const rawVersionPlans = await readRawVersionPlans(); + setVersionPlansOnGroups( + rawVersionPlans, + releaseGroups, + Object.keys(projectGraph.nodes) ); - await gitCommit({ - messages: commitMessageValues, - additionalArgs: nxReleaseConfig.git.commitArgs, - dryRun: args.dryRun, - verbose: args.verbose, + const planFiles = new Set(); + 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 (shouldTag) { - output.logSingleLine(`Tagging commit with git`); + if (shouldCommit) { + output.logSingleLine(`Committing changes with git`); - // Resolve any git tags as early as possible so that we can hard error in case of any duplicates before reaching the actual git command - const gitTagValues: string[] = createGitTagValues( - releaseGroups, - releaseGroupToFilteredProjects, - versionResult.projectsVersionData - ); - handleDuplicateGitTags(gitTagValues); + const commitMessage: string | undefined = + nxReleaseConfig.git.commitMessage; - for (const tag of gitTagValues) { - await gitTag({ - tag, - message: nxReleaseConfig.git.tagMessage, - additionalArgs: nxReleaseConfig.git.tagArgs, + const commitMessageValues: string[] = createCommitMessageValues( + releaseGroups, + releaseGroupToFilteredProjects, + versionResult.projectsVersionData, + commitMessage + ); + + await gitCommit({ + messages: commitMessageValues, + additionalArgs: nxReleaseConfig.git.commitArgs, dryRun: args.dryRun, verbose: args.verbose, }); } - } - const shouldCreateWorkspaceRelease = shouldCreateGitHubRelease( - nxReleaseConfig.changelog.workspaceChangelog - ); + if (shouldTag) { + output.logSingleLine(`Tagging commit with git`); + + // Resolve any git tags as early as possible so that we can hard error in case of any duplicates before reaching the actual git command + const gitTagValues: string[] = createGitTagValues( + releaseGroups, + releaseGroupToFilteredProjects, + versionResult.projectsVersionData + ); + handleDuplicateGitTags(gitTagValues); + + for (const tag of gitTagValues) { + await gitTag({ + tag, + message: nxReleaseConfig.git.tagMessage, + additionalArgs: nxReleaseConfig.git.tagArgs, + dryRun: args.dryRun, + verbose: args.verbose, + }); + } + } - let hasPushedChanges = false; - let latestCommit: string | undefined; + const shouldCreateWorkspaceRelease = shouldCreateGitHubRelease( + nxReleaseConfig.changelog.workspaceChangelog + ); - if (shouldCreateWorkspaceRelease && changelogResult.workspaceChangelog) { - output.logSingleLine(`Pushing to git remote`); + let hasPushedChanges = false; + let latestCommit: string | undefined; - // Before we can create/update the release we need to ensure the commit exists on the remote - await gitPush({ - dryRun: args.dryRun, - verbose: args.verbose, - }); + if (shouldCreateWorkspaceRelease && changelogResult.workspaceChangelog) { + output.logSingleLine(`Pushing to git remote`); - hasPushedChanges = true; + // Before we can create/update the release we need to ensure the commit exists on the remote + await gitPush({ + dryRun: args.dryRun, + verbose: args.verbose, + }); - output.logSingleLine(`Creating GitHub Release`); + hasPushedChanges = true; - latestCommit = await getCommitHash('HEAD'); - await createOrUpdateGithubRelease( - changelogResult.workspaceChangelog.releaseVersion, - changelogResult.workspaceChangelog.contents, - latestCommit, - { dryRun: args.dryRun } - ); - } + output.logSingleLine(`Creating GitHub Release`); - for (const releaseGroup of releaseGroups) { - const shouldCreateProjectReleases = shouldCreateGitHubRelease( - releaseGroup.changelog - ); + latestCommit = await getCommitHash('HEAD'); + await createOrUpdateGithubRelease( + changelogResult.workspaceChangelog.releaseVersion, + changelogResult.workspaceChangelog.contents, + latestCommit, + { dryRun: args.dryRun } + ); + } - if (shouldCreateProjectReleases && changelogResult.projectChangelogs) { - const projects = args.projects?.length - ? // If the user has passed a list of projects, we need to use the filtered list of projects within the release group - Array.from(releaseGroupToFilteredProjects.get(releaseGroup)) - : // Otherwise, we use the full list of projects within the release group - releaseGroup.projects; - const projectNodes = projects.map((name) => projectGraph.nodes[name]); - - for (const project of projectNodes) { - const changelog = changelogResult.projectChangelogs[project.name]; - if (!changelog) { - continue; - } + for (const releaseGroup of releaseGroups) { + const shouldCreateProjectReleases = shouldCreateGitHubRelease( + releaseGroup.changelog + ); + + if (shouldCreateProjectReleases && changelogResult.projectChangelogs) { + const projects = args.projects?.length + ? // If the user has passed a list of projects, we need to use the filtered list of projects within the release group + Array.from(releaseGroupToFilteredProjects.get(releaseGroup)) + : // Otherwise, we use the full list of projects within the release group + releaseGroup.projects; + const projectNodes = projects.map((name) => projectGraph.nodes[name]); + + for (const project of projectNodes) { + const changelog = changelogResult.projectChangelogs[project.name]; + if (!changelog) { + continue; + } - if (!hasPushedChanges) { - output.logSingleLine(`Pushing to git remote`); + if (!hasPushedChanges) { + output.logSingleLine(`Pushing to git remote`); - // Before we can create/update the release we need to ensure the commit exists on the remote - await gitPush({ - dryRun: args.dryRun, - verbose: args.verbose, - }); + // Before we can create/update the release we need to ensure the commit exists on the remote + await gitPush({ + dryRun: args.dryRun, + verbose: args.verbose, + }); - hasPushedChanges = true; - } + hasPushedChanges = true; + } - output.logSingleLine(`Creating GitHub Release`); + output.logSingleLine(`Creating GitHub Release`); - if (!latestCommit) { - latestCommit = await getCommitHash('HEAD'); - } + if (!latestCommit) { + latestCommit = await getCommitHash('HEAD'); + } - await createOrUpdateGithubRelease( - changelog.releaseVersion, - changelog.contents, - latestCommit, - { dryRun: args.dryRun } - ); + await createOrUpdateGithubRelease( + changelog.releaseVersion, + changelog.contents, + latestCommit, + { dryRun: args.dryRun } + ); + } } } - } - let hasNewVersion = false; - // null means that all projects are versioned together but there were no changes - if (versionResult.workspaceVersion !== null) { - hasNewVersion = Object.values(versionResult.projectsVersionData).some( - (version) => version.newVersion !== null - ); - } + let hasNewVersion = false; + // null means that all projects are versioned together but there were no changes + if (versionResult.workspaceVersion !== null) { + hasNewVersion = Object.values(versionResult.projectsVersionData).some( + (version) => version.newVersion !== null + ); + } - let shouldPublish = !!args.yes && !args.skipPublish && hasNewVersion; - const shouldPromptPublishing = - !args.yes && !args.skipPublish && !args.dryRun && hasNewVersion; + let shouldPublish = !!args.yes && !args.skipPublish && hasNewVersion; + const shouldPromptPublishing = + !args.yes && !args.skipPublish && !args.dryRun && hasNewVersion; - if (shouldPromptPublishing) { - shouldPublish = await promptForPublish(); - } + if (shouldPromptPublishing) { + shouldPublish = await promptForPublish(); + } - if (shouldPublish) { - await releasePublish(args); - } else { - output.logSingleLine('Skipped publishing packages.'); - } + if (shouldPublish) { + await releasePublish(args); + } else { + output.logSingleLine('Skipped publishing packages.'); + } - return versionResult; + return versionResult; + }; } async function promptForPublish(): Promise { diff --git a/packages/nx/src/command-line/release/utils/print-config.ts b/packages/nx/src/command-line/release/utils/print-config.ts new file mode 100644 index 0000000000000..c139d1ac210ff --- /dev/null +++ b/packages/nx/src/command-line/release/utils/print-config.ts @@ -0,0 +1,54 @@ +import type { NxReleaseConfiguration } from '../../../config/nx-json'; +import { output } from '../../../utils/output'; +import type { NxReleaseConfig } from '../config/config'; + +export function printConfigAndExit({ + userProvidedReleaseConfig, + nxReleaseConfig, + isDebug, +}: { + userProvidedReleaseConfig: NxReleaseConfiguration; + nxReleaseConfig: NxReleaseConfig; + isDebug: boolean; +}): any { + if (isDebug) { + console.log( + '============================================================= START FINAL INTERNAL CONFIG' + ); + console.log(JSON.stringify(nxReleaseConfig, null, 2)); + console.log( + '============================================================= END FINAL INTERNAL CONFIG' + ); + console.log(''); + console.log( + '============================================================= START USER CONFIG' + ); + console.log(JSON.stringify(userProvidedReleaseConfig, null, 2)); + console.log( + '============================================================= END USER CONFIG' + ); + output.log({ + title: 'Resolved Nx Release Configuration', + bodyLines: [ + 'NOTE: --printConfig was set to debug, so the above output contains two different resolved configs:', + '', + '- The config immediately above is the user config, the one provided by you in nx.json and/or the programmatic API.', + '- The config above that is the low level resolved configuration object used internally by nx release. It can be referenced for advanced troubleshooting.', + '', + 'For the user-facing configuration format, and the full list of available options, please reference https://nx.dev/reference/nx-json#release', + ], + }); + process.exit(0); + } + + console.log(JSON.stringify(userProvidedReleaseConfig, null, 2)); + + output.log({ + title: 'Resolved Nx Release Configuration', + bodyLines: [ + 'The above config is the result of merging any nx release config in nx.json and/or the programmatic API.', + '', + 'For details on the configuration format, and the full list of available options, please reference https://nx.dev/reference/nx-json#release', + ], + }); +} diff --git a/packages/nx/src/command-line/release/version.ts b/packages/nx/src/command-line/release/version.ts index ef5f68437f916..8be3ecf745694 100644 --- a/packages/nx/src/command-line/release/version.ts +++ b/packages/nx/src/command-line/release/version.ts @@ -3,7 +3,11 @@ import { execSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { relative } from 'node:path'; import { Generator } from '../../config/misc-interfaces'; -import { NxJsonConfiguration, readNxJson } from '../../config/nx-json'; +import { + NxJsonConfiguration, + NxReleaseConfiguration, + readNxJson, +} from '../../config/nx-json'; import { ProjectGraph, ProjectGraphProjectNode, @@ -26,6 +30,7 @@ import { createNxReleaseConfig, handleNxReleaseConfigError, } from './config/config'; +import { deepMergeJson } from './config/deep-merge-json'; import { ReleaseGroupWithName, filterReleaseGroups, @@ -37,6 +42,7 @@ import { import { batchProjectsByGeneratorConfig } from './utils/batch-projects-by-generator-config'; import { gitAdd, gitTag } from './utils/git'; import { printDiff } from './utils/print-changes'; +import { printConfigAndExit } from './utils/print-config'; import { resolveNxJsonConfigErrorMessage } from './utils/resolve-nx-json-error-message'; import { ReleaseVersionGeneratorResult, @@ -101,114 +107,269 @@ export interface NxReleaseVersionResult { } export const releaseVersionCLIHandler = (args: VersionOptions) => - handleErrors(args.verbose, () => releaseVersion(args)); - -/** - * NOTE: This function is also exported for programmatic usage and forms part of the public API - * of Nx. We intentionally do not wrap the implementation with handleErrors because users need - * to have control over their own error handling when using the API. - */ -export async function releaseVersion( - args: VersionOptions -): Promise { - const projectGraph = await createProjectGraphAsync({ exitOnError: true }); - const { projects } = readProjectsConfigurationFromProjectGraph(projectGraph); - const nxJson = readNxJson(); - - if (args.verbose) { - process.env.NX_VERBOSE_LOGGING = 'true'; - } + handleErrors(args.verbose, () => createAPI({})(args)); - // 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); - } +export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { + /** + * NOTE: This function is also exported for programmatic usage and forms part of the public API + * of Nx. We intentionally do not wrap the implementation with handleErrors because users need + * to have control over their own error handling when using the API. + */ + return async function releaseVersion( + args: VersionOptions + ): Promise { + const projectGraph = await createProjectGraphAsync({ exitOnError: true }); + const { projects } = + readProjectsConfigurationFromProjectGraph(projectGraph); + const nxJson = readNxJson(); + const userProvidedReleaseConfig = deepMergeJson( + nxJson.release ?? {}, + overrideReleaseConfig ?? {} + ); - // The nx release top level command will always override these three git args. This is how we can tell - // if the top level release command was used or if the user is using the changelog subcommand. - // If the user explicitly overrides these args, then it doesn't matter if the top level config is set, - // as all of the git options would be overridden anyway. - if ( - (args.gitCommit === undefined || - args.gitTag === undefined || - args.stageChanges === undefined) && - nxJson.release?.git - ) { - const nxJsonMessage = await resolveNxJsonConfigErrorMessage([ - 'release', - 'git', - ]); - output.error({ - title: `The "release.git" property in nx.json may not be used with the "nx release version" subcommand or programmatic API. Instead, configure git options for subcommands directly with "release.version.git" and "release.changelog.git".`, - bodyLines: [nxJsonMessage], + 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), + userProvidedReleaseConfig + ); + if (configError) { + return await handleNxReleaseConfigError(configError); + } + // --print-config exits directly as it is not designed to be combined with any other programmatic operations + if (args.printConfig) { + return printConfigAndExit({ + userProvidedReleaseConfig, + nxReleaseConfig, + isDebug: args.printConfig === 'debug', + }); + } + + // The nx release top level command will always override these three git args. This is how we can tell + // if the top level release command was used or if the user is using the changelog subcommand. + // If the user explicitly overrides these args, then it doesn't matter if the top level config is set, + // as all of the git options would be overridden anyway. + if ( + (args.gitCommit === undefined || + args.gitTag === undefined || + args.stageChanges === undefined) && + userProvidedReleaseConfig.git + ) { + const nxJsonMessage = await resolveNxJsonConfigErrorMessage([ + 'release', + 'git', + ]); + output.error({ + title: `The "release.git" property in nx.json may not be used with the "nx release version" subcommand or programmatic API. Instead, configure git options for subcommands directly with "release.version.git" and "release.changelog.git".`, + bodyLines: [nxJsonMessage], + }); + process.exit(1); + } + + const { + error: filterError, + releaseGroups, + releaseGroupToFilteredProjects, + } = filterReleaseGroups( + projectGraph, + nxReleaseConfig, + args.projects, + args.groups + ); + if (filterError) { + 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, + verbose: args.verbose, }); - process.exit(1); - } - const { - error: filterError, - releaseGroups, - releaseGroupToFilteredProjects, - } = filterReleaseGroups( - projectGraph, - nxReleaseConfig, - args.projects, - args.groups - ); - if (filterError) { - output.error(filterError); - process.exit(1); - } - const rawVersionPlans = await readRawVersionPlans(); - setVersionPlansOnGroups( - rawVersionPlans, - releaseGroups, - Object.keys(projectGraph.nodes) - ); + const tree = new FsTree(workspaceRoot, args.verbose); - if (args.deleteVersionPlans === undefined) { - // default to not delete version plans after versioning as they may be needed for changelog generation - args.deleteVersionPlans = false; - } + const versionData: VersionData = {}; + const commitMessage: string | undefined = + args.gitCommitMessage || nxReleaseConfig.version.git.commitMessage; + const generatorCallbacks: (() => Promise)[] = []; - runPreVersionCommand(nxReleaseConfig.version.preVersionCommand, { - dryRun: args.dryRun, - verbose: args.verbose, - }); + /** + * additionalChangedFiles are files which need to be updated as a side-effect of versioning (such as package manager lock files), + * and need to get staged and committed as part of the existing commit, if applicable. + */ + const additionalChangedFiles = new Set(); + const additionalDeletedFiles = new Set(); + + if (args.projects?.length) { + /** + * Run versioning 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) + ); + const projectBatches = batchProjectsByGeneratorConfig( + projectGraph, + releaseGroup, + // Only batch based on the filtered projects within the release group + releaseGroupProjectNames + ); + + for (const [ + generatorConfigString, + projectNames, + ] of projectBatches.entries()) { + const [generatorName, generatorOptions] = JSON.parse( + generatorConfigString + ); + // Resolve the generator for the batch and run versioning on the projects within the batch + const generatorData = resolveGeneratorData({ + ...extractGeneratorCollectionAndName( + `batch "${JSON.stringify( + projectNames + )}" for release-group "${releaseGroupName}"`, + generatorName + ), + configGeneratorOptions: generatorOptions, + // all project data from the project graph (not to be confused with projectNamesToRunVersionOn) + projects, + }); + const generatorCallback = await runVersionOnProjects( + projectGraph, + nxJson, + args, + tree, + generatorData, + args.generatorOptionsOverrides, + projectNames, + releaseGroup, + versionData, + nxReleaseConfig.conventionalCommits + ); + // Capture the callback so that we can run it after flushing the changes to disk + generatorCallbacks.push(async () => { + const result = await generatorCallback(tree, { + dryRun: !!args.dryRun, + verbose: !!args.verbose, + generatorOptions: { + ...generatorOptions, + ...args.generatorOptionsOverrides, + }, + }); + const { changedFiles, deletedFiles } = + parseGeneratorCallbackResult(result); + changedFiles.forEach((f) => additionalChangedFiles.add(f)); + deletedFiles.forEach((f) => additionalDeletedFiles.add(f)); + }); + } + } - const tree = new FsTree(workspaceRoot, args.verbose); + // Resolve any git tags as early as possible so that we can hard error in case of any duplicates before reaching the actual git command + const gitTagValues: string[] = + args.gitTag ?? nxReleaseConfig.version.git.tag + ? createGitTagValues( + releaseGroups, + releaseGroupToFilteredProjects, + versionData + ) + : []; + handleDuplicateGitTags(gitTagValues); + + printAndFlushChanges(tree, !!args.dryRun); + + for (const generatorCallback of generatorCallbacks) { + await generatorCallback(); + } - const versionData: VersionData = {}; - const commitMessage: string | undefined = - args.gitCommitMessage || nxReleaseConfig.version.git.commitMessage; - const generatorCallbacks: (() => Promise)[] = []; + const changedFiles = [ + ...tree.listChanges().map((f) => f.path), + ...additionalChangedFiles, + ]; + + // No further actions are necessary in this scenario (e.g. if conventional commits detected no changes) + if (!changedFiles.length) { + return { + // An overall workspace version cannot be relevant when filtering to independent projects + workspaceVersion: undefined, + projectsVersionData: versionData, + }; + } - /** - * additionalChangedFiles are files which need to be updated as a side-effect of versioning (such as package manager lock files), - * and need to get staged and committed as part of the existing commit, if applicable. - */ - const additionalChangedFiles = new Set(); - const additionalDeletedFiles = new Set(); + if (args.gitCommit ?? nxReleaseConfig.version.git.commit) { + await commitChanges({ + changedFiles, + deletedFiles: Array.from(additionalDeletedFiles), + isDryRun: !!args.dryRun, + isVerbose: !!args.verbose, + gitCommitMessages: createCommitMessageValues( + releaseGroups, + releaseGroupToFilteredProjects, + versionData, + commitMessage + ), + gitCommitArgs: + args.gitCommitArgs || nxReleaseConfig.version.git.commitArgs, + }); + } else if ( + args.stageChanges ?? + nxReleaseConfig.version.git.stageChanges + ) { + output.logSingleLine(`Staging changed files with git`); + await gitAdd({ + changedFiles, + dryRun: args.dryRun, + verbose: args.verbose, + }); + } + + if (args.gitTag ?? nxReleaseConfig.version.git.tag) { + output.logSingleLine(`Tagging commit with git`); + for (const tag of gitTagValues) { + await gitTag({ + tag, + message: + args.gitTagMessage || nxReleaseConfig.version.git.tagMessage, + additionalArgs: + args.gitTagArgs || nxReleaseConfig.version.git.tagArgs, + dryRun: args.dryRun, + verbose: args.verbose, + }); + } + } + + return { + // An overall workspace version cannot be relevant when filtering to independent projects + workspaceVersion: undefined, + projectsVersionData: versionData, + }; + } - if (args.projects?.length) { /** - * Run versioning for all remaining release groups and filtered projects within them + * Run versioning for all remaining release groups */ for (const releaseGroup of releaseGroups) { const releaseGroupName = releaseGroup.name; - const releaseGroupProjectNames = Array.from( - releaseGroupToFilteredProjects.get(releaseGroup) - ); const projectBatches = batchProjectsByGeneratorConfig( projectGraph, releaseGroup, - // Only batch based on the filtered projects within the release group - releaseGroupProjectNames + // Batch based on all projects within the release group + releaseGroup.projects ); for (const [ @@ -277,6 +438,18 @@ export async function releaseVersion( await generatorCallback(); } + // Only applicable when there is a single release group with a fixed relationship + let workspaceVersion: string | null | undefined = undefined; + if (releaseGroups.length === 1) { + const releaseGroup = releaseGroups[0]; + if (releaseGroup.projectsRelationship === 'fixed') { + const releaseGroupProjectNames = Array.from( + releaseGroupToFilteredProjects.get(releaseGroup) + ); + workspaceVersion = versionData[releaseGroupProjectNames[0]].newVersion; // all projects have the same version so we can just grab the first + } + } + const changedFiles = [ ...tree.listChanges().map((f) => f.path), ...additionalChangedFiles, @@ -286,8 +459,7 @@ export async function releaseVersion( // No further actions are necessary in this scenario (e.g. if conventional commits detected no changes) if (!changedFiles.length && !deletedFiles.length) { return { - // An overall workspace version cannot be relevant when filtering to independent projects - workspaceVersion: undefined, + workspaceVersion, projectsVersionData: versionData, }; } @@ -331,156 +503,10 @@ export async function releaseVersion( } } - return { - // An overall workspace version cannot be relevant when filtering to independent projects - workspaceVersion: undefined, - projectsVersionData: versionData, - }; - } - - /** - * Run versioning for all remaining release groups - */ - for (const releaseGroup of releaseGroups) { - const releaseGroupName = releaseGroup.name; - const projectBatches = batchProjectsByGeneratorConfig( - projectGraph, - releaseGroup, - // Batch based on all projects within the release group - releaseGroup.projects - ); - - for (const [ - generatorConfigString, - projectNames, - ] of projectBatches.entries()) { - const [generatorName, generatorOptions] = JSON.parse( - generatorConfigString - ); - // Resolve the generator for the batch and run versioning on the projects within the batch - const generatorData = resolveGeneratorData({ - ...extractGeneratorCollectionAndName( - `batch "${JSON.stringify( - projectNames - )}" for release-group "${releaseGroupName}"`, - generatorName - ), - configGeneratorOptions: generatorOptions, - // all project data from the project graph (not to be confused with projectNamesToRunVersionOn) - projects, - }); - const generatorCallback = await runVersionOnProjects( - projectGraph, - nxJson, - args, - tree, - generatorData, - args.generatorOptionsOverrides, - projectNames, - releaseGroup, - versionData, - nxReleaseConfig.conventionalCommits - ); - // Capture the callback so that we can run it after flushing the changes to disk - generatorCallbacks.push(async () => { - const result = await generatorCallback(tree, { - dryRun: !!args.dryRun, - verbose: !!args.verbose, - generatorOptions: { - ...generatorOptions, - ...args.generatorOptionsOverrides, - }, - }); - const { changedFiles, deletedFiles } = - parseGeneratorCallbackResult(result); - changedFiles.forEach((f) => additionalChangedFiles.add(f)); - deletedFiles.forEach((f) => additionalDeletedFiles.add(f)); - }); - } - } - - // Resolve any git tags as early as possible so that we can hard error in case of any duplicates before reaching the actual git command - const gitTagValues: string[] = - args.gitTag ?? nxReleaseConfig.version.git.tag - ? createGitTagValues( - releaseGroups, - releaseGroupToFilteredProjects, - versionData - ) - : []; - handleDuplicateGitTags(gitTagValues); - - printAndFlushChanges(tree, !!args.dryRun); - - for (const generatorCallback of generatorCallbacks) { - await generatorCallback(); - } - - // Only applicable when there is a single release group with a fixed relationship - let workspaceVersion: string | null | undefined = undefined; - if (releaseGroups.length === 1) { - const releaseGroup = releaseGroups[0]; - if (releaseGroup.projectsRelationship === 'fixed') { - const releaseGroupProjectNames = Array.from( - releaseGroupToFilteredProjects.get(releaseGroup) - ); - workspaceVersion = versionData[releaseGroupProjectNames[0]].newVersion; // all projects have the same version so we can just grab the first - } - } - - const changedFiles = [ - ...tree.listChanges().map((f) => f.path), - ...additionalChangedFiles, - ]; - - // No further actions are necessary in this scenario (e.g. if conventional commits detected no changes) - if (!changedFiles.length) { return { workspaceVersion, projectsVersionData: versionData, }; - } - - if (args.gitCommit ?? nxReleaseConfig.version.git.commit) { - await commitChanges({ - changedFiles, - deletedFiles: Array.from(additionalDeletedFiles), - isDryRun: !!args.dryRun, - isVerbose: !!args.verbose, - gitCommitMessages: createCommitMessageValues( - releaseGroups, - releaseGroupToFilteredProjects, - versionData, - commitMessage - ), - gitCommitArgs: - args.gitCommitArgs || nxReleaseConfig.version.git.commitArgs, - }); - } else if (args.stageChanges ?? nxReleaseConfig.version.git.stageChanges) { - output.logSingleLine(`Staging changed files with git`); - await gitAdd({ - changedFiles, - dryRun: args.dryRun, - verbose: args.verbose, - }); - } - - if (args.gitTag ?? nxReleaseConfig.version.git.tag) { - output.logSingleLine(`Tagging commit with git`); - for (const tag of gitTagValues) { - await gitTag({ - tag, - message: args.gitTagMessage || nxReleaseConfig.version.git.tagMessage, - additionalArgs: args.gitTagArgs || nxReleaseConfig.version.git.tagArgs, - dryRun: args.dryRun, - verbose: args.verbose, - }); - } - } - - return { - workspaceVersion, - projectsVersionData: versionData, }; } diff --git a/packages/nx/src/config/nx-json.ts b/packages/nx/src/config/nx-json.ts index 79a70af5098a4..0bb9764555caa 100644 --- a/packages/nx/src/config/nx-json.ts +++ b/packages/nx/src/config/nx-json.ts @@ -174,7 +174,7 @@ export interface NxReleaseConventionalCommitsConfiguration { >; } -interface NxReleaseConfiguration { +export interface NxReleaseConfiguration { /** * Shorthand for amending the projects which will be included in the implicit default release group (all projects by default). * @note Only one of `projects` or `groups` can be specified, the cannot be used together.