diff --git a/e2e/release/src/independent-projects.test.ts b/e2e/release/src/independent-projects.test.ts index 8d1cc646ecbf5..a6e6ba3f4060b 100644 --- a/e2e/release/src/independent-projects.test.ts +++ b/e2e/release/src/independent-projects.test.ts @@ -191,8 +191,10 @@ describe('nx release - independent projects', () => { {project-name} 🔍 Reading data for package "@proj/{project-name}" from {project-name}/package.json {project-name} 📄 Resolved the current version as 0.0.0 from {project-name}/package.json {project-name} 📄 Using the provided version specifier "999.9.9-package.3". + {project-name} ⚠️ Warning, the following packages depend on "{project-name}" but have been filtered out via --projects, and therefore will not be updated: + - {project-name} + => You can adjust this behavior by setting \`version.generatorOptions.updateDependents.when\` to "always" {project-name} ✍️ New version 999.9.9-package.3 written to {project-name}/package.json - {project-name} ✍️ Applying new version 999.9.9-package.3 to 1 package which depends on {project-name} "name": "@proj/{project-name}", @@ -201,10 +203,6 @@ describe('nx release - independent projects', () => { "scripts": { - "dependencies": { - - "@proj/{project-name}": "0.0.0" - + "@proj/{project-name}": "999.9.9-package.3" - } NX Staging changed files with git diff --git a/packages/js/src/generators/release-version/release-version.spec.ts b/packages/js/src/generators/release-version/release-version.spec.ts index 3f63c86a7ad57..6895638bafe4f 100644 --- a/packages/js/src/generators/release-version/release-version.spec.ts +++ b/packages/js/src/generators/release-version/release-version.spec.ts @@ -444,78 +444,252 @@ To fix this you will either need to add a package.json file at that location, or `); }); - it(`should update dependents even when filtering to a subset of projects which do not include those dependents`, async () => { - expect(readJson(tree, 'libs/my-lib/package.json').version).toEqual( - '0.0.1' - ); - expect( - readJson(tree, 'libs/project-with-dependency-on-my-pkg/package.json') - ).toMatchInlineSnapshot(` - { - "dependencies": { - "my-lib": "0.0.1", - }, - "name": "project-with-dependency-on-my-pkg", - "version": "0.0.1", - } - `); - expect( - readJson( - tree, - 'libs/project-with-devDependency-on-my-pkg/package.json' - ) - ).toMatchInlineSnapshot(` - { - "devDependencies": { - "my-lib": "0.0.1", - }, - "name": "project-with-devDependency-on-my-pkg", - "version": "0.0.1", - } - `); + describe('updateDependentsOptions', () => { + it(`should not update dependents when filtering to a subset of projects by default`, async () => { + expect(readJson(tree, 'libs/my-lib/package.json').version).toEqual( + '0.0.1' + ); + expect( + readJson( + tree, + 'libs/project-with-dependency-on-my-pkg/package.json' + ) + ).toMatchInlineSnapshot(` + { + "dependencies": { + "my-lib": "0.0.1", + }, + "name": "project-with-dependency-on-my-pkg", + "version": "0.0.1", + } + `); + expect( + readJson( + tree, + 'libs/project-with-devDependency-on-my-pkg/package.json' + ) + ).toMatchInlineSnapshot(` + { + "devDependencies": { + "my-lib": "0.0.1", + }, + "name": "project-with-devDependency-on-my-pkg", + "version": "0.0.1", + } + `); - await releaseVersionGenerator(tree, { - projects: [projectGraph.nodes['my-lib']], // version only my-lib - projectGraph, - specifier: '9.9.9', // user CLI specifier override set, no prompting should occur - currentVersionResolver: 'disk', - specifierSource: 'prompt', - releaseGroup: createReleaseGroup('independent'), + await releaseVersionGenerator(tree, { + projects: [projectGraph.nodes['my-lib']], // version only my-lib + projectGraph, + specifier: '9.9.9', // user CLI specifier override set, no prompting should occur + currentVersionResolver: 'disk', + specifierSource: 'prompt', + releaseGroup: createReleaseGroup('independent'), + }); + + expect(readJson(tree, 'libs/my-lib/package.json')) + .toMatchInlineSnapshot(` + { + "name": "my-lib", + "version": "9.9.9", + } + `); + + expect( + readJson( + tree, + 'libs/project-with-dependency-on-my-pkg/package.json' + ) + ).toMatchInlineSnapshot(` + { + "dependencies": { + "my-lib": "0.0.1", + }, + "name": "project-with-dependency-on-my-pkg", + "version": "0.0.1", + } + `); + expect( + readJson( + tree, + 'libs/project-with-devDependency-on-my-pkg/package.json' + ) + ).toMatchInlineSnapshot(` + { + "devDependencies": { + "my-lib": "0.0.1", + }, + "name": "project-with-devDependency-on-my-pkg", + "version": "0.0.1", + } + `); }); - expect(readJson(tree, 'libs/my-lib/package.json')) - .toMatchInlineSnapshot(` - { - "name": "my-lib", - "version": "9.9.9", - } - `); + it(`should not update dependents when filtering to a subset of projects by default, if "when" is set to "auto"`, async () => { + expect(readJson(tree, 'libs/my-lib/package.json').version).toEqual( + '0.0.1' + ); + expect( + readJson( + tree, + 'libs/project-with-dependency-on-my-pkg/package.json' + ) + ).toMatchInlineSnapshot(` + { + "dependencies": { + "my-lib": "0.0.1", + }, + "name": "project-with-dependency-on-my-pkg", + "version": "0.0.1", + } + `); + expect( + readJson( + tree, + 'libs/project-with-devDependency-on-my-pkg/package.json' + ) + ).toMatchInlineSnapshot(` + { + "devDependencies": { + "my-lib": "0.0.1", + }, + "name": "project-with-devDependency-on-my-pkg", + "version": "0.0.1", + } + `); - expect( - readJson(tree, 'libs/project-with-dependency-on-my-pkg/package.json') - ).toMatchInlineSnapshot(` - { - "dependencies": { - "my-lib": "9.9.9", + await releaseVersionGenerator(tree, { + projects: [projectGraph.nodes['my-lib']], // version only my-lib + projectGraph, + specifier: '9.9.9', // user CLI specifier override set, no prompting should occur + currentVersionResolver: 'disk', + specifierSource: 'prompt', + releaseGroup: createReleaseGroup('independent'), + updateDependents: { + when: 'auto', }, - "name": "project-with-dependency-on-my-pkg", - "version": "0.0.1", - } - `); - expect( - readJson( - tree, - 'libs/project-with-devDependency-on-my-pkg/package.json' - ) - ).toMatchInlineSnapshot(` - { - "devDependencies": { - "my-lib": "9.9.9", + }); + + expect(readJson(tree, 'libs/my-lib/package.json')) + .toMatchInlineSnapshot(` + { + "name": "my-lib", + "version": "9.9.9", + } + `); + + expect( + readJson( + tree, + 'libs/project-with-dependency-on-my-pkg/package.json' + ) + ).toMatchInlineSnapshot(` + { + "dependencies": { + "my-lib": "0.0.1", + }, + "name": "project-with-dependency-on-my-pkg", + "version": "0.0.1", + } + `); + expect( + readJson( + tree, + 'libs/project-with-devDependency-on-my-pkg/package.json' + ) + ).toMatchInlineSnapshot(` + { + "devDependencies": { + "my-lib": "0.0.1", + }, + "name": "project-with-devDependency-on-my-pkg", + "version": "0.0.1", + } + `); + }); + + it(`should update dependents even when filtering to a subset of projects which do not include those dependents, if "when" is "always"`, async () => { + expect(readJson(tree, 'libs/my-lib/package.json').version).toEqual( + '0.0.1' + ); + expect( + readJson( + tree, + 'libs/project-with-dependency-on-my-pkg/package.json' + ) + ).toMatchInlineSnapshot(` + { + "dependencies": { + "my-lib": "0.0.1", + }, + "name": "project-with-dependency-on-my-pkg", + "version": "0.0.1", + } + `); + expect( + readJson( + tree, + 'libs/project-with-devDependency-on-my-pkg/package.json' + ) + ).toMatchInlineSnapshot(` + { + "devDependencies": { + "my-lib": "0.0.1", + }, + "name": "project-with-devDependency-on-my-pkg", + "version": "0.0.1", + } + `); + + await releaseVersionGenerator(tree, { + projects: [projectGraph.nodes['my-lib']], // version only my-lib + projectGraph, + specifier: '9.9.9', // user CLI specifier override set, no prompting should occur + currentVersionResolver: 'disk', + specifierSource: 'prompt', + releaseGroup: createReleaseGroup('independent'), + updateDependents: { + when: 'always', }, - "name": "project-with-devDependency-on-my-pkg", - "version": "0.0.1", - } - `); + }); + + expect(readJson(tree, 'libs/my-lib/package.json')) + .toMatchInlineSnapshot(` + { + "name": "my-lib", + "version": "9.9.9", + } + `); + + expect( + readJson( + tree, + 'libs/project-with-dependency-on-my-pkg/package.json' + ) + ).toMatchInlineSnapshot(` + { + "dependencies": { + "my-lib": "9.9.9", + }, + "name": "project-with-dependency-on-my-pkg", + "version": "0.0.2", + } + `); + expect( + readJson( + tree, + 'libs/project-with-devDependency-on-my-pkg/package.json' + ) + ).toMatchInlineSnapshot(` + { + "devDependencies": { + "my-lib": "9.9.9", + }, + "name": "project-with-devDependency-on-my-pkg", + "version": "0.0.2", + } + `); + }); }); }); }); diff --git a/packages/js/src/generators/release-version/release-version.ts b/packages/js/src/generators/release-version/release-version.ts index 45c5631a1e366..265a9db76f3af 100644 --- a/packages/js/src/generators/release-version/release-version.ts +++ b/packages/js/src/generators/release-version/release-version.ts @@ -1,7 +1,10 @@ import { + ProjectGraph, + ProjectGraphDependency, ProjectGraphProjectNode, Tree, formatFiles, + joinPathFragments, output, readJson, updateJson, @@ -32,7 +35,10 @@ import * as ora from 'ora'; import { prerelease } from 'semver'; import { parseRegistryOptions } from '../../utils/npm-config'; import { ReleaseVersionGeneratorSchema } from './schema'; -import { resolveLocalPackageDependencies } from './utils/resolve-local-package-dependencies'; +import { + LocalPackageDependency, + resolveLocalPackageDependencies, +} from './utils/resolve-local-package-dependencies'; import { updateLockFile } from './utils/update-lock-file'; export async function releaseVersionGenerator( @@ -73,7 +79,20 @@ Valid values are: ${validReleaseVersionPrefixes options.fallbackCurrentVersionResolver = 'disk'; } - const projects = options.projects; + // Set defaults for updateDependentsOptions + const updateDependentsOptions = options.updateDependents ?? {}; + // "auto" means "only when the dependents are already included in the current batch", and is the default + updateDependentsOptions.when = updateDependentsOptions.when || 'auto'; + // in the case "when" is set to "always", what semver bump should be applied to the dependents which are not included in the current batch + updateDependentsOptions.bump = updateDependentsOptions.bump || 'patch'; + + // Sort the projects topologically because there are cases where we need to perform updates based on dependent relationships + // TODO: maybe move this sorting to the command level? + const projects = sortProjectsTopologically( + options.projectGraph, + options.projects + ); + const projectToDependencyBumps = new Map(); const resolvePackageRoot = createResolvePackageRoot(options.packageRoot); @@ -236,6 +255,11 @@ To fix this you will either need to add a package.json file at that location, or } case 'disk': currentVersion = currentVersionFromDisk; + if (!currentVersion) { + throw new Error( + `Unable to determine the current version for project "${project.name}" from ${packageJsonPath}` + ); + } log( `📄 Resolved the current version as ${currentVersion} from ${packageJsonPath}` ); @@ -351,13 +375,21 @@ To fix this you will either need to add a package.json file at that location, or ); if (!specifier) { + if (projectToDependencyBumps.has(projectName)) { + // No applicable changes to the project directly by the user, but we have updated one or more dependencies from the current batch already, so it does need to be bumped + specifier = updateDependentsOptions.bump; + log( + `📄 Resolved the specifier as "${specifier}" based on "release.version.generatorOptions.updateDependentsOptions.bump"` + ); + break; + } log( `🚫 No changes were detected using git history and the conventional commits standard.` ); break; } - // TODO: reevaluate this logic/workflow for independent projects + // TODO: reevaluate this prerelease logic/workflow for independent projects // // Always assume that if the current version is a prerelease, then the next version should be a prerelease. // Users must manually graduate from a prerelease to a release by providing an explicit specifier. @@ -422,12 +454,46 @@ To fix this you will either need to add a package.json file at that location, or options.releaseGroup.projectsRelationship === 'independent' ); - const dependentProjects = Object.values(localPackageDependencies) + const allDependentProjects = Object.values(localPackageDependencies) .flat() .filter((localPackageDependency) => { return localPackageDependency.target === project.name; }); + const dependentProjectsInCurrentBatch = []; + const dependentProjectsOutsideCurrentBatch = []; + + for (const dependentProject of allDependentProjects) { + const isInCurrentBatch = options.projects.some( + (project) => project.name === dependentProject.source + ); + if (!isInCurrentBatch) { + dependentProjectsOutsideCurrentBatch.push(dependentProject); + } else { + dependentProjectsInCurrentBatch.push(dependentProject); + } + } + + // If not always updating dependents (when they don't already appear in the batch itself), print a warning to the user about what is being skipped and how to change it + if (updateDependentsOptions.when === 'auto') { + if (dependentProjectsOutsideCurrentBatch.length > 0) { + let logMsg = `⚠️ Warning, the following packages depend on "${project.name}"`; + if (options.releaseGroup.name === IMPLICIT_DEFAULT_RELEASE_GROUP) { + logMsg += ` but have been filtered out via --projects, and therefore will not be updated:`; + } else { + logMsg += ` but are either not part of the current release group "${options.releaseGroup.name}", or have been filtered out via --projects, and therefore will not be updated:`; + } + const indent = Array.from(new Array(projectName.length + 4)) + .map(() => ' ') + .join(''); + logMsg += `\n${dependentProjectsOutsideCurrentBatch + .map((dependentProject) => `${indent}- ${dependentProject.source}`) + .join('\n')}`; + logMsg += `\n${indent}=> You can adjust this behavior by setting \`version.generatorOptions.updateDependents.when\` to "always"`; + log(logMsg); + } + } + if (!currentVersion) { throw new Error( `The current version for project "${project.name}" could not be resolved. Please report this on https://github.com/nrwl/nx` @@ -436,9 +502,8 @@ To fix this you will either need to add a package.json file at that location, or versionData[projectName] = { currentVersion, - dependentProjects, - // @ts-ignore: The types will be updated in a future version of Nx newVersion: null, // will stay as null in the final result in the case that no changes are detected + dependentProjects: allDependentProjects, }; if (!specifier) { @@ -462,39 +527,44 @@ To fix this you will either need to add a package.json file at that location, or log(`✍️ New version ${newVersion} written to ${packageJsonPath}`); - if (dependentProjects.length > 0) { - log( - `✍️ Applying new version ${newVersion} to ${ - dependentProjects.length - } ${ - dependentProjects.length > 1 - ? 'packages which depend' - : 'package which depends' - } on ${project.name}` - ); + if (allDependentProjects.length > 0) { + const totalProjectsToUpdate = + updateDependentsOptions.when === 'always' + ? allDependentProjects.length + : dependentProjectsInCurrentBatch.length; + if (totalProjectsToUpdate > 0) { + log( + `✍️ Applying new version ${newVersion} to ${totalProjectsToUpdate} ${ + totalProjectsToUpdate > 1 + ? 'packages which depend' + : 'package which depends' + } on ${project.name}` + ); + } } - for (const dependentProject of dependentProjects) { - const dependentPackageRoot = projectNameToPackageRootMap.get( - dependentProject.source + const updateDependentProjectAndAddToVersionData = ({ + dependentProject, + forceVersionBump, + }: { + dependentProject: LocalPackageDependency; + forceVersionBump: 'major' | 'minor' | 'patch' | false; + }) => { + const updatedFilePath = joinPathFragments( + projectNameToPackageRootMap.get(dependentProject.source), + 'package.json' ); - if (!dependentPackageRoot) { - throw new Error( - `The dependent project "${dependentProject.source}" does not have a packageRoot available. Please report this issue on https://github.com/nrwl/nx` - ); - } - updateJson(tree, join(dependentPackageRoot, 'package.json'), (json) => { + updateJson(tree, updatedFilePath, (json) => { // Auto (i.e.infer existing) by default let versionPrefix = options.versionPrefix ?? 'auto'; + const currentDependencyVersion = + json[dependentProject.dependencyCollection][packageName]; // For auto, we infer the prefix based on the current version of the dependent if (versionPrefix === 'auto') { versionPrefix = ''; // we don't want to end up printing auto - - const current = - json[dependentProject.dependencyCollection][packageName]; - if (current) { - const prefixMatch = current.match(/^[~^]/); + if (currentDependencyVersion) { + const prefixMatch = currentDependencyVersion.match(/^[~^]/); if (prefixMatch) { versionPrefix = prefixMatch[0]; } else { @@ -502,11 +572,59 @@ To fix this you will either need to add a package.json file at that location, or } } } - json[dependentProject.dependencyCollection][ - packageName - ] = `${versionPrefix}${newVersion}`; + + // Apply the new version of the dependency to the dependent + const newDepVersion = `${versionPrefix}${newVersion}`; + json[dependentProject.dependencyCollection][packageName] = + newDepVersion; + + // Bump the dependent's version if applicable and record it in the version data + if (forceVersionBump) { + const currentPackageVersion = json.version; + const newPackageVersion = deriveNewSemverVersion( + currentPackageVersion, + forceVersionBump, + options.preid + ); + json.version = newPackageVersion; + versionData[dependentProject.source] = { + currentVersion: currentPackageVersion, + newVersion: newPackageVersion, + dependentProjects: [], // TODO: missing recursion here? + }; + } + return json; }); + }; + + for (const dependentProject of dependentProjectsInCurrentBatch) { + if (projectToDependencyBumps.has(dependentProject.source)) { + const dependencyBumps = projectToDependencyBumps.get( + dependentProject.source + ); + dependencyBumps.add(projectName); + } else { + projectToDependencyBumps.set( + dependentProject.source, + new Set([projectName]) + ); + } + updateDependentProjectAndAddToVersionData({ + dependentProject, + // We don't force bump because we know they will come later in the topologically sorted projects loop and may have their own version update logic to take into account + forceVersionBump: false, + }); + } + + if (updateDependentsOptions.when === 'always') { + for (const dependentProject of dependentProjectsOutsideCurrentBatch) { + updateDependentProjectAndAddToVersionData({ + dependentProject, + // For these additional dependents, we need to update their package.json version as well because we know they will not come later in the topologically sorted projects loop + forceVersionBump: updateDependentsOptions.bump, + }); + } } } @@ -583,3 +701,58 @@ function getColor(projectName: string) { return colors[colorIndex]; } + +function sortProjectsTopologically( + projectGraph: ProjectGraph, + projectNodes: ProjectGraphProjectNode[] +): ProjectGraphProjectNode[] { + const edges = new Map( + projectNodes.map((node) => [node, 0]) + ); + + const filteredDependencies: ProjectGraphDependency[] = []; + for (const node of projectNodes) { + const deps = projectGraph.dependencies[node.name]; + if (deps) { + filteredDependencies.push( + ...deps.filter((dep) => projectNodes.find((n) => n.name === dep.target)) + ); + } + } + + filteredDependencies.forEach((dep) => { + const sourceNode = projectGraph.nodes[dep.source]; + // dep.source depends on dep.target + edges.set(sourceNode, (edges.get(sourceNode) || 0) + 1); + }); + + // Initialize queue with projects that have no dependencies + const processQueue = [...edges] + .filter(([_, count]) => count === 0) + .map(([node]) => node); + const sortedProjects = []; + + while (processQueue.length > 0) { + const node = processQueue.shift(); + sortedProjects.push(node); + + // Process each project that depends on the current node + filteredDependencies.forEach((dep) => { + const dependentNode = projectGraph.nodes[dep.source]; + const count = edges.get(dependentNode) - 1; + edges.set(dependentNode, count); + if (count === 0) { + processQueue.push(dependentNode); + } + }); + } + + // TODO: should hopefully be impossible by this point? + if (sortedProjects.length !== projectNodes.length) { + throw new Error( + 'Cycle detected or a disconnected node exists that was not included in the input set.' + ); + } + + return sortedProjects; +} diff --git a/packages/js/src/generators/release-version/utils/resolve-local-package-dependencies.ts b/packages/js/src/generators/release-version/utils/resolve-local-package-dependencies.ts index a2a576bcc93d5..76c123e516cea 100644 --- a/packages/js/src/generators/release-version/utils/resolve-local-package-dependencies.ts +++ b/packages/js/src/generators/release-version/utils/resolve-local-package-dependencies.ts @@ -12,7 +12,7 @@ import { satisfies } from 'semver'; import { Package } from './package'; import { resolveVersionSpec } from './resolve-version-spec'; -interface LocalPackageDependency extends ProjectGraphDependency { +export interface LocalPackageDependency extends ProjectGraphDependency { /** * The rawVersionSpec contains the value of the version spec as it was defined in the package.json * of the dependent project. This can be useful in cases where the version spec is a range, path or diff --git a/packages/js/src/generators/release-version/utils/update-lock-file.ts b/packages/js/src/generators/release-version/utils/update-lock-file.ts index 94ac233a757e0..86f6a055dbccf 100644 --- a/packages/js/src/generators/release-version/utils/update-lock-file.ts +++ b/packages/js/src/generators/release-version/utils/update-lock-file.ts @@ -125,8 +125,10 @@ function execLockFileUpdate( env: object = {} ): void { try { + const LARGE_BUFFER = 1024 * 1000000; execSync(command, { cwd, + maxBuffer: LARGE_BUFFER, env: { ...process.env, ...env, diff --git a/packages/nx/release/changelog-renderer/index.spec.ts b/packages/nx/release/changelog-renderer/index.spec.ts index 04acbf7350868..3cc321578eb10 100644 --- a/packages/nx/release/changelog-renderer/index.spec.ts +++ b/packages/nx/release/changelog-renderer/index.spec.ts @@ -671,4 +671,82 @@ describe('defaultChangelogRenderer()', () => { `); }); }); + + describe('dependency bumps', () => { + it('should render the dependency bumps in addition to the commits', async () => { + expect( + await defaultChangelogRenderer({ + projectGraph, + commits, + releaseVersion: 'v1.1.0', + entryWhenNoChanges: false as const, + changelogRenderOptions: { + authors: true, + }, + conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG, + project: 'pkg-a', + dependencyBumps: [ + { + dependencyName: 'pkg-b', + newVersion: '2.0.0', + }, + ], + }) + ).toMatchInlineSnapshot(` + "## v1.1.0 + + + ### 🚀 Features + + - **pkg-a:** new hotness + + + ### 🩹 Fixes + + - all packages fixed + + - **pkg-a:** squashing bugs + + + ### 🧱 Updated Dependencies + + - Updated pkg-b to 2.0.0 + + + ### ❤️ Thank You + + - James Henry" + `); + }); + + it('should render the dependency bumps and release version title even when there are no commits', async () => { + expect( + await defaultChangelogRenderer({ + projectGraph, + commits: [], + releaseVersion: 'v3.1.0', + entryWhenNoChanges: + 'should not be printed because we have dependency bumps', + changelogRenderOptions: { + authors: true, + }, + conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG, + project: 'pkg-a', + dependencyBumps: [ + { + dependencyName: 'pkg-b', + newVersion: '4.0.0', + }, + ], + }) + ).toMatchInlineSnapshot(` + "## v3.1.0 + + + ### 🧱 Updated Dependencies + + - Updated pkg-b to 4.0.0" + `); + }); + }); }); diff --git a/packages/nx/release/changelog-renderer/index.ts b/packages/nx/release/changelog-renderer/index.ts index 00e1723e0d2cd..783c40711c0ec 100644 --- a/packages/nx/release/changelog-renderer/index.ts +++ b/packages/nx/release/changelog-renderer/index.ts @@ -18,6 +18,16 @@ const axios = _axios as any as typeof _axios['default']; */ export type ChangelogRenderOptions = Record; +/** + * When versioning projects independently and enabling `"updateDependents": "always"`, there could + * be additional dependency bump information that is not captured in the commit data, but that nevertheless + * should be included in the rendered changelog. + */ +export type DependencyBump = { + dependencyName: string; + newVersion: string; +}; + /** * A ChangelogRenderer function takes in the extracted commits and other relevant metadata * and returns a string, or a Promise of a string of changelog contents (usually markdown). @@ -29,6 +39,7 @@ export type ChangelogRenderOptions = Record; * @param {string | null} config.project The name of specific project to generate a changelog for, or `null` if the overall workspace changelog * @param {string | false} config.entryWhenNoChanges The (already interpolated) string to use as the changelog entry when there are no changes, or `false` if no entry should be generated * @param {ChangelogRenderOptions} config.changelogRenderOptions The options specific to the ChangelogRenderer implementation + * @param {DependencyBump[]} config.dependencyBumps Optional list of additional dependency bumps that occurred as part of the release, outside of the commit data */ export type ChangelogRenderer = (config: { projectGraph: ProjectGraph; @@ -37,6 +48,7 @@ export type ChangelogRenderer = (config: { project: string | null; entryWhenNoChanges: string | false; changelogRenderOptions: DefaultChangelogRenderOptions; + dependencyBumps?: DependencyBump[]; repoSlug?: RepoSlug; conventionalCommitsConfig: NxReleaseConfig['conventionalCommits']; }) => Promise | string; @@ -74,6 +86,7 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({ project, entryWhenNoChanges, changelogRenderOptions, + dependencyBumps, repoSlug, conventionalCommitsConfig, }): Promise => { @@ -100,7 +113,14 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({ if (project === null) { // No changes for the workspace if (commits.length === 0) { - if (entryWhenNoChanges) { + if (dependencyBumps?.length) { + applyAdditionalDependencyBumps({ + markdownLines, + dependencyBumps, + releaseVersion, + changelogRenderOptions, + }); + } else if (entryWhenNoChanges) { markdownLines.push( '', `${createVersionTitle( @@ -173,7 +193,14 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({ // Generating for a named project, but that project has no relevant changes in the current set of commits, exit early if (relevantCommits.length === 0) { - if (entryWhenNoChanges) { + if (dependencyBumps?.length) { + applyAdditionalDependencyBumps({ + markdownLines, + dependencyBumps, + releaseVersion, + changelogRenderOptions, + }); + } else if (entryWhenNoChanges) { markdownLines.push( '', `${createVersionTitle( @@ -229,6 +256,15 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({ markdownLines.push('', '#### ⚠️ Breaking Changes', '', ...breakingChanges); } + if (dependencyBumps?.length) { + applyAdditionalDependencyBumps({ + markdownLines, + dependencyBumps, + releaseVersion, + changelogRenderOptions, + }); + } + if (changelogRenderOptions.authors) { const _authors = new Map; github?: string }>(); for (const commit of commits) { @@ -306,6 +342,33 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({ export default defaultChangelogRenderer; +function applyAdditionalDependencyBumps({ + markdownLines, + dependencyBumps, + releaseVersion, + changelogRenderOptions, +}: { + markdownLines: string[]; + dependencyBumps: DependencyBump[]; + releaseVersion: string; + changelogRenderOptions: DefaultChangelogRenderOptions; +}) { + if (markdownLines.length === 0) { + markdownLines.push( + '', + `${createVersionTitle(releaseVersion, changelogRenderOptions)}\n`, + '' + ); + } else { + markdownLines.push(''); + } + markdownLines.push('### 🧱 Updated Dependencies\n'); + dependencyBumps.forEach(({ dependencyName, newVersion }) => { + markdownLines.push(`- Updated ${dependencyName} to ${newVersion}`); + }); + markdownLines.push(''); +} + function formatName(name = '') { return name .split(' ') diff --git a/packages/nx/src/command-line/release/changelog.ts b/packages/nx/src/command-line/release/changelog.ts index 3974f567700dd..6466105c0fc38 100644 --- a/packages/nx/src/command-line/release/changelog.ts +++ b/packages/nx/src/command-line/release/changelog.ts @@ -304,8 +304,36 @@ export async function releaseChangelog( releaseGroup.projects; const projectNodes = projects.map((name) => projectGraph.nodes[name]); + // Determine any additional projects that we need to generate changelogs for, based on the dependent projects of the projects within the release group + const projectToDependencyProjectNames = new Map< + ProjectGraphProjectNode, + string[] + >(); + for (const project of projects) { + const dependentProjects = + projectsVersionData[project]?.dependentProjects || []; + if (dependentProjects.length) { + for (const dependentProject of dependentProjects) { + const dependentProjectNode = + projectGraph.nodes[dependentProject.source]; + if (!projectToDependencyProjectNames.has(dependentProjectNode)) { + projectToDependencyProjectNames.set(dependentProjectNode, [ + project, + ]); + continue; + } + projectToDependencyProjectNames + .get(dependentProjectNode) + .push(project); + } + } + } + if (releaseGroup.projectsRelationship === 'independent') { - for (const project of projectNodes) { + for (const project of Array.from( + // The full list is based on the projectNodes within the releaseGroup, plus any additional dependents + new Set([...projectNodes, ...projectToDependencyProjectNames.keys()]) + )) { let fromRef = args.from || ( @@ -358,7 +386,8 @@ export async function releaseChangelog( projectsVersionData, releaseGroup, [project], - nxReleaseConfig + nxReleaseConfig, + projectToDependencyProjectNames ); let hasPushed = false; @@ -425,7 +454,8 @@ export async function releaseChangelog( projectsVersionData, releaseGroup, projectNodes, - nxReleaseConfig + nxReleaseConfig, + projectToDependencyProjectNames ); let hasPushed = false; @@ -822,7 +852,8 @@ async function generateChangelogForProjects( projectsVersionData: VersionData, releaseGroup: ReleaseGroupWithName, projects: ProjectGraphProjectNode[], - nxReleaseConfig: NxReleaseConfig + nxReleaseConfig: NxReleaseConfig, + projectToDependencyProjectNames: Map ): Promise { const config = releaseGroup.changelog; // The entire feature is disabled at the release group level, exit early @@ -839,8 +870,37 @@ async function generateChangelogForProjects( const changelogRenderer = resolveChangelogRenderer(config.renderer); const projectChangelogs: NxReleaseChangelogResult['projectChangelogs'] = {}; + const projectToAdditionalDependencyProjectNames = new Map< + ProjectGraphProjectNode, + string[] + >(); for (const project of projects) { + const additionalDependentProjects = + projectsVersionData[project.name]?.dependentProjects || []; + if (additionalDependentProjects.length) { + for (const dependentProject of additionalDependentProjects) { + const dependentProjectNode = + projectGraph.nodes[dependentProject.source]; + if ( + !projectToAdditionalDependencyProjectNames.has(dependentProjectNode) + ) { + projectToAdditionalDependencyProjectNames.set(dependentProjectNode, [ + project.name, + ]); + continue; + } + projectToAdditionalDependencyProjectNames + .get(dependentProjectNode) + .push(project.name); + } + } + } + + for (const project of [ + ...projects, + ...projectToAdditionalDependencyProjectNames.keys(), + ]) { let interpolatedTreePath = config.file || ''; if (interpolatedTreePath) { interpolatedTreePath = interpolate(interpolatedTreePath, { @@ -878,6 +938,15 @@ async function generateChangelogForProjects( ? getGitHubRepoSlug(gitRemote) : undefined; + const dependencyBumps = projectToDependencyProjectNames.has(project) + ? projectToDependencyProjectNames.get(project).map((dep) => { + return { + dependencyName: dep, + newVersion: projectsVersionData[dep].newVersion, + }; + }) + : undefined; + let contents = await changelogRenderer({ projectGraph, commits, @@ -894,6 +963,7 @@ async function generateChangelogForProjects( : false, changelogRenderOptions: config.renderOptions, conventionalCommitsConfig: nxReleaseConfig.conventionalCommits, + dependencyBumps, }); /** diff --git a/packages/nx/src/command-line/release/utils/shared.ts b/packages/nx/src/command-line/release/utils/shared.ts index 5993ede7ea9c9..b69fb05457ba8 100644 --- a/packages/nx/src/command-line/release/utils/shared.ts +++ b/packages/nx/src/command-line/release/utils/shared.ts @@ -33,7 +33,11 @@ export type VersionData = Record< */ newVersion: string | null; currentVersion: string; - dependentProjects: any[]; // TODO: investigate generic type for this once more ecosystems are explored + /** + * The list of projects which depend upon the current project. + * TODO: investigate generic type for this once more ecosystems are explored + */ + dependentProjects: any[]; } >; diff --git a/packages/nx/src/command-line/release/version.ts b/packages/nx/src/command-line/release/version.ts index 959745e6e4226..b2a0ce40eccc2 100644 --- a/packages/nx/src/command-line/release/version.ts +++ b/packages/nx/src/command-line/release/version.ts @@ -73,6 +73,12 @@ export interface ReleaseVersionGeneratorSchema { installArgs?: string; installIgnoreScripts?: boolean; conventionalCommitsConfig?: NxReleaseConfig['conventionalCommits']; + updateDependents?: { + // "auto" means "only when the dependents are already included in the current batch", and is the default + when?: 'auto' | 'always'; + // in the case "when" is set to "always", what semver bump should be applied to the dependents which are not included in the current batch + bump?: 'patch' | 'minor' | 'major'; + }; } export interface NxReleaseVersionResult { @@ -165,9 +171,14 @@ export async function releaseVersion( const versionData: VersionData = {}; const commitMessage: string | undefined = args.gitCommitMessage || nxReleaseConfig.version.git.commitMessage; - const additionalChangedFiles = new Set(); const generatorCallbacks: (() => Promise)[] = []; + /** + * 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(); + if (args.projects?.length) { /** * Run versioning for all remaining release groups and filtered projects within them @@ -453,7 +464,7 @@ function appendVersionData( for (const [key, value] of Object.entries(newVersionData)) { if (existingVersionData[key]) { throw new Error( - `Version data key "${key}" already exists in version data. This is likely a bug.` + `Version data key "${key}" already exists in version data. This is likely a bug, please report your use-case on https://github.com/nrwl/nx` ); } existingVersionData[key] = value;