diff --git a/e2e/release/src/conventional-commits-config.test.ts b/e2e/release/src/conventional-commits-config.test.ts index 191edca887961..a8e3e8b8c79eb 100644 --- a/e2e/release/src/conventional-commits-config.test.ts +++ b/e2e/release/src/conventional-commits-config.test.ts @@ -371,7 +371,7 @@ describe('nx release conventional commits config', () => { - ⚠️ **{project-name}:** this is a breaking change - #### ⚠️ Breaking Changes + ### ⚠️ Breaking Changes - ⚠️ **{project-name}:** this is a breaking change `); diff --git a/e2e/release/src/create-github-release.test.ts b/e2e/release/src/create-github-release.test.ts index 06f18b427ddd4..de6c4cfe0f291 100644 --- a/e2e/release/src/create-github-release.test.ts +++ b/e2e/release/src/create-github-release.test.ts @@ -125,7 +125,7 @@ describe('nx release create github release', () => { expect(result.match(new RegExp(`### 🚀 Features`, 'g')).length).toEqual(2); expect(result.match(new RegExp(`### 🩹 Fixes`, 'g')).length).toEqual(2); expect( - result.match(new RegExp(`#### ⚠️ Breaking Changes`, 'g')).length + result.match(new RegExp(`### ⚠️ Breaking Changes`, 'g')).length ).toEqual(2); }); @@ -159,7 +159,7 @@ describe('nx release create github release', () => { expect(result.match(new RegExp(`### 🚀 Features`, 'g')).length).toEqual(2); expect(result.match(new RegExp(`### 🩹 Fixes`, 'g')).length).toEqual(1); expect( - result.match(new RegExp(`#### ⚠️ Breaking Changes`, 'g')).length + result.match(new RegExp(`### ⚠️ Breaking Changes`, 'g')).length ).toEqual(1); }); }); diff --git a/e2e/release/src/version-plans.test.ts b/e2e/release/src/version-plans.test.ts index 0a80cb3b791d7..e8d2a7cf00af1 100644 --- a/e2e/release/src/version-plans.test.ts +++ b/e2e/release/src/version-plans.test.ts @@ -73,6 +73,10 @@ describe('nx release version plans', () => { pkg5 = uniq('my-pkg-5'); runCLI(`generate @nx/workspace:npm-package ${pkg5}`); + // Normalize git committer information so it is deterministic in snapshots + await runCommandAsync(`git config user.email "test@test.com"`); + await runCommandAsync(`git config user.name "Test"`); + await runCommandAsync(`git add .`); await runCommandAsync(`git commit -m "chore: initial commit"`); await runCommandAsync(`git tag -a v0.0.0 -m "v0.0.0"`); @@ -174,7 +178,12 @@ Here is another line in the message. + + ### 🚀 Features + -+ - Update the fixed packages with a minor release.` ++ - Update the fixed packages with a minor release. ++ ++ ++ ### ❤️ Thank You ++ ++ - Test` ); expect(resultWithoutDate).toContain( `NX Generating an entry in ${pkg2}/CHANGELOG.md for v0.1.0 @@ -185,7 +194,12 @@ Here is another line in the message. + + ### 🚀 Features + -+ - Update the fixed packages with a minor release.` ++ - Update the fixed packages with a minor release. ++ ++ ++ ### ❤️ Thank You ++ ++ - Test` ); expect(resultWithoutDate).toContain( `NX Generating an entry in ${pkg3}/CHANGELOG.md for ${pkg3}@0.0.1 @@ -196,9 +210,14 @@ Here is another line in the message. + + ### 🩹 Fixes + -+ - **${pkg3}:** Update the independent packages with a patch, preminor, and prerelease. ++ - Update the independent packages with a patch, preminor, and prerelease. + -+ Here is another line in the message.` ++ Here is another line in the message. ++ ++ ++ ### ❤️ Thank You ++ ++ - Test` ); expect(resultWithoutDate).toContain( @@ -210,9 +229,14 @@ Here is another line in the message. + + ### 🚀 Features + -+ - **${pkg4}:** Update the independent packages with a patch, preminor, and prerelease. ++ - Update the independent packages with a patch, preminor, and prerelease. ++ ++ Here is another line in the message. ++ + -+ Here is another line in the message.` ++ ### ❤️ Thank You ++ ++ - Test` ); expect(resultWithoutDate).toContain( @@ -224,9 +248,14 @@ Here is another line in the message. + + ### 🩹 Fixes + -+ - **${pkg5}:** Update the independent packages with a patch, preminor, and prerelease. ++ - Update the independent packages with a patch, preminor, and prerelease. ++ ++ Here is another line in the message. ++ ++ ++ ### ❤️ Thank You + -+ Here is another line in the message.` ++ - Test` ); await writeFile( @@ -298,12 +327,17 @@ Update packages in both groups with a mix #2 + + ### 🚀 Features + -+ - **${pkg1}:** Update packages in both groups with a mix #1 ++ - Update packages in both groups with a mix #1 + + + ### 🩹 Fixes + -+ - Update packages in both groups with a mix #2` ++ - Update packages in both groups with a mix #2 ++ ++ ++ ### ❤️ Thank You ++ ++ - Test` ); expect(result2WithoutDate).toContain( `NX Generating an entry in ${pkg2}/CHANGELOG.md for v0.2.0 @@ -316,6 +350,11 @@ Update packages in both groups with a mix #2 + ### 🩹 Fixes + + - Update packages in both groups with a mix #2 ++ ++ ++ ### ❤️ Thank You ++ ++ - Test ` ); expect(result2WithoutDate).toContain( @@ -328,7 +367,12 @@ Update packages in both groups with a mix #2 + + ### 🩹 Fixes + -+ - **${pkg3}:** Update packages in both groups with a mix #1` ++ - Update packages in both groups with a mix #1 ++ ++ ++ ### ❤️ Thank You ++ ++ - Test` ); expect(result2WithoutDate).toContain( @@ -341,7 +385,12 @@ Update packages in both groups with a mix #2 + + ### 🚀 Features + -+ - **${pkg4}:** Update packages in both groups with a mix #2` ++ - Update packages in both groups with a mix #2 ++ ++ ++ ### ❤️ Thank You ++ ++ - Test` ); expect(result2WithoutDate).toContain( @@ -354,7 +403,12 @@ Update packages in both groups with a mix #2 + + ### 🩹 Fixes + -+ - **${pkg5}:** Update packages in both groups with a mix #2` ++ - Update packages in both groups with a mix #2 ++ ++ ++ ### ❤️ Thank You ++ ++ - Test` ); expect(exists(join(versionPlansDir, 'bump-mixed1.md'))).toBeFalsy(); @@ -512,7 +566,12 @@ const yargs = require('yargs'); + + ### 🚀 Features + -+ - Update the fixed packages with a minor release.` ++ - Update the fixed packages with a minor release. ++ ++ ++ ### ❤️ Thank You ++ ++ - Test` ); expect(resultWithoutDate).toContain( `NX Generating an entry in ${pkg2}/CHANGELOG.md for v0.1.0 @@ -523,7 +582,12 @@ const yargs = require('yargs'); + + ### 🚀 Features + -+ - Update the fixed packages with a minor release.` ++ - Update the fixed packages with a minor release. ++ ++ ++ ### ❤️ Thank You ++ ++ - Test` ); expect(resultWithoutDate).toContain( `NX Generating an entry in ${pkg3}/CHANGELOG.md for ${pkg3}@0.0.1 @@ -534,7 +598,12 @@ const yargs = require('yargs'); + + ### 🩹 Fixes + -+ - **${pkg3}:** Update the independent packages with a patch, preminor, and prerelease.` ++ - Update the independent packages with a patch, preminor, and prerelease. ++ ++ ++ ### ❤️ Thank You ++ ++ - Test` ); expect(resultWithoutDate).toContain( @@ -546,7 +615,12 @@ const yargs = require('yargs'); + + ### 🚀 Features + -+ - **${pkg4}:** Update the independent packages with a patch, preminor, and prerelease.` ++ - Update the independent packages with a patch, preminor, and prerelease. ++ ++ ++ ### ❤️ Thank You ++ ++ - Test` ); expect(resultWithoutDate).toContain( @@ -558,7 +632,12 @@ const yargs = require('yargs'); + + ### 🩹 Fixes + -+ - **${pkg5}:** Update the independent packages with a patch, preminor, and prerelease.` ++ - Update the independent packages with a patch, preminor, and prerelease. ++ ++ ++ ### ❤️ Thank You ++ ++ - Test` ); expect(exists(join(versionPlansDir, 'bump-fixed.md'))).toBeFalsy(); @@ -633,12 +712,17 @@ Update packages in both groups with a mix #2 + + ### 🚀 Features + -+ - **${pkg1}:** Update packages in both groups with a mix #1 ++ - Update packages in both groups with a mix #1 + + + ### 🩹 Fixes + -+ - Update packages in both groups with a mix #2` ++ - Update packages in both groups with a mix #2 ++ ++ ++ ### ❤️ Thank You ++ ++ - Test` ); expect(result2WithoutDate).toContain( `NX Generating an entry in ${pkg2}/CHANGELOG.md for v0.2.0 @@ -651,6 +735,11 @@ Update packages in both groups with a mix #2 + ### 🩹 Fixes + + - Update packages in both groups with a mix #2 ++ ++ ++ ### ❤️ Thank You ++ ++ - Test ` ); expect(result2WithoutDate).toContain( @@ -663,7 +752,12 @@ Update packages in both groups with a mix #2 + + ### 🩹 Fixes + -+ - **${pkg3}:** Update packages in both groups with a mix #1` ++ - Update packages in both groups with a mix #1 ++ ++ ++ ### ❤️ Thank You ++ ++ - Test` ); expect(result2WithoutDate).toContain( @@ -676,7 +770,12 @@ Update packages in both groups with a mix #2 + + ### 🚀 Features + -+ - **${pkg4}:** Update packages in both groups with a mix #2` ++ - Update packages in both groups with a mix #2 ++ ++ ++ ### ❤️ Thank You ++ ++ - Test` ); expect(result2WithoutDate).toContain( @@ -689,7 +788,12 @@ Update packages in both groups with a mix #2 + + ### 🩹 Fixes + -+ - **${pkg5}:** Update packages in both groups with a mix #2` ++ - Update packages in both groups with a mix #2 ++ ++ ++ ### ❤️ Thank You ++ ++ - Test` ); expect(exists(join(versionPlansDir, 'bump-mixed1.md'))).toBeFalsy(); diff --git a/packages/js/src/generators/release-version/release-version.ts b/packages/js/src/generators/release-version/release-version.ts index 059031d435baa..072369adc8ed4 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 { + PackageManager, ProjectGraphProjectNode, Tree, + detectPackageManager, formatFiles, + getPackageManagerVersion, joinPathFragments, output, readJson, @@ -35,7 +38,7 @@ import { } from 'nx/src/command-line/release/version'; import { interpolate } from 'nx/src/tasks-runner/utils'; import * as ora from 'ora'; -import { ReleaseType, gt, inc, prerelease } from 'semver'; +import { ReleaseType, gt, inc, lt, prerelease } from 'semver'; import { parseRegistryOptions } from '../../utils/npm-config'; import { ReleaseVersionGeneratorSchema } from './schema'; import { @@ -723,6 +726,28 @@ To fix this you will either need to add a package.json file at that location, or const currentDependencyVersion = json[dependentProject.dependencyCollection][dependencyPackageName]; + // Depending on the package manager, locally linked packages could reference packages with `"private": true` and no version field at all + const currentPackageVersion = json.version ?? null; + if ( + !currentPackageVersion && + isLocallyLinkedPackageVersion(currentDependencyVersion) + ) { + if (forceVersionBump) { + // Look up any dependent projects from the transitiveLocalPackageDependents list + const transitiveDependentProjects = + transitiveLocalPackageDependents.filter( + (localPackageDependency) => + localPackageDependency.target === dependentProject.source + ); + versionData[dependentProject.source] = { + currentVersion: currentPackageVersion, + newVersion: currentDependencyVersion, + dependentProjects: transitiveDependentProjects, + }; + } + return json; + } + // 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 @@ -743,7 +768,6 @@ To fix this you will either need to add a package.json file at that location, or // 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, @@ -962,3 +986,42 @@ class ProjectLogger { }); } } + +let pm: PackageManager | undefined; +let pmVersion: string | undefined; + +const localPackageProtocols = [ + 'file:', // all package managers + 'workspace:', // not npm + 'portal:', // modern yarn only +]; + +function isLocallyLinkedPackageVersion(version: string): boolean { + // Not using a supported local protocol + if (!localPackageProtocols.some((protocol) => version.startsWith(protocol))) { + return false; + } + // Supported by all package managers + if (version.startsWith('file:')) { + return true; + } + // Determine specific package manager in use + if (!pm) { + pm = detectPackageManager(); + pmVersion = getPackageManagerVersion(pm); + } + if (pm === 'npm' && version.startsWith('workspace:')) { + throw new Error( + `The "workspace:" protocol is not yet supported by npm (https://github.com/npm/rfcs/issues/765). Please ensure you have a valid setup according to your package manager before attempting to release packages.` + ); + } + if ( + version.startsWith('portal:') && + (pm !== 'yarn' || lt(pmVersion, '2.0.0')) + ) { + throw new Error( + `The "portal:" protocol is only supported by yarn@2.0.0 and above. Please ensure you have a valid setup according to your package manager before attempting to release packages.` + ); + } + return true; +} diff --git a/packages/nx/release/changelog-renderer/index.spec.ts b/packages/nx/release/changelog-renderer/index.spec.ts index f7a2eccc2ed4e..2f33a089dde5f 100644 --- a/packages/nx/release/changelog-renderer/index.spec.ts +++ b/packages/nx/release/changelog-renderer/index.spec.ts @@ -690,7 +690,7 @@ describe('defaultChangelogRenderer()', () => { - ⚠️ **WebSocketSubject:** no longer extends \`Subject\`. - #### ⚠️ Breaking Changes + ### ⚠️ Breaking Changes - ⚠️ **WebSocketSubject:** no longer extends \`Subject\`. @@ -742,7 +742,7 @@ describe('defaultChangelogRenderer()', () => { - ⚠️ **WebSocketSubject:** no longer extends \`Subject\`. - #### ⚠️ Breaking Changes + ### ⚠️ Breaking Changes - **WebSocketSubject:** \`WebSocketSubject\` is no longer \`instanceof Subject\`. Check for \`instanceof WebSocketSubject\` instead. diff --git a/packages/nx/release/changelog-renderer/index.ts b/packages/nx/release/changelog-renderer/index.ts index fcf6e9be3bad8..4cbd618a4627d 100644 --- a/packages/nx/release/changelog-renderer/index.ts +++ b/packages/nx/release/changelog-renderer/index.ts @@ -1,6 +1,7 @@ import { major } from 'semver'; import { ChangelogChange } from '../../src/command-line/release/changelog'; import { NxReleaseConfig } from '../../src/command-line/release/config/config'; +import { DEFAULT_CONVENTIONAL_COMMITS_CONFIG } from '../../src/command-line/release/config/conventional-commits'; import { GitCommit } from '../../src/command-line/release/utils/git'; import { RepoSlug, @@ -44,7 +45,7 @@ export type DependencyBump = { */ export type ChangelogRenderer = (config: { projectGraph: ProjectGraph; - // TODO: remove 'commits' and make 'changes' whenever we make the next breaking change to this API + // TODO(v20): remove 'commits' and make 'changes' required commits?: GitCommit[]; changes?: ChangelogChange[]; releaseVersion: string; @@ -53,7 +54,9 @@ export type ChangelogRenderer = (config: { changelogRenderOptions: DefaultChangelogRenderOptions; dependencyBumps?: DependencyBump[]; repoSlug?: RepoSlug; - conventionalCommitsConfig: NxReleaseConfig['conventionalCommits']; + // TODO(v20): Evaluate if there is a cleaner way to configure this when breaking changes are allowed + // null if version plans are being used to generate the changelog + conventionalCommitsConfig: NxReleaseConfig['conventionalCommits'] | null; }) => Promise | string; /** @@ -99,9 +102,7 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({ repoSlug, conventionalCommitsConfig, }): Promise => { - const changeTypes = conventionalCommitsConfig.types; const markdownLines: string[] = []; - const breakingChanges = []; // If the current range of changes contains both a commit and its revert, we strip them both from the final list. Changes from version plans are unaffected, as they have no hashes. for (const change of changes) { @@ -119,11 +120,48 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({ } let relevantChanges = changes; + const breakingChanges = []; + + // For now to keep the interface of the changelog renderer non-breaking for v19 releases we have a somewhat indirect check for whether or not we are generating a changelog for version plans + const isVersionPlans = !conventionalCommitsConfig; + + // Only applicable for version plans + const additionalChangesForAuthorsSection = []; + + // Provide a default configuration for version plans to allow most of the subsequent logic to work in the same way it would for conventional commits + // NOTE: The one exception is breaking/major changes, where we do not follow the same structure and instead only show the changes once + if (isVersionPlans) { + conventionalCommitsConfig = { + types: { + feat: DEFAULT_CONVENTIONAL_COMMITS_CONFIG.types.feat, + fix: DEFAULT_CONVENTIONAL_COMMITS_CONFIG.types.fix, + }, + }; + // Trim down "relevant changes" to only include non-breaking ones so that we can render them differently under version plans, + // but keep track of the changes for the purposes of the authors section + // TODO(v20): Clean this abstraction up as part of the larger overall refactor of changelog rendering + for (let i = 0; i < relevantChanges.length; i++) { + if (relevantChanges[i].isBreaking) { + const change = relevantChanges[i]; + additionalChangesForAuthorsSection.push(change); + const line = formatChange( + change, + changelogRenderOptions, + isVersionPlans, + repoSlug + ); + breakingChanges.push(line); + relevantChanges.splice(i, 1); + } + } + } + + const changeTypes = conventionalCommitsConfig.types; // workspace root level changelog if (project === null) { // No changes for the workspace - if (relevantChanges.length === 0) { + if (relevantChanges.length === 0 && breakingChanges.length === 0) { if (dependencyBumps?.length) { applyAdditionalDependencyBumps({ markdownLines, @@ -180,7 +218,12 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({ for (const scope of scopesSortedAlphabetically) { const changes = changesGroupedByScope[scope]; for (const change of changes) { - const line = formatChange(change, changelogRenderOptions, repoSlug); + const line = formatChange( + change, + changelogRenderOptions, + isVersionPlans, + repoSlug + ); markdownLines.push(line); if (change.isBreaking) { const breakingChangeExplanation = extractBreakingChangeExplanation( @@ -206,7 +249,7 @@ 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 (relevantChanges.length === 0) { + if (relevantChanges.length === 0 && breakingChanges.length === 0) { if (dependencyBumps?.length) { applyAdditionalDependencyBumps({ markdownLines, @@ -248,7 +291,12 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({ const changesInChronologicalOrder = group.reverse(); for (const change of changesInChronologicalOrder) { - const line = formatChange(change, changelogRenderOptions, repoSlug); + const line = formatChange( + change, + changelogRenderOptions, + isVersionPlans, + repoSlug + ); markdownLines.push(line + '\n'); if (change.isBreaking) { const breakingChangeExplanation = extractBreakingChangeExplanation( @@ -267,7 +315,7 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({ } if (breakingChanges.length > 0) { - markdownLines.push('', '#### ⚠️ Breaking Changes', '', ...breakingChanges); + markdownLines.push('', '### ⚠️ Breaking Changes', '', ...breakingChanges); } if (dependencyBumps?.length) { @@ -281,7 +329,11 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({ if (changelogRenderOptions.authors) { const _authors = new Map; github?: string }>(); - for (const change of relevantChanges) { + + for (const change of [ + ...relevantChanges, + ...additionalChangesForAuthorsSection, + ]) { if (!change.author) { continue; } @@ -402,6 +454,7 @@ function groupBy(items: any[], key: string) { function formatChange( change: ChangelogChange, changelogRenderOptions: DefaultChangelogRenderOptions, + isVersionPlans: boolean, repoSlug?: RepoSlug ): string { let description = change.description; @@ -417,10 +470,15 @@ function formatChange( .join('\n'); } + /** + * In version plans changelogs: + * - don't repeat the breaking change icon + * - don't render the scope + */ let changeLine = '- ' + - (change.isBreaking ? '⚠️ ' : '') + - (change.scope ? `**${change.scope.trim()}:** ` : '') + + (!isVersionPlans && change.isBreaking ? '⚠️ ' : '') + + (!isVersionPlans && change.scope ? `**${change.scope.trim()}:** ` : '') + description; if (repoSlug && changelogRenderOptions.commitReferences) { changeLine += formatReferences(change.githubReferences, repoSlug); diff --git a/packages/nx/src/command-line/release/changelog.ts b/packages/nx/src/command-line/release/changelog.ts index c2289d30e85d6..62d31a9621a7d 100644 --- a/packages/nx/src/command-line/release/changelog.ts +++ b/packages/nx/src/command-line/release/changelog.ts @@ -56,6 +56,7 @@ import { gitPush, gitTag, parseCommits, + parseGitCommit, } from './utils/git'; import { createOrUpdateGithubRelease, getGitHubRepoSlug } from './utils/github'; import { launchEditor } from './utils/launch-editor'; @@ -95,6 +96,7 @@ export interface ChangelogChange { body?: string; isBreaking?: boolean; githubReferences?: Reference[]; + // TODO(v20): This should be an array of one or more authors (Co-authored-by is supported at the commit level and should have been supported here) author?: { name: string; email: string }; shortHash?: string; revertedHashes?: string[]; @@ -175,10 +177,11 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { process.exit(1); } const rawVersionPlans = await readRawVersionPlans(); - setResolvedVersionPlansOnGroups( + await setResolvedVersionPlansOnGroups( rawVersionPlans, releaseGroups, - Object.keys(projectGraph.nodes) + Object.keys(projectGraph.nodes), + args.verbose ); if (args.deleteVersionPlans === undefined) { @@ -282,6 +285,15 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { const releaseType = versionPlanSemverReleaseTypeToChangelogType( vp.groupVersionBump ); + let githubReferences = []; + let author = undefined; + const parsedCommit = vp.commit + ? parseGitCommit(vp.commit, true) + : null; + if (parsedCommit) { + githubReferences = parsedCommit.references; + author = parsedCommit.author; + } const changes: ChangelogChange | ChangelogChange[] = !vp.triggeredByProjects ? { @@ -290,7 +302,8 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { description: vp.message, body: '', isBreaking: releaseType.isBreaking, - githubReferences: [], + githubReferences, + author, affectedProjects: '*', } : vp.triggeredByProjects.map((project) => { @@ -299,9 +312,9 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { scope: project, description: vp.message, body: '', - // TODO: what about github references? isBreaking: releaseType.isBreaking, - githubReferences: [], + githubReferences, + author, affectedProjects: [project], }; }); @@ -498,6 +511,15 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { } const releaseType = versionPlanSemverReleaseTypeToChangelogType(bumpForProject); + let githubReferences = []; + let author = undefined; + const parsedCommit = vp.commit + ? parseGitCommit(vp.commit, true) + : null; + if (parsedCommit) { + githubReferences = parsedCommit.references; + author = parsedCommit.author; + } return { type: releaseType.type, scope: project.name, @@ -505,8 +527,8 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { body: '', isBreaking: releaseType.isBreaking, affectedProjects: Object.keys(vp.projectVersionBumps), - // TODO: can we include github references when using version plans? - githubReferences: [], + githubReferences, + author, }; }) .filter(Boolean); @@ -642,6 +664,15 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { const releaseType = versionPlanSemverReleaseTypeToChangelogType( vp.groupVersionBump ); + let githubReferences = []; + let author = undefined; + const parsedCommit = vp.commit + ? parseGitCommit(vp.commit, true) + : null; + if (parsedCommit) { + githubReferences = parsedCommit.references; + author = parsedCommit.author; + } const changes: ChangelogChange | ChangelogChange[] = !vp.triggeredByProjects ? { @@ -650,7 +681,8 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { description: vp.message, body: '', isBreaking: releaseType.isBreaking, - githubReferences: [], + githubReferences, + author, affectedProjects: '*', } : vp.triggeredByProjects.map((project) => { @@ -659,9 +691,9 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { scope: project, description: vp.message, body: '', - // TODO: what about github references? isBreaking: releaseType.isBreaking, - githubReferences: [], + githubReferences, + author, affectedProjects: [project], }; }); @@ -1239,7 +1271,9 @@ async function generateChangelogForProjects({ }) : false, changelogRenderOptions: config.renderOptions, - conventionalCommitsConfig: nxReleaseConfig.conventionalCommits, + conventionalCommitsConfig: releaseGroup.versionPlans + ? null + : nxReleaseConfig.conventionalCommits, dependencyBumps: projectToAdditionalDependencyBumps.get(project.name), }); diff --git a/packages/nx/src/command-line/release/config/version-plans.spec.ts b/packages/nx/src/command-line/release/config/version-plans.spec.ts index ff0866f77b600..bde19101391a1 100644 --- a/packages/nx/src/command-line/release/config/version-plans.spec.ts +++ b/packages/nx/src/command-line/release/config/version-plans.spec.ts @@ -147,7 +147,7 @@ describe('version-plans', () => { describe('error cases', () => { describe('for default group', () => { describe('when bump "key" is a group name', () => { - it('should error if version plans are not enabled', () => { + it('should error if version plans are not enabled', async () => { const rawVersionPlans: RawVersionPlan[] = [ versionPlan({ name: 'plan1.md', @@ -169,18 +169,19 @@ describe('version-plans', () => { ]; const allProjectNamesInWorkspace: string[] = ['pkg1']; - expect(() => + await expect(() => setResolvedVersionPlansOnGroups( rawVersionPlans, releaseGroups, - allProjectNamesInWorkspace + allProjectNamesInWorkspace, + false ) - ).toThrowErrorMatchingInlineSnapshot( + ).rejects.toThrowErrorMatchingInlineSnapshot( `Found a version bump in 'plan1.md' but version plans are not enabled.` ); }); - it('should error if group is independently versioned', () => { + it('should error if group is independently versioned', async () => { const rawVersionPlans: RawVersionPlan[] = [ versionPlan({ name: 'plan1.md', @@ -200,18 +201,19 @@ describe('version-plans', () => { ]; const allProjectNamesInWorkspace: string[] = ['pkg1']; - expect(() => + await expect(() => setResolvedVersionPlansOnGroups( rawVersionPlans, releaseGroups, - allProjectNamesInWorkspace + allProjectNamesInWorkspace, + false ) - ).toThrowErrorMatchingInlineSnapshot( + ).rejects.toThrowErrorMatchingInlineSnapshot( `Found a version bump in 'plan1.md' but projects are configured to be independently versioned. Individual projects should be bumped instead.` ); }); - it('should error if bump "value" is not a release type', () => { + it('should error if bump "value" is not a release type', async () => { const rawVersionPlans: RawVersionPlan[] = [ versionPlan({ name: 'plan1.md', @@ -231,18 +233,19 @@ describe('version-plans', () => { ]; const allProjectNamesInWorkspace: string[] = ['pkg1']; - expect(() => + await expect(() => setResolvedVersionPlansOnGroups( rawVersionPlans, releaseGroups, - allProjectNamesInWorkspace + allProjectNamesInWorkspace, + false ) - ).toThrowErrorMatchingInlineSnapshot( + ).rejects.toThrowErrorMatchingInlineSnapshot( `Found a version bump in 'plan1.md' with an invalid release type. Please specify one of major, premajor, minor, preminor, patch, prepatch, prerelease.` ); }); - it('should error if fixed default group has two different entries with different bump types', () => { + it('should error if fixed default group has two different entries with different bump types', async () => { const rawVersionPlans: RawVersionPlan[] = [ versionPlan({ name: 'plan1.md', @@ -263,20 +266,21 @@ describe('version-plans', () => { ]; const allProjectNamesInWorkspace: string[] = ['pkg1']; - expect(() => + await expect(() => setResolvedVersionPlansOnGroups( rawVersionPlans, releaseGroups, - allProjectNamesInWorkspace + allProjectNamesInWorkspace, + false ) - ).toThrowErrorMatchingInlineSnapshot( + ).rejects.toThrowErrorMatchingInlineSnapshot( `Found a version bump in 'plan1.md' that conflicts with another version bump. When in fixed versioning mode, all version bumps must match.` ); }); }); describe('when bump "key" is a project name', () => { - it('should error if project does not exist in the workspace', () => { + it('should error if project does not exist in the workspace', async () => { const rawVersionPlans: RawVersionPlan[] = [ versionPlan({ name: 'plan1.md', @@ -296,18 +300,19 @@ describe('version-plans', () => { ]; const allProjectNamesInWorkspace: string[] = []; - expect(() => + await expect(() => setResolvedVersionPlansOnGroups( rawVersionPlans, releaseGroups, - allProjectNamesInWorkspace + allProjectNamesInWorkspace, + false ) - ).toThrowErrorMatchingInlineSnapshot( + ).rejects.toThrowErrorMatchingInlineSnapshot( `Found a version bump for project 'nonExistentPkg' in 'plan1.md' but the project does not exist in the workspace.` ); }); - it('should error if version plans are not enabled', () => { + it('should error if version plans are not enabled', async () => { const rawVersionPlans: RawVersionPlan[] = [ versionPlan({ name: 'plan1.md', @@ -327,18 +332,19 @@ describe('version-plans', () => { ]; const allProjectNamesInWorkspace: string[] = ['pkg1']; - expect(() => + await expect(() => setResolvedVersionPlansOnGroups( rawVersionPlans, releaseGroups, - allProjectNamesInWorkspace + allProjectNamesInWorkspace, + false ) - ).toThrowErrorMatchingInlineSnapshot( + ).rejects.toThrowErrorMatchingInlineSnapshot( `Found a version bump for project 'pkg1' in 'plan1.md' but version plans are not enabled.` ); }); - it('should error if project is not included in the default release group', () => { + it('should error if project is not included in the default release group', async () => { const rawVersionPlans: RawVersionPlan[] = [ versionPlan({ name: 'plan1.md', @@ -358,18 +364,19 @@ describe('version-plans', () => { ]; const allProjectNamesInWorkspace: string[] = ['pkg1', 'pkg2']; - expect(() => + await expect(() => setResolvedVersionPlansOnGroups( rawVersionPlans, releaseGroups, - allProjectNamesInWorkspace + allProjectNamesInWorkspace, + false ) - ).toThrowErrorMatchingInlineSnapshot( + ).rejects.toThrowErrorMatchingInlineSnapshot( `Found a version bump for project 'pkg2' in 'plan1.md' but the project is not configured for release. Ensure it is included by the 'release.projects' globs in nx.json.` ); }); - it(`should error if project's bump "value" is not a release type`, () => { + it(`should error if project's bump "value" is not a release type`, async () => { const rawVersionPlans: RawVersionPlan[] = [ versionPlan({ name: 'plan1.md', @@ -389,18 +396,19 @@ describe('version-plans', () => { ]; const allProjectNamesInWorkspace: string[] = ['pkg1']; - expect(() => + await expect(() => setResolvedVersionPlansOnGroups( rawVersionPlans, releaseGroups, - allProjectNamesInWorkspace + allProjectNamesInWorkspace, + false ) - ).toThrowErrorMatchingInlineSnapshot( + ).rejects.toThrowErrorMatchingInlineSnapshot( `Found a version bump for project 'pkg1' in 'plan1.md' with an invalid release type. Please specify one of major, premajor, minor, preminor, patch, prepatch, prerelease.` ); }); - it('should error if the fixed default group has two different projects with different bump types', () => { + it('should error if the fixed default group has two different projects with different bump types', async () => { const rawVersionPlans: RawVersionPlan[] = [ versionPlan({ name: 'plan1.md', @@ -421,13 +429,14 @@ describe('version-plans', () => { ]; const allProjectNamesInWorkspace: string[] = ['pkg1', 'pkg2']; - expect(() => + await expect(() => setResolvedVersionPlansOnGroups( rawVersionPlans, releaseGroups, - allProjectNamesInWorkspace + allProjectNamesInWorkspace, + false ) - ).toThrowErrorMatchingInlineSnapshot( + ).rejects.toThrowErrorMatchingInlineSnapshot( `Found a version bump for project 'pkg2' in 'plan1.md' that conflicts with another version bump. When in fixed versioning mode, all version bumps must match.` ); }); @@ -436,7 +445,7 @@ describe('version-plans', () => { describe('for explicit groups', () => { describe('when bump "key" is a group name', () => { - it('should error if version plans are not enabled', () => { + it('should error if version plans are not enabled', async () => { const rawVersionPlans: RawVersionPlan[] = [ versionPlan({ name: 'plan1.md', @@ -456,18 +465,19 @@ describe('version-plans', () => { ]; const allProjectNamesInWorkspace: string[] = ['pkg1']; - expect(() => + await expect(() => setResolvedVersionPlansOnGroups( rawVersionPlans, releaseGroups, - allProjectNamesInWorkspace + allProjectNamesInWorkspace, + false ) - ).toThrowErrorMatchingInlineSnapshot( + ).rejects.toThrowErrorMatchingInlineSnapshot( `Found a version bump for group 'group1' in 'plan1.md' but the group does not have version plans enabled.` ); }); - it('should error if group is independently versioned', () => { + it('should error if group is independently versioned', async () => { const rawVersionPlans: RawVersionPlan[] = [ versionPlan({ name: 'plan1.md', @@ -487,18 +497,19 @@ describe('version-plans', () => { ]; const allProjectNamesInWorkspace: string[] = ['pkg1']; - expect(() => + await expect(() => setResolvedVersionPlansOnGroups( rawVersionPlans, releaseGroups, - allProjectNamesInWorkspace + allProjectNamesInWorkspace, + false ) - ).toThrowErrorMatchingInlineSnapshot( + ).rejects.toThrowErrorMatchingInlineSnapshot( `Found a version bump for group 'group1' in 'plan1.md' but the group's projects are independently versioned. Individual projects of 'group1' should be bumped instead.` ); }); - it('should error if bump "value" is not a release type', () => { + it('should error if bump "value" is not a release type', async () => { const rawVersionPlans: RawVersionPlan[] = [ versionPlan({ name: 'plan1.md', @@ -518,18 +529,19 @@ describe('version-plans', () => { ]; const allProjectNamesInWorkspace: string[] = ['pkg1']; - expect(() => + await expect(() => setResolvedVersionPlansOnGroups( rawVersionPlans, releaseGroups, - allProjectNamesInWorkspace + allProjectNamesInWorkspace, + false ) - ).toThrowErrorMatchingInlineSnapshot( + ).rejects.toThrowErrorMatchingInlineSnapshot( `Found a version bump for group 'group1' in 'plan1.md' with an invalid release type. Please specify one of major, premajor, minor, preminor, patch, prepatch, prerelease.` ); }); - it('should error if fixed group has two different entries with different bump types', () => { + it('should error if fixed group has two different entries with different bump types', async () => { const rawVersionPlans: RawVersionPlan[] = [ versionPlan({ name: 'plan1.md', @@ -550,19 +562,20 @@ describe('version-plans', () => { ]; const allProjectNamesInWorkspace: string[] = ['pkg1']; - expect(() => + await expect(() => setResolvedVersionPlansOnGroups( rawVersionPlans, releaseGroups, - allProjectNamesInWorkspace + allProjectNamesInWorkspace, + false ) - ).toThrowErrorMatchingInlineSnapshot( + ).rejects.toThrowErrorMatchingInlineSnapshot( `Found a version bump for group 'group1' in 'plan1.md' that conflicts with another version bump for this group. When the group is in fixed versioning mode, all groups' version bumps within the same version plan must match.` ); }); }); describe('when bump "key" is a project name', () => { - it('should error if version plans are not enabled', () => { + it('should error if version plans are not enabled', async () => { const rawVersionPlans: RawVersionPlan[] = [ versionPlan({ name: 'plan1.md', @@ -589,18 +602,19 @@ describe('version-plans', () => { ]; const allProjectNamesInWorkspace: string[] = ['pkg1', 'pkg2']; - expect(() => + await expect(() => setResolvedVersionPlansOnGroups( rawVersionPlans, releaseGroups, - allProjectNamesInWorkspace + allProjectNamesInWorkspace, + false ) - ).toThrowErrorMatchingInlineSnapshot( + ).rejects.toThrowErrorMatchingInlineSnapshot( `Found a version bump for project 'pkg2' in 'plan1.md' but the project's group 'group2' does not have version plans enabled.` ); }); - it('should error if project does not exist in the workspace', () => { + it('should error if project does not exist in the workspace', async () => { const rawVersionPlans: RawVersionPlan[] = [ versionPlan({ name: 'plan1.md', @@ -620,18 +634,19 @@ describe('version-plans', () => { ]; const allProjectNamesInWorkspace: string[] = []; - expect(() => + await expect(() => setResolvedVersionPlansOnGroups( rawVersionPlans, releaseGroups, - allProjectNamesInWorkspace + allProjectNamesInWorkspace, + false ) - ).toThrowErrorMatchingInlineSnapshot( + ).rejects.toThrowErrorMatchingInlineSnapshot( `Found a version bump for project 'nonExistentPkg' in 'plan1.md' but the project does not exist in the workspace.` ); }); - it('should error if project is not included in any release groups', () => { + it('should error if project is not included in any release groups', async () => { const rawVersionPlans: RawVersionPlan[] = [ versionPlan({ name: 'plan1.md', @@ -661,18 +676,19 @@ describe('version-plans', () => { 'pkg3', ]; - expect(() => + await expect(() => setResolvedVersionPlansOnGroups( rawVersionPlans, releaseGroups, - allProjectNamesInWorkspace + allProjectNamesInWorkspace, + false ) - ).toThrowErrorMatchingInlineSnapshot( + ).rejects.toThrowErrorMatchingInlineSnapshot( `Found a version bump for project 'pkg3' in 'plan1.md' but the project is not in any configured release groups.` ); }); - it(`should error if project's bump "value" is not a release type`, () => { + it(`should error if project's bump "value" is not a release type`, async () => { const rawVersionPlans: RawVersionPlan[] = [ versionPlan({ name: 'plan1.md', @@ -692,18 +708,19 @@ describe('version-plans', () => { ]; const allProjectNamesInWorkspace: string[] = ['pkg1']; - expect(() => + await expect(() => setResolvedVersionPlansOnGroups( rawVersionPlans, releaseGroups, - allProjectNamesInWorkspace + allProjectNamesInWorkspace, + false ) - ).toThrowErrorMatchingInlineSnapshot( + ).rejects.toThrowErrorMatchingInlineSnapshot( `Found a version bump for project 'pkg1' in 'plan1.md' with an invalid release type. Please specify one of major, premajor, minor, preminor, patch, prepatch, prerelease.` ); }); - it('should error if a fixed group has two different projects with different bump types', () => { + it('should error if a fixed group has two different projects with different bump types', async () => { const rawVersionPlans: RawVersionPlan[] = [ versionPlan({ name: 'plan1.md', @@ -724,13 +741,14 @@ describe('version-plans', () => { ]; const allProjectNamesInWorkspace: string[] = ['pkg1', 'pkg2']; - expect(() => + await expect(() => setResolvedVersionPlansOnGroups( rawVersionPlans, releaseGroups, - allProjectNamesInWorkspace + allProjectNamesInWorkspace, + false ) - ).toThrowErrorMatchingInlineSnapshot( + ).rejects.toThrowErrorMatchingInlineSnapshot( `Found a version bump for project 'pkg2' in 'plan1.md' that conflicts with another project's version bump in the same release group 'group1'. When the group is in fixed versioning mode, all projects' version bumps within the same group must match.` ); }); @@ -740,7 +758,7 @@ describe('version-plans', () => { describe('success cases', () => { describe('for default group', () => { - it('should correctly handle fixed default group', () => { + it('should correctly handle fixed default group', async () => { const rawVersionPlans: RawVersionPlan[] = [ versionPlan({ name: 'plan2.md', @@ -773,22 +791,24 @@ describe('version-plans', () => { ]; const allProjectNamesInWorkspace: string[] = ['pkg1', 'pkg2', 'pkg3']; - expect( + await expect( peelResultFromGroups( setResolvedVersionPlansOnGroups( rawVersionPlans, releaseGroups, - allProjectNamesInWorkspace + allProjectNamesInWorkspace, + false ) ) // plan 1 should be first in the list because it was created after plan 2 - ).toMatchInlineSnapshot(` + ).resolves.toMatchInlineSnapshot(` [ { name: __default__, resolvedVersionPlans: [ { absolutePath: /version-plans/plan1.md, + commit: null, createdOnMs: 20, fileName: plan1.md, groupVersionBump: patch, @@ -802,6 +822,7 @@ describe('version-plans', () => { }, { absolutePath: /version-plans/plan2.md, + commit: null, createdOnMs: 19, fileName: plan2.md, groupVersionBump: minor, @@ -819,7 +840,7 @@ describe('version-plans', () => { `); }); - it('should correctly handle independent default group', () => { + it('should correctly handle independent default group', async () => { const rawVersionPlans: RawVersionPlan[] = [ versionPlan({ name: 'plan2.md', @@ -859,21 +880,23 @@ describe('version-plans', () => { ]; const allProjectNamesInWorkspace: string[] = ['pkg1', 'pkg2', 'pkg3']; - expect( + await expect( peelResultFromGroups( setResolvedVersionPlansOnGroups( rawVersionPlans, releaseGroups, - allProjectNamesInWorkspace + allProjectNamesInWorkspace, + false ) ) - ).toMatchInlineSnapshot(` + ).resolves.toMatchInlineSnapshot(` [ { name: __default__, resolvedVersionPlans: [ { absolutePath: /version-plans/plan3.md, + commit: null, createdOnMs: 23, fileName: plan3.md, message: plan3 message, @@ -886,6 +909,7 @@ describe('version-plans', () => { }, { absolutePath: /version-plans/plan1.md, + commit: null, createdOnMs: 22, fileName: plan1.md, message: plan1 message, @@ -898,6 +922,7 @@ describe('version-plans', () => { }, { absolutePath: /version-plans/plan2.md, + commit: null, createdOnMs: 21, fileName: plan2.md, message: plan2 message, @@ -916,7 +941,7 @@ describe('version-plans', () => { }); describe('for explicit groups', () => { - it('should correctly handle fixed and independent groups', () => { + it('should correctly handle fixed and independent groups', async () => { const rawVersionPlans: RawVersionPlan[] = [ versionPlan({ name: 'plan2.md', @@ -987,21 +1012,23 @@ describe('version-plans', () => { 'pkg5', ]; - expect( + await expect( peelResultFromGroups( setResolvedVersionPlansOnGroups( rawVersionPlans, releaseGroups, - allProjectNamesInWorkspace + allProjectNamesInWorkspace, + false ) ) - ).toMatchInlineSnapshot(` + ).resolves.toMatchInlineSnapshot(` [ { name: group1, resolvedVersionPlans: [ { absolutePath: /version-plans/plan3.md, + commit: null, createdOnMs: 26, fileName: plan3.md, groupVersionBump: major, @@ -1010,6 +1037,7 @@ describe('version-plans', () => { }, { absolutePath: /version-plans/plan1.md, + commit: null, createdOnMs: 25, fileName: plan1.md, groupVersionBump: patch, @@ -1018,6 +1046,7 @@ describe('version-plans', () => { }, { absolutePath: /version-plans/plan2.md, + commit: null, createdOnMs: 24, fileName: plan2.md, groupVersionBump: minor, @@ -1034,6 +1063,7 @@ describe('version-plans', () => { resolvedVersionPlans: [ { absolutePath: /version-plans/plan3.md, + commit: null, createdOnMs: 26, fileName: plan3.md, groupVersionBump: patch, @@ -1042,6 +1072,7 @@ describe('version-plans', () => { }, { absolutePath: /version-plans/plan2.md, + commit: null, createdOnMs: 24, fileName: plan2.md, groupVersionBump: minor, @@ -1058,6 +1089,7 @@ describe('version-plans', () => { resolvedVersionPlans: [ { absolutePath: /version-plans/plan1.md, + commit: null, createdOnMs: 25, fileName: plan1.md, groupVersionBump: major, @@ -1066,6 +1098,7 @@ describe('version-plans', () => { }, { absolutePath: /version-plans/plan2.md, + commit: null, createdOnMs: 24, fileName: plan2.md, groupVersionBump: minor, @@ -1082,6 +1115,7 @@ describe('version-plans', () => { resolvedVersionPlans: [ { absolutePath: /version-plans/plan3.md, + commit: null, createdOnMs: 26, fileName: plan3.md, message: plan3 message, @@ -1092,6 +1126,7 @@ describe('version-plans', () => { }, { absolutePath: /version-plans/plan1.md, + commit: null, createdOnMs: 25, fileName: plan1.md, message: plan1 message, @@ -1143,11 +1178,15 @@ function releaseGroup( } as ReleaseGroupWithName; } -function peelResultFromGroups(releaseGroups: ReleaseGroupWithName[]): { - name: string; - resolvedVersionPlans: ReleaseGroupWithName['resolvedVersionPlans']; -}[] { - return releaseGroups.map((g) => ({ +async function peelResultFromGroups( + releaseGroupsPromise: Promise +): Promise< + { + name: string; + resolvedVersionPlans: ReleaseGroupWithName['resolvedVersionPlans']; + }[] +> { + return (await releaseGroupsPromise).map((g) => ({ name: g.name, resolvedVersionPlans: g.resolvedVersionPlans, })); diff --git a/packages/nx/src/command-line/release/config/version-plans.ts b/packages/nx/src/command-line/release/config/version-plans.ts index 0f397bf9cebb9..633f7e3c785e9 100644 --- a/packages/nx/src/command-line/release/config/version-plans.ts +++ b/packages/nx/src/command-line/release/config/version-plans.ts @@ -1,8 +1,10 @@ import { readFileSync, readdirSync } from 'fs'; import { pathExists, stat } from 'fs-extra'; +import { exec } from 'node:child_process'; import { join } from 'path'; import { RELEASE_TYPES, ReleaseType } from 'semver'; import { workspaceRoot } from '../../../utils/workspace-root'; +import { RawGitCommit } from '../utils/git'; import { IMPLICIT_DEFAULT_RELEASE_GROUP } from './config'; import { ReleaseGroupWithName } from './filter-release-groups'; const fm = require('front-matter'); @@ -21,10 +23,22 @@ export interface RawVersionPlan extends VersionPlanFile { export interface VersionPlan extends VersionPlanFile { message: string; + /** + * The commit that added the version plan file, will be null if the file was never committed. + * For optimal performance, we don't apply it at the time of reading the raw contents, because + * it hasn't yet passed further validation at that point. + */ + commit: RawGitCommit | null; } export interface GroupVersionPlan extends VersionPlan { groupVersionBump: ReleaseType; + /** + * The commit that added the version plan file, will be null if the file was never committed. + * For optimal performance, we don't apply it at the time of reading the raw contents, because. + * it hasn't yet passed validation. + */ + commit: RawGitCommit | null; /** * Will not be set if the group name was the trigger, otherwise will be a list of * all the individual project names explicitly found in the version plan file. @@ -67,11 +81,12 @@ export async function readRawVersionPlans(): Promise { return versionPlans; } -export function setResolvedVersionPlansOnGroups( +export async function setResolvedVersionPlansOnGroups( rawVersionPlans: RawVersionPlan[], releaseGroups: ReleaseGroupWithName[], - allProjectNamesInWorkspace: string[] -): ReleaseGroupWithName[] { + allProjectNamesInWorkspace: string[], + isVerbose: boolean +): Promise { const groupsByName = releaseGroups.reduce( (acc, group) => acc.set(group.name, group), new Map() @@ -158,6 +173,10 @@ export function setResolvedVersionPlansOnGroups( createdOnMs: rawVersionPlan.createdOnMs, message: rawVersionPlan.message, groupVersionBump: value, + commit: await getCommitForVersionPlanFile( + rawVersionPlan, + isVerbose + ), }); } } else { @@ -222,6 +241,10 @@ export function setResolvedVersionPlansOnGroups( projectVersionBumps: { [key]: value, }, + commit: await getCommitForVersionPlanFile( + rawVersionPlan, + isVerbose + ), }); } } else { @@ -257,6 +280,10 @@ export function setResolvedVersionPlansOnGroups( // but we track the projects that triggered the version bump so that we can accurately produce changelog entries. groupVersionBump: value, triggeredByProjects: [key], + commit: await getCommitForVersionPlanFile( + rawVersionPlan, + isVerbose + ), }); } } @@ -288,3 +315,43 @@ export function getVersionPlansAbsolutePath() { function isReleaseType(value: string): value is ReleaseType { return RELEASE_TYPES.includes(value as ReleaseType); } + +async function getCommitForVersionPlanFile( + rawVersionPlan: RawVersionPlan, + isVerbose: boolean +): Promise { + return new Promise((resolve) => { + exec( + `git log --diff-filter=A --pretty=format:"%s|%h|%an|%ae|%b" -n 1 -- ${rawVersionPlan.absolutePath}`, + (error, stdout, stderr) => { + if (error) { + if (isVerbose) { + console.error( + `Error executing git command for ${rawVersionPlan.relativePath}: ${error.message}` + ); + } + return resolve(null); + } + if (stderr) { + if (isVerbose) { + console.error( + `Git command stderr for ${rawVersionPlan.relativePath}: ${stderr}` + ); + } + return resolve(null); + } + + const [message, shortHash, authorName, authorEmail, ...body] = stdout + .trim() + .split('|'); + const commitDetails: RawGitCommit = { + message: message || '', + shortHash: shortHash || '', + author: { name: authorName || '', email: authorEmail || '' }, + body: body.join('|') || '', // Handle case where body might be empty or contain multiple '|' + }; + return resolve(commitDetails); + } + ); + }); +} diff --git a/packages/nx/src/command-line/release/plan-check.ts b/packages/nx/src/command-line/release/plan-check.ts index 807a7747ea99b..ab9b2b869558e 100644 --- a/packages/nx/src/command-line/release/plan-check.ts +++ b/packages/nx/src/command-line/release/plan-check.ts @@ -76,10 +76,11 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { } const rawVersionPlans = await readRawVersionPlans(); - setResolvedVersionPlansOnGroups( + await setResolvedVersionPlansOnGroups( rawVersionPlans, releaseGroups, - Object.keys(projectGraph.nodes) + Object.keys(projectGraph.nodes), + args.verbose ); // Resolve the final values for base, head etc to use when resolving the changes to consider diff --git a/packages/nx/src/command-line/release/release.ts b/packages/nx/src/command-line/release/release.ts index 467fe91354e36..8c45c29a1eaa6 100644 --- a/packages/nx/src/command-line/release/release.ts +++ b/packages/nx/src/command-line/release/release.ts @@ -143,10 +143,11 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { process.exit(1); } - setResolvedVersionPlansOnGroups( + await setResolvedVersionPlansOnGroups( rawVersionPlans, releaseGroups, - Object.keys(projectGraph.nodes) + Object.keys(projectGraph.nodes), + args.verbose ); const planFiles = new Set(); diff --git a/packages/nx/src/command-line/release/utils/git.ts b/packages/nx/src/command-line/release/utils/git.ts index 90511f8d0be86..49d082f3bb2b0 100644 --- a/packages/nx/src/command-line/release/utils/git.ts +++ b/packages/nx/src/command-line/release/utils/git.ts @@ -403,6 +403,35 @@ export function parseConventionalCommitsMessage(message: string): { }; } +function extractReferencesFromCommitMessage( + message: string, + shortHash: string +): Reference[] { + const references: Reference[] = []; + for (const m of message.matchAll(PullRequestRE)) { + references.push({ type: 'pull-request', value: m[1] }); + } + for (const m of message.matchAll(IssueRE)) { + if (!references.some((i) => i.value === m[1])) { + references.push({ type: 'issue', value: m[1] }); + } + } + references.push({ value: shortHash, type: 'hash' }); + return references; +} + +function getAllAuthorsForCommit(commit: RawGitCommit): GitCommitAuthor[] { + const authors: GitCommitAuthor[] = [commit.author]; + // Additional authors can be specified in the commit body (depending on the VCS provider) + for (const match of commit.body.matchAll(CoAuthoredByRegex)) { + authors.push({ + name: (match.groups.name || '').trim(), + email: (match.groups.email || '').trim(), + }); + } + return authors; +} + // https://www.conventionalcommits.org/en/v1.0.0/ // https://regex101.com/r/FSfNvA/1 const ConventionalCommitRegex = @@ -413,7 +442,32 @@ const IssueRE = /(#\d+)/gm; const ChangedFileRegex = /(A|M|D|R\d*|C\d*)\t([^\t\n]*)\t?(.*)?/gm; const RevertHashRE = /This reverts commit (?[\da-f]{40})./gm; -export function parseGitCommit(commit: RawGitCommit): GitCommit | null { +export function parseGitCommit( + commit: RawGitCommit, + isVersionPlanCommit = false +): GitCommit | null { + // For version plans, we do not require conventional commits and therefore cannot extract data based on that format + if (isVersionPlanCommit) { + return { + ...commit, + description: commit.message, + type: '', + scope: '', + references: extractReferencesFromCommitMessage( + commit.message, + commit.shortHash + ), + // The commit message is not the source of truth for a breaking (major) change in version plans, so the value is not relevant + // TODO(v20): Make the current GitCommit interface more clearly tied to conventional commits + isBreaking: false, + authors: getAllAuthorsForCommit(commit), + // Not applicable to version plans + affectedFiles: [], + // Not applicable, a version plan cannot have been added in a commit that also reverts another commit + revertedHashes: [], + }; + } + const parsedMessage = parseConventionalCommitsMessage(commit.message); if (!parsedMessage) { return null; @@ -425,16 +479,10 @@ export function parseGitCommit(commit: RawGitCommit): GitCommit | null { let description = parsedMessage.description; // Extract references from message - const references: Reference[] = []; - for (const m of description.matchAll(PullRequestRE)) { - references.push({ type: 'pull-request', value: m[1] }); - } - for (const m of description.matchAll(IssueRE)) { - if (!references.some((i) => i.value === m[1])) { - references.push({ type: 'issue', value: m[1] }); - } - } - references.push({ value: commit.shortHash, type: 'hash' }); + const references = extractReferencesFromCommitMessage( + description, + commit.shortHash + ); // Remove references and normalize description = description.replace(PullRequestRE, '').trim(); @@ -452,13 +500,7 @@ export function parseGitCommit(commit: RawGitCommit): GitCommit | null { } // Find all authors - const authors: GitCommitAuthor[] = [commit.author]; - for (const match of commit.body.matchAll(CoAuthoredByRegex)) { - authors.push({ - name: (match.groups.name || '').trim(), - email: (match.groups.email || '').trim(), - }); - } + const authors = getAllAuthorsForCommit(commit); // Extract file changes from commit body const affectedFiles = Array.from( diff --git a/packages/nx/src/command-line/release/version.ts b/packages/nx/src/command-line/release/version.ts index 180281d262f3b..07742a5dfcc57 100644 --- a/packages/nx/src/command-line/release/version.ts +++ b/packages/nx/src/command-line/release/version.ts @@ -187,10 +187,11 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { } if (!args.specifier) { const rawVersionPlans = await readRawVersionPlans(); - setResolvedVersionPlansOnGroups( + await setResolvedVersionPlansOnGroups( rawVersionPlans, releaseGroups, - Object.keys(projectGraph.nodes) + Object.keys(projectGraph.nodes), + args.verbose ); } else { if (args.verbose && releaseGroups.some((g) => !!g.versionPlans)) {