UNPKG

nx

Version:

The core Nx plugin contains the core functionality of Nx like the project graph, nx commands and task orchestration.

891 lines (890 loc) • 48.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.releaseChangelogCLIHandler = void 0; exports.createAPI = createAPI; const chalk = require("chalk"); const enquirer_1 = require("enquirer"); const node_fs_1 = require("node:fs"); const semver_1 = require("semver"); const tmp_1 = require("tmp"); const nx_json_1 = require("../../config/nx-json"); const tree_1 = require("../../generators/tree"); const file_map_utils_1 = require("../../project-graph/file-map-utils"); const project_graph_1 = require("../../project-graph/project-graph"); const utils_1 = require("../../tasks-runner/utils"); const handle_errors_1 = require("../../utils/handle-errors"); const is_ci_1 = require("../../utils/is-ci"); const output_1 = require("../../utils/output"); const path_1 = require("../../utils/path"); const workspace_root_1 = require("../../utils/workspace-root"); const config_1 = require("./config/config"); const deep_merge_json_1 = require("./config/deep-merge-json"); const version_plans_1 = require("./config/version-plans"); const git_1 = require("./utils/git"); const launch_editor_1 = require("./utils/launch-editor"); const markdown_1 = require("./utils/markdown"); const print_changes_1 = require("./utils/print-changes"); const print_config_1 = require("./utils/print-config"); const release_graph_1 = require("./utils/release-graph"); const remote_release_client_1 = require("./utils/remote-release-clients/remote-release-client"); const resolve_changelog_renderer_1 = require("./utils/resolve-changelog-renderer"); const resolve_nx_json_error_message_1 = require("./utils/resolve-nx-json-error-message"); const shared_1 = require("./utils/shared"); const version_plan_utils_1 = require("./utils/version-plan-utils"); const version_plan_utils_2 = require("./changelog/version-plan-utils"); const commit_utils_1 = require("./changelog/commit-utils"); const version_plan_filtering_1 = require("./changelog/version-plan-filtering"); const releaseChangelogCLIHandler = (args) => (0, handle_errors_1.handleErrors)(args.verbose, () => createAPI({}, false)(args)); exports.releaseChangelogCLIHandler = releaseChangelogCLIHandler; function createAPI(overrideReleaseConfig, ignoreNxJsonConfig) { /** * 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) { const projectGraph = await (0, project_graph_1.createProjectGraphAsync)({ exitOnError: true }); const overriddenConfig = overrideReleaseConfig ?? {}; const userProvidedReleaseConfig = ignoreNxJsonConfig ? overriddenConfig : (0, deep_merge_json_1.deepMergeJson)((0, nx_json_1.readNxJson)().release ?? {}, overriddenConfig); // Apply default configuration to any optional user configuration const { error: configError, nxReleaseConfig } = await (0, config_1.createNxReleaseConfig)(projectGraph, await (0, file_map_utils_1.createProjectFileMapUsingProjectGraph)(projectGraph), userProvidedReleaseConfig); if (configError) { return await (0, config_1.handleNxReleaseConfigError)(configError); } // --print-config exits directly as it is not designed to be combined with any other programmatic operations if (args.printConfig) { return (0, print_config_1.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 (0, resolve_nx_json_error_message_1.resolveNxJsonConfigErrorMessage)([ 'release', 'git', ]); output_1.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 tree = new tree_1.FsTree(workspace_root_1.workspaceRoot, args.verbose); // Use pre-built release graph if provided, otherwise create a new one const releaseGraph = args.releaseGraph || (await (0, release_graph_1.createReleaseGraph)({ tree, projectGraph, nxReleaseConfig, filters: { projects: args.projects, groups: args.groups, }, firstRelease: args.firstRelease, verbose: args.verbose, })); // Display filter log if filters were applied (only when graph was created, not reused) if (!args.releaseGraph && releaseGraph.filterLog && process.env.NX_RELEASE_INTERNAL_SUPPRESS_FILTER_LOG !== 'true') { output_1.output.note(releaseGraph.filterLog); } let rawVersionPlans = await (0, version_plans_1.readRawVersionPlans)(); if (args.deleteVersionPlans === undefined) { // default to deleting version plans in this command instead of after versioning args.deleteVersionPlans = true; } const changelogGenerationEnabled = !!nxReleaseConfig.changelog.workspaceChangelog || Object.values(nxReleaseConfig.groups).some((g) => g.changelog); if (!changelogGenerationEnabled) { output_1.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 useAutomaticFromRef = nxReleaseConfig.changelog?.automaticFromRef || args.firstRelease; /** * 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, releaseGraph); const to = args.to || 'HEAD'; const toSHA = await (0, git_1.getCommitHash)(to); const headSHA = to === 'HEAD' ? toSHA : await (0, git_1.getCommitHash)('HEAD'); // Resolve the from SHA once for reuse across different contexts const fromSHA = await (0, version_plan_filtering_1.resolveWorkspaceChangelogFromSHA)({ args, nxReleaseConfig, useAutomaticFromRef, }); // Filter version plans based on resolveVersionPlans option const shouldFilterVersionPlans = args.resolveVersionPlans === 'using-from-and-to'; if (shouldFilterVersionPlans && rawVersionPlans.length > 0 && fromSHA) { rawVersionPlans = await (0, version_plan_filtering_1.filterVersionPlansByCommitRange)(rawVersionPlans, fromSHA, toSHA, args.verbose); if (args.verbose) { console.log(`Using version plans committed between ${fromSHA} and ${toSHA}`); } } // Set resolved version plans on groups await (0, version_plans_1.setResolvedVersionPlansOnGroups)(rawVersionPlans, releaseGraph.releaseGroups, Object.keys(projectGraph.nodes), args.verbose); // Validate version plans against the filter after resolution const versionPlanValidationError = (0, version_plan_utils_1.validateResolvedVersionPlansAgainstFilter)(releaseGraph.releaseGroups, releaseGraph.releaseGroupToFilteredProjects); if (versionPlanValidationError) { output_1.output.error(versionPlanValidationError); process.exit(1); } /** * Extract the preid from the workspace version and the project versions */ const workspacePreid = workspaceChangelogVersion ? extractPreid(workspaceChangelogVersion) : undefined; const projectsPreid = Object.fromEntries(Object.entries(projectsVersionData).map(([projectName, v]) => [ projectName, v.newVersion ? extractPreid(v.newVersion) : undefined, ])); /** * 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 commitMessage = args.gitCommitMessage || nxReleaseConfig.changelog.git.commitMessage; const commitMessageValues = (0, shared_1.createCommitMessageValues)(releaseGraph.releaseGroups, releaseGraph.releaseGroupToFilteredProjects, projectsVersionData, commitMessage); // 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 = (args.gitTag ?? nxReleaseConfig.changelog.git.tag) ? (0, shared_1.createGitTagValues)(releaseGraph.releaseGroups, releaseGraph.releaseGroupToFilteredProjects, projectsVersionData) : []; (0, shared_1.handleDuplicateGitTags)(gitTagValues); const postGitTasks = []; const workspaceChangelogChanges = await resolveWorkspaceChangelogChanges({ releaseGraph, nxReleaseConfig, args, workspacePreid, projectsPreid, useAutomaticFromRef, toSHA, fromSHA, }); const workspaceChangelog = await generateChangelogForWorkspace({ tree, args, nxReleaseConfig, workspaceChangelogVersion, changes: workspaceChangelogChanges, }); // Add the post git task (e.g. create a remote release) for the workspace changelog, if applicable if (workspaceChangelog && workspaceChangelog.postGitTask) { postGitTasks.push(workspaceChangelog.postGitTask); } /** * Create a cache for from SHA resolution to avoid duplicate git operations */ const fromSHACache = new Map(); // Cache the workspace from SHA if available if (fromSHA) { fromSHACache.set('workspace', fromSHA); } // Helper function to get cached from SHA or resolve and cache it const getCachedFromSHA = async (cacheKey, pattern, templateValues, preid, checkAllBranchesWhen, requireSemver, strictPreid) => { if (fromSHACache.has(cacheKey)) { return fromSHACache.get(cacheKey); } const sha = await (0, version_plan_filtering_1.resolveChangelogFromSHA)({ fromRef: args.from, tagPattern: pattern, tagPatternValues: templateValues, checkAllBranchesWhen, preid, requireSemver, strictPreid, useAutomaticFromRef, }); fromSHACache.set(cacheKey, sha); return sha; }; /** * 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(); for (const releaseGroup of releaseGraph.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 ?? null, }; }) .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 = {}; for (const releaseGroup of releaseGraph.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(releaseGraph.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 = null; if (releaseGroup.resolvedVersionPlans) { changes = (0, version_plan_utils_2.createChangesFromProjectsVersionPlans)(releaseGroup.resolvedVersionPlans, project.name); } else { const projectCacheKey = `${releaseGroup.name}:${project.name}`; const fromSHA = await getCachedFromSHA(projectCacheKey, releaseGroup.releaseTag.pattern, { projectName: project.name, releaseGroupName: releaseGroup.name, }, projectsPreid[project.name], releaseGroup.releaseTag.checkAllBranchesWhen, releaseGroup.releaseTag.requireSemver, releaseGroup.releaseTag.strictPreid); let commits; let fromRef = fromSHA; if (!fromRef && useAutomaticFromRef) { // For automatic from ref, we already have it cached fromRef = fromSHACache.get(projectCacheKey); if (fromRef) { commits = await filterProjectCommits({ fromSHA: fromRef, toSHA, projectPath: project.data.root, }); fromRef = commits[0]?.shortHash; if (args.verbose) { console.log(`Determined --from ref for ${project.name} from the first commit in which it exists: ${fromRef}`); } } } 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.releaseTag.pattern" property in nx.json to match the structure of your repository's git tags.`); } if (!commits) { commits = await filterProjectCommits({ fromSHA: fromRef, toSHA, projectPath: project.data.root, }); } const { fileMap } = await (0, file_map_utils_1.createFileMapUsingProjectGraph)(projectGraph); const fileToProjectMap = (0, commit_utils_1.createFileToProjectMap)(fileMap.projectFileMap); changes = (0, commit_utils_1.createChangesFromCommits)(commits, fileMap, fileToProjectMap, nxReleaseConfig.conventionalCommits); } const projectChangelogs = await generateChangelogForProjects({ tree, args, changes, projectsVersionData, releaseGroup, projects: [project], nxReleaseConfig, projectToAdditionalDependencyBumps, }); if (projectChangelogs) { for (const [projectName, projectChangelog] of Object.entries(projectChangelogs)) { // Add the post git task (e.g. create a remote release) for the project changelog, if applicable if (projectChangelog.postGitTask) { postGitTasks.push(projectChangelog.postGitTask); } allProjectChangelogs[projectName] = projectChangelog; } } } } else { let changes = []; if (releaseGroup.resolvedVersionPlans) { // This is identical to workspace changelog for fixed groups changes = (0, version_plan_utils_2.createChangesFromGroupVersionPlans)(releaseGroup.resolvedVersionPlans); } else { const groupCacheKey = `${releaseGroup.name}:fixed`; const fromSHA = await getCachedFromSHA(groupCacheKey, releaseGroup.releaseTag.pattern, {}, workspacePreid ?? projectsPreid?.[Object.keys(projectsPreid)[0]], releaseGroup.releaseTag.checkAllBranchesWhen, releaseGroup.releaseTag.requireSemver, releaseGroup.releaseTag.strictPreid); if (!fromSHA) { 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.releaseTag.pattern" property in nx.json to match the structure of your repository's git tags.`); } if (args.verbose && useAutomaticFromRef && !args.from) { console.log(`Determined release group --from ref from the first commit in the workspace: ${fromSHA}`); } const { fileMap } = await (0, file_map_utils_1.createFileMapUsingProjectGraph)(projectGraph); const fileToProjectMap = (0, commit_utils_1.createFileToProjectMap)(fileMap.projectFileMap); const commits = await getCommits(fromSHA, toSHA); changes = (0, commit_utils_1.createChangesFromCommits)(commits, fileMap, fileToProjectMap, nxReleaseConfig.conventionalCommits); } const projectChangelogs = await generateChangelogForProjects({ tree, args, changes, projectsVersionData, releaseGroup, projects: projectNodes, nxReleaseConfig, projectToAdditionalDependencyBumps, }); if (projectChangelogs) { for (const [projectName, projectChangelog] of Object.entries(projectChangelogs)) { // Add the post git task (e.g. create a remote release) for the project changelog, if applicable if (projectChangelog.postGitTask) { postGitTasks.push(projectChangelog.postGitTask); } allProjectChangelogs[projectName] = projectChangelog; } } } } await applyChangesAndExit(args, nxReleaseConfig, tree, toSHA, postGitTasks, commitMessageValues, gitTagValues, releaseGraph); return { workspaceChangelog, projectChangelogs: allProjectChangelogs, }; }; } function resolveChangelogVersions(args, releaseGraph) { if (!args.version && !args.versionData) { throw new Error(`You must provide a version string and/or a versionData object.`); } const versionData = releaseGraph.releaseGroups.reduce((versionData, releaseGroup) => { const releaseGroupProjectNames = Array.from(releaseGraph.releaseGroupToFilteredProjects.get(releaseGroup)); for (const projectName of releaseGroupProjectNames) { if (!args.versionData) { versionData[projectName] = { dockerVersion: args.version, newVersion: args.version, currentVersion: '', // not relevant within changelog/commit generation dependentProjects: [], // not relevant within changelog/commit generation }; continue; } /** * In the case where a versionData object was provided, we need to make sure all projects are present, * otherwise it suggests a filtering mismatch between the version and changelog command invocations. */ if (!args.versionData[projectName]) { throw new Error(`The provided versionData object does not contain a version for project "${projectName}". This suggests a filtering mismatch between the version and changelog command invocations. Please ensure that you have used the same "group" or "project" filter between commands.`); } } return versionData; }, args.versionData || {}); return { workspaceChangelogVersion: args.version, projectsVersionData: versionData, }; } // Can be overridden to something more specific as we resolve the remote release client within nested logic let remoteReleaseProviderName; // If already set, and not the same as the remote release client, append function applyRemoteReleaseProviderName(newRemoteReleaseProviderName) { if (remoteReleaseProviderName) { if (remoteReleaseProviderName !== newRemoteReleaseProviderName) { remoteReleaseProviderName = `${remoteReleaseProviderName}/${newRemoteReleaseProviderName}`; } } else { remoteReleaseProviderName = newRemoteReleaseProviderName; } } async function applyChangesAndExit(args, nxReleaseConfig, tree, toSHA, postGitTasks, commitMessageValues, gitTagValues, releaseGraph) { let latestCommit = toSHA; const changes = tree.listChanges(); /** * In the case where we are expecting changelog file updates, but there is nothing * to flush from the tree, we exit early. This could happen we using conventional * commits, for example. */ const changelogFilesEnabled = checkChangelogFilesEnabled(nxReleaseConfig); if (changelogFilesEnabled && !changes.length) { output_1.output.warn({ title: `No changes detected for changelogs`, bodyLines: [ `No changes were detected for any changelog files, so no changelog entries will be generated.`, ], }); if (!postGitTasks.length) { // No post git tasks (e.g. remote release creation) to perform so we can just exit return; } if ((0, is_ci_1.isCI)()) { output_1.output.warn({ title: `Skipped ${remoteReleaseProviderName ?? 'remote'} release creation because no changes were detected for any changelog files.`, }); return; } /** * Prompt the user to see if they want to create a remote release anyway. * We know that the user has configured remote releases because we have postGitTasks. */ const shouldCreateRemoteReleaseAnyway = await promptForRemoteRelease(); if (!shouldCreateRemoteReleaseAnyway) { return; } for (const postGitTask of postGitTasks) { await postGitTask(latestCommit); } return; } const changedFiles = changes.map((f) => f.path); let deletedFiles = []; if (args.deleteVersionPlans) { const planFiles = new Set(); releaseGraph.releaseGroups.forEach((group) => { const filteredProjects = releaseGraph.releaseGroupToFilteredProjects.get(group); if (group.resolvedVersionPlans) { // Check each version plan individually to see if it should be deleted const plansToDelete = []; for (const plan of group.resolvedVersionPlans) { // Only delete if ALL projects in the version plan are being filtered/released if ((0, version_plan_utils_1.areAllVersionPlanProjectsFiltered)(plan, group, filteredProjects)) { plansToDelete.push(plan); } } // Delete the plans that only affect filtered projects plansToDelete.forEach((plan) => { if (!args.dryRun) { (0, node_fs_1.rmSync)(plan.absolutePath, { recursive: true, force: true }); 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); }); } }); deletedFiles = Array.from(planFiles); } // Generate a new commit for the changes, if configured to do so if (args.gitCommit ?? nxReleaseConfig.changelog.git.commit) { await (0, shared_1.commitChanges)({ changedFiles, deletedFiles, isDryRun: !!args.dryRun, isVerbose: !!args.verbose, gitCommitMessages: commitMessageValues, gitCommitArgs: args.gitCommitArgs || nxReleaseConfig.changelog.git.commitArgs, }); // Resolve the commit we just made latestCommit = await (0, git_1.getCommitHash)('HEAD'); } else if ((args.stageChanges ?? nxReleaseConfig.changelog.git.stageChanges) && changes.length) { output_1.output.logSingleLine(`Staging changed files with git`); await (0, git_1.gitAdd)({ changedFiles, deletedFiles, dryRun: args.dryRun, verbose: args.verbose, }); } // Generate a one or more git tags for the changes, if configured to do so if (args.gitTag ?? nxReleaseConfig.changelog.git.tag) { output_1.output.logSingleLine(`Tagging commit with git`); for (const tag of gitTagValues) { await (0, git_1.gitTag)({ tag, message: args.gitTagMessage || nxReleaseConfig.changelog.git.tagMessage, additionalArgs: args.gitTagArgs || nxReleaseConfig.changelog.git.tagArgs, dryRun: args.dryRun, verbose: args.verbose, }); } } if (args.gitPush ?? nxReleaseConfig.changelog.git.push) { output_1.output.logSingleLine(`Pushing to git remote "${args.gitRemote ?? 'origin'}"`); await (0, git_1.gitPush)({ gitRemote: args.gitRemote, dryRun: args.dryRun, verbose: args.verbose, additionalArgs: args.gitPushArgs || nxReleaseConfig.changelog.git.pushArgs, }); } // Run any post-git tasks in series for (const postGitTask of postGitTasks) { await postGitTask(latestCommit); } return; } async function generateChangelogForWorkspace({ tree, args, nxReleaseConfig, workspaceChangelogVersion, changes, }) { const config = nxReleaseConfig.changelog.workspaceChangelog; // The entire feature is disabled at the workspace level, exit early if (config === false) { return; } // If explicitly null it must mean that no changes were detected (e.g. when using conventional commits), so do nothing if (workspaceChangelogVersion === null) { return; } // The user explicitly passed workspaceChangelog=true but does not have a workspace changelog config in nx.json if (!config) { throw new Error(`Workspace changelog is enabled but no configuration was provided. Please provide a workspaceChangelog object in your nx.json`); } if (Object.entries(nxReleaseConfig.groups).length > 1) { output_1.output.warn({ title: `Workspace changelog is enabled, but you have multiple release groups configured. This is not supported, so workspace changelog will be disabled.`, bodyLines: [ `A single workspace version cannot be determined when defining multiple release groups because versions can differ between each group.`, `Project level changelogs can be enabled with the "release.changelog.projectChangelogs" property.`, ], }); return; } if (Object.values(nxReleaseConfig.groups)[0].projectsRelationship === 'independent') { output_1.output.warn({ title: `Workspace changelog is enabled, but you have configured an independent projects relationship. This is not supported, so workspace changelog will be disabled.`, bodyLines: [ `A single workspace version cannot be determined when using independent projects because versions can differ between each project.`, `Project level changelogs can be enabled with the "release.changelog.projectChangelogs" property.`, ], }); return; } // Only trigger interactive mode for the workspace changelog if the user explicitly requested it via "all" or "workspace" const interactive = args.interactive === 'all' || args.interactive === 'workspace'; const dryRun = !!args.dryRun; const gitRemote = args.gitRemote; const ChangelogRendererClass = (0, resolve_changelog_renderer_1.resolveChangelogRenderer)(config.renderer); let interpolatedTreePath = config.file || ''; if (interpolatedTreePath) { interpolatedTreePath = (0, utils_1.interpolate)(interpolatedTreePath, { projectName: '', // n/a for the workspace changelog projectRoot: '', // n/a for the workspace changelog workspaceRoot: '', // within the tree, workspaceRoot is the root }); } const releaseVersion = new shared_1.ReleaseVersion({ version: workspaceChangelogVersion, releaseTagPattern: nxReleaseConfig.releaseTag.pattern, releaseGroupName: Object.keys(nxReleaseConfig.groups)[0], }); if (interpolatedTreePath) { const prefix = dryRun ? 'Previewing' : 'Generating'; output_1.output.log({ title: `${prefix} an entry in ${interpolatedTreePath} for ${chalk.white(releaseVersion.gitTag)}`, }); } const remoteReleaseClient = await (0, remote_release_client_1.createRemoteReleaseClient)(config.createRelease, gitRemote); applyRemoteReleaseProviderName(remoteReleaseClient.remoteReleaseProviderName); const changelogRenderer = new ChangelogRendererClass({ changes, changelogEntryVersion: releaseVersion.rawVersion, project: null, isVersionPlans: !!nxReleaseConfig.versionPlans, entryWhenNoChanges: config.entryWhenNoChanges, changelogRenderOptions: config.renderOptions, conventionalCommitsConfig: nxReleaseConfig.conventionalCommits, remoteReleaseClient, }); let contents = await changelogRenderer.render(); /** * If interactive mode, make the changelog contents available for the user to modify in their editor of choice, * in a similar style to git interactive rebases/merges. */ if (interactive) { const tmpDir = (0, tmp_1.dirSync)().name; const changelogPath = (0, path_1.joinPathFragments)(tmpDir, // Include the tree path in the name so that it is easier to identify which changelog file is being edited `PREVIEW__${interpolatedTreePath.replace(/\//g, '_')}`); (0, node_fs_1.writeFileSync)(changelogPath, contents); await (0, launch_editor_1.launchEditor)(changelogPath); contents = (0, node_fs_1.readFileSync)(changelogPath, 'utf-8'); } if (interpolatedTreePath) { let rootChangelogContents = tree.exists(interpolatedTreePath) ? tree.read(interpolatedTreePath).toString() : ''; if (rootChangelogContents && !args.replaceExistingContents) { // NOTE: right now existing releases are always expected to be in markdown format, but in the future we could potentially support others via a custom parser option const changelogReleases = (0, markdown_1.parseChangelogMarkdown)(rootChangelogContents).releases; const existingVersionToUpdate = changelogReleases.find((r) => r.version === releaseVersion.rawVersion); if (existingVersionToUpdate) { rootChangelogContents = rootChangelogContents.replace(`## ${releaseVersion.rawVersion}\n\n\n${existingVersionToUpdate.body}`, contents); } else { // No existing version, simply prepend the new release to the top of the file rootChangelogContents = `${contents}\n\n${rootChangelogContents}`; } } else { // No existing changelog contents, or replaceExistingContents is true, simply use the generated contents directly rootChangelogContents = contents; } tree.write(interpolatedTreePath, rootChangelogContents); (0, print_changes_1.printAndFlushChanges)(tree, !!dryRun, 3, false, shared_1.noDiffInChangelogMessage); } const postGitTask = args.createRelease !== false && config.createRelease ? remoteReleaseClient.createPostGitTask(releaseVersion, contents, dryRun) : null; return { releaseVersion, contents, postGitTask, }; } async function generateChangelogForProjects({ tree, args, changes, projectsVersionData, releaseGroup, projects, nxReleaseConfig, projectToAdditionalDependencyBumps, }) { const config = releaseGroup.changelog; // The entire feature is disabled at the release group level, exit early if (config === false) { return; } // Only trigger interactive mode for the project changelog if the user explicitly requested it via "all" or "projects" const interactive = args.interactive === 'all' || args.interactive === 'projects'; const dryRun = !!args.dryRun; const gitRemote = args.gitRemote; const ChangelogRendererClass = (0, resolve_changelog_renderer_1.resolveChangelogRenderer)(config.renderer); // Maximum of one remote release client per release group const remoteReleaseClient = await (0, remote_release_client_1.createRemoteReleaseClient)(config.createRelease, gitRemote); applyRemoteReleaseProviderName(remoteReleaseClient.remoteReleaseProviderName); const projectChangelogs = {}; for (const project of projects) { let interpolatedTreePath = config.file || ''; if (interpolatedTreePath) { interpolatedTreePath = (0, utils_1.interpolate)(interpolatedTreePath, { projectName: project.name, projectRoot: project.data.root, workspaceRoot: '', // within the tree, workspaceRoot is the root }); } /** * newVersion will be null in the case that no changes were detected (e.g. in conventional commits mode), * no changelog entry is relevant in that case. */ if (!projectsVersionData[project.name] || (projectsVersionData[project.name].newVersion === null && !projectsVersionData[project.name].dockerVersion)) { continue; } const preferDockerVersion = (0, shared_1.shouldPreferDockerVersionForReleaseGroup)(releaseGroup); const releaseVersion = new shared_1.ReleaseVersion({ version: (preferDockerVersion === true || preferDockerVersion === 'both') && projectsVersionData[project.name].dockerVersion ? projectsVersionData[project.name].dockerVersion : projectsVersionData[project.name].newVersion, releaseTagPattern: releaseGroup.releaseTag.pattern, projectName: project.name, releaseGroupName: releaseGroup.name, }); if (interpolatedTreePath) { const prefix = dryRun ? 'Previewing' : 'Generating'; output_1.output.log({ title: `${prefix} an entry in ${interpolatedTreePath} for ${chalk.white(releaseVersion.gitTag)}`, }); } const changelogRenderer = new ChangelogRendererClass({ changes, changelogEntryVersion: releaseVersion.rawVersion, project: project.name, entryWhenNoChanges: typeof config.entryWhenNoChanges === 'string' ? (0, utils_1.interpolate)(config.entryWhenNoChanges, { projectName: project.name, projectRoot: project.data.root, workspaceRoot: '', // within the tree, workspaceRoot is the root }) : false, changelogRenderOptions: config.renderOptions, isVersionPlans: !!releaseGroup.versionPlans, conventionalCommitsConfig: nxReleaseConfig.conventionalCommits, dependencyBumps: projectToAdditionalDependencyBumps.get(project.name), remoteReleaseClient, }); let contents = await changelogRenderer.render(); /** * If interactive mode, make the changelog contents available for the user to modify in their editor of choice, * in a similar style to git interactive rebases/merges. */ if (interactive) { const tmpDir = (0, tmp_1.dirSync)().name; const changelogPath = (0, path_1.joinPathFragments)(tmpDir, // Include the tree path in the name so that it is easier to identify which changelog file is being edited `PREVIEW__${interpolatedTreePath.replace(/\//g, '_')}`); (0, node_fs_1.writeFileSync)(changelogPath, contents); await (0, launch_editor_1.launchEditor)(changelogPath); contents = (0, node_fs_1.readFileSync)(changelogPath, 'utf-8'); } if (interpolatedTreePath) { let changelogContents = tree.exists(interpolatedTreePath) ? tree.read(interpolatedTreePath).toString() : ''; if (changelogContents) { // NOTE: right now existing releases are always expected to be in markdown format, but in the future we could potentially support others via a custom parser option const changelogReleases = (0, markdown_1.parseChangelogMarkdown)(changelogContents).releases; const existingVersionToUpdate = changelogReleases.find((r) => r.version === releaseVersion.rawVersion); if (existingVersionToUpdate) { changelogContents = changelogContents.replace(`## ${releaseVersion.rawVersion}\n\n\n${existingVersionToUpdate.body}`, contents); } else { // No existing version, simply prepend the new release to the top of the file changelogContents = `${contents}\n\n${changelogContents}`; } } else { // No existing changelog contents, simply create a new one using the generated contents changelogContents = contents; } tree.write(interpolatedTreePath, changelogContents); (0, print_changes_1.printAndFlushChanges)(tree, !!dryRun, 3, false, shared_1.noDiffInChangelogMessage, // Only print the change for the current changelog file at this point (f) => f.path === interpolatedTreePath); } const postGitTask = args.createRelease !== false && config.createRelease ? remoteReleaseClient.createPostGitTask(releaseVersion, contents, dryRun) : null; projectChangelogs[project.name] = { releaseVersion, contents, postGitTask, }; } return projectChangelogs; } function checkChangelogFilesEnabled(nxReleaseConfig) { if (nxReleaseConfig.changelog.workspaceChangelog && nxReleaseConfig.changelog.workspaceChangelog.file) { return true; } for (const releaseGroup of Object.values(nxReleaseConfig.groups)) { if (releaseGroup.changelog && releaseGroup.changelog.file) { return true; } } return false; } async function getCommits(fromSHA, toSHA) { const rawCommits = await (0, git_1.getGitDiff)(fromSHA, toSHA); // Parse as conventional commits return (0, git_1.parseCommits)(rawCommits); } async function filterProjectCommits({ fromSHA, toSHA, projectPath, }) { const allCommits = await getCommits(fromSHA, toSHA); return allCommits.filter((c) => c.affectedFiles.find((f) => f.startsWith(projectPath))); } async function promptForRemoteRelease() { try { const result = await (0, enquirer_1.prompt)([ { name: 'confirmation', message: `Do you want to create a ${remoteReleaseProviderName ?? 'remote'} release anyway?`, type: 'confirm', }, ]); return result.confirmation; } catch { // Ensure the cursor is always restored process.stdout.write('\u001b[?25h'); // Handle the case where the user exits the prompt with ctrl+c return false; } } async function resolveWorkspaceChangelogChanges({ releaseGraph, nxReleaseConfig, args, workspacePreid, projectsPreid, useAutomaticFromRef, toSHA, fromSHA, }) { // NOTE: If there are multiple release groups, we'll just skip the workspace changelog anyway. const versionPlansEnabledForWorkspaceChangelog = releaseGraph.releaseGroups[0]?.resolvedVersionPlans; // Derive changes from version plan files if (versionPlansEnabledForWorkspaceChangelog) { return resolveWorkspaceChangelogFromVersionPlans(releaseGraph); } // Derive changes from commits return resolveWorkspaceChangelogFromCommits({ nxReleaseConfig, args, workspacePreid, projectsPreid, useAutomaticFromRef, toSHA, fromSHA, }); } function resolveWorkspaceChangelogFromVersionPlans(releaseGraph) { const firstReleaseGroup = releaseGraph.releaseGroups[0]; // We only produce a workspace changelog in the case of a single, fixed relationship release group if (releaseGraph.releaseGroups.length !== 1 || firstReleaseGroup?.projectsRelationship !== 'fixed') { return []; } const versionPlans = firstReleaseGroup.resolvedVersionPlans; return (0, version_plan_utils_2.createChangesFromGroupVersionPlans)(versionPlans); } async function resolveWorkspaceChangelogFromCommits({ nxReleaseConfig, args, workspacePreid, projectsPreid, useAutomaticFromRef, toSHA, fromSHA, }) { // Use the cached fromSHA if available, otherwise throw an error if (!fromSHA) { 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.releaseTag.pattern" property in nx.json to match the structure of your repository's git tags.`); } const workspaceChangelogFromSHA = fromSHA; const commits = await getCommits(workspaceChangelogFromSHA, toSHA); // For workspace changelog, all commits affect all projects return (0, commit_utils_1.filterHiddenChanges)(commits.map((c) => (0, commit_utils_1.mapCommitToChange)(c, '*')), nxReleaseConfig.conventionalCommits); } function extractPreid(version) { if (!(0, shared_1.isPrerelease)(version)) { return undefined; } const preid = (0, semver_1.prerelease)(version)?.[0]; if (typeof preid === 'string') { if (preid.trim() === '') { return undefined; } return preid; } if (typeof preid === 'number') { return preid.toString(); } return undefined; }