nx
Version:
891 lines (890 loc) • 48.3 kB
JavaScript
"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;
}