From b625a79ccaf192a8cfddf7071978e18e6a97ec5a Mon Sep 17 00:00:00 2001 From: Austin Fahsl Date: Tue, 13 Feb 2024 17:32:35 -0700 Subject: [PATCH] fix(release): move github release creation to git tasks (#21510) --- docs/generated/devkit/FileChange.md | 4 +- docs/shared/features/manage-releases.md | 6 +- e2e/release/src/create-github-release.test.ts | 165 +++++++ e2e/release/src/release.test.ts | 33 +- .../nx/src/command-line/release/changelog.ts | 467 +++++++----------- .../command-line/release/command-object.ts | 1 + .../nx/src/command-line/release/release.ts | 84 +++- .../nx/src/command-line/release/utils/git.ts | 43 +- .../src/command-line/release/utils/github.ts | 89 +++- .../src/command-line/release/utils/shared.ts | 5 + 10 files changed, 595 insertions(+), 302 deletions(-) create mode 100644 e2e/release/src/create-github-release.test.ts diff --git a/docs/generated/devkit/FileChange.md b/docs/generated/devkit/FileChange.md index ca665749c3bb9..3f53e9ba7f891 100644 --- a/docs/generated/devkit/FileChange.md +++ b/docs/generated/devkit/FileChange.md @@ -9,7 +9,7 @@ Description of a file change in the Nx virtual file system/ - [content](../../devkit/documents/FileChange#content): Buffer - [options](../../devkit/documents/FileChange#options): TreeWriteOptions - [path](../../devkit/documents/FileChange#path): string -- [type](../../devkit/documents/FileChange#type): "DELETE" | "CREATE" | "UPDATE" +- [type](../../devkit/documents/FileChange#type): "CREATE" | "DELETE" | "UPDATE" ## Properties @@ -39,6 +39,6 @@ Path relative to the workspace root ### type -• **type**: `"DELETE"` \| `"CREATE"` \| `"UPDATE"` +• **type**: `"CREATE"` \| `"DELETE"` \| `"UPDATE"` Type of change: 'CREATE' | 'DELETE' | 'UPDATE' diff --git a/docs/shared/features/manage-releases.md b/docs/shared/features/manage-releases.md index 808d783bf9837..031483cf9e8e1 100644 --- a/docs/shared/features/manage-releases.md +++ b/docs/shared/features/manage-releases.md @@ -105,16 +105,12 @@ import * as yargs from 'yargs'; verbose: options.verbose, }); - // The returned number value from releaseChangelog will be non-zero if something went wrong - const changelogStatus = await releaseChangelog({ + await releaseChangelog({ versionData: projectsVersionData, version: workspaceVersion, dryRun: options.dryRun, verbose: options.verbose, }); - if (changelogStatus !== 0) { - process.exit(changelogStatus); - } // The returned number value from releasePublish will be zero if all projects are published successfully, non-zero if not const publishStatus = await releasePublish({ diff --git a/e2e/release/src/create-github-release.test.ts b/e2e/release/src/create-github-release.test.ts new file mode 100644 index 0000000000000..fc1ec518ee63e --- /dev/null +++ b/e2e/release/src/create-github-release.test.ts @@ -0,0 +1,165 @@ +import { NxJsonConfiguration } from '@nx/devkit'; +import { + cleanupProject, + newProject, + runCLI, + runCommandAsync, + uniq, + updateJson, +} from '@nx/e2e/utils'; + +expect.addSnapshotSerializer({ + serialize(str: string) { + return ( + str + // Remove all output unique to specific projects to ensure deterministic snapshots + .replaceAll(/my-pkg-\d+/g, '{project-name}') + .replaceAll( + /integrity:\s*.*/g, + 'integrity: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' + ) + .replaceAll(/\b[0-9a-f]{40}\b/g, '{SHASUM}') + .replaceAll(/\d*B index\.js/g, 'XXB index.js') + .replaceAll(/\d*B project\.json/g, 'XXB project.json') + .replaceAll(/\d*B package\.json/g, 'XXXB package.json') + .replaceAll(/size:\s*\d*\s?B/g, 'size: XXXB') + .replaceAll(/\d*\.\d*\s?kB/g, 'XXX.XXX kb') + .replaceAll(/[a-fA-F0-9]{7}/g, '{COMMIT_SHA}') + .replaceAll(/Test @[\w\d]+/g, 'Test @{COMMIT_AUTHOR}') + // Normalize the version title date. + .replaceAll(/\(\d{4}-\d{2}-\d{2}\)/g, '(YYYY-MM-DD)') + // We trim each line to reduce the chances of snapshot flakiness + .split('\n') + .map((r) => r.trim()) + .join('\n') + ); + }, + test(val: string) { + return val != null && typeof val === 'string'; + }, +}); + +describe('nx release create github release', () => { + let pkg1: string; + let pkg2: string; + let pkg3: string; + + beforeAll(async () => { + newProject({ + unsetProjectNameAndRootFormat: false, + packages: ['@nx/js'], + }); + + pkg1 = uniq('my-pkg-1'); + runCLI(`generate @nx/workspace:npm-package ${pkg1}`); + + pkg2 = uniq('my-pkg-2'); + runCLI(`generate @nx/workspace:npm-package ${pkg2}`); + + pkg3 = uniq('my-pkg-3'); + runCLI(`generate @nx/workspace:npm-package ${pkg3}`); + + // Update pkg2 to depend on pkg1 + updateJson(`${pkg2}/package.json`, (json) => { + json.dependencies ??= {}; + json.dependencies[`@proj/${pkg1}`] = '0.0.0'; + return json; + }); + + // 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"`); + + // update my-pkg-1 with a fix commit + updateJson(`${pkg1}/package.json`, (json) => ({ + ...json, + license: 'MIT', + })); + await runCommandAsync(`git add ${pkg1}/package.json`); + await runCommandAsync(`git commit -m "fix(${pkg1}): fix 1"`); + + // update my-pkg-2 with a breaking change + updateJson(`${pkg2}/package.json`, (json) => ({ + ...json, + license: 'GNU GPLv3', + })); + await runCommandAsync(`git add ${pkg2}/package.json`); + await runCommandAsync(`git commit -m "feat(${pkg2})!: breaking change 2"`); + + // update my-pkg-3 with a feature commit + updateJson(`${pkg3}/package.json`, (json) => ({ + ...json, + license: 'GNU GPLv3', + })); + await runCommandAsync(`git add ${pkg3}/package.json`); + await runCommandAsync(`git commit -m "feat(${pkg3}): feat 3"`); + + // We need a valid git origin to exist for the commit references to work (and later the test for createRelease) + await runCommandAsync( + `git remote add origin https://github.com/nrwl/fake-repo.git` + ); + }); + afterAll(() => cleanupProject()); + + it('should create github release for the first release', async () => { + updateJson('nx.json', (nxJson) => { + nxJson.release = { + changelog: { + workspaceChangelog: { + createRelease: 'github', + }, + }, + }; + return nxJson; + }); + const result = runCLI('release patch -d --first-release --verbose'); + + expect( + result.match(new RegExp(`> NX Pushing to git remote`, 'g')).length + ).toEqual(1); + expect( + result.match(new RegExp(`> NX Creating GitHub Release`, 'g')).length + ).toEqual(1); + + // should have two occurrences of each - one for the changelog file, one for the 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 + ).toEqual(2); + }); + + it('should create github releases for all independent packages', async () => { + updateJson('nx.json', (nxJson) => { + nxJson.release = { + projectsRelationship: 'independent', + version: { + conventionalCommits: true, + }, + changelog: { + projectChangelogs: { + file: false, + createRelease: 'github', + }, + }, + }; + return nxJson; + }); + + const result = runCLI('release -d --first-release --verbose'); + + expect( + result.match(new RegExp(`> NX Pushing to git remote`, 'g')).length + ).toEqual(1); + expect( + result.match(new RegExp(`> NX Creating GitHub Release`, 'g')).length + ).toEqual(3); + + // should have one occurrence of each because files are disabled + 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 + ).toEqual(1); + }); +}); diff --git a/e2e/release/src/release.test.ts b/e2e/release/src/release.test.ts index 4be781c5978e5..0de69d214089f 100644 --- a/e2e/release/src/release.test.ts +++ b/e2e/release/src/release.test.ts @@ -726,7 +726,7 @@ ${JSON.stringify( - > NX Previewing a GitHub release and an entry in {project-name}/CHANGELOG.md for v1000.0.0-next.0 + > NX Previewing an entry in {project-name}/CHANGELOG.md for v1000.0.0-next.0 + ## 1000.0.0-next.0 @@ -734,7 +734,7 @@ ${JSON.stringify( + This was a version bump only for {project-name} to align it with other projects, there were no code changes. - > NX Previewing a GitHub release and an entry in {project-name}/CHANGELOG.md for v1000.0.0-next.0 + > NX Previewing an entry in {project-name}/CHANGELOG.md for v1000.0.0-next.0 + ## 1000.0.0-next.0 @@ -742,7 +742,7 @@ ${JSON.stringify( + This was a version bump only for {project-name} to align it with other projects, there were no code changes. - > NX Previewing a GitHub release and an entry in {project-name}/CHANGELOG.md for v1000.0.0-next.0 + > NX Previewing an entry in {project-name}/CHANGELOG.md for v1000.0.0-next.0 + ## 1000.0.0-next.0 @@ -756,6 +756,33 @@ ${JSON.stringify( > NX Tagging commit with git + > NX Pushing to git remote + + + > NX Creating GitHub Release + + + + ## 1000.0.0-next.0 + + + + This was a version bump only for {project-name} to align it with other projects, there were no code changes. + + + > NX Creating GitHub Release + + + + ## 1000.0.0-next.0 + + + + This was a version bump only for {project-name} to align it with other projects, there were no code changes. + + + > NX Creating GitHub Release + + + + ## 1000.0.0-next.0 + + + + This was a version bump only for {project-name} to align it with other projects, there were no code changes. + + `); // port and process cleanup diff --git a/packages/nx/src/command-line/release/changelog.ts b/packages/nx/src/command-line/release/changelog.ts index 6f37baf5f774a..04b963dc3fa43 100644 --- a/packages/nx/src/command-line/release/changelog.ts +++ b/packages/nx/src/command-line/release/changelog.ts @@ -3,7 +3,10 @@ import { readFileSync, writeFileSync } from 'node:fs'; import { valid } from 'semver'; import { dirSync } from 'tmp'; import type { ChangelogRenderer } from '../../../release/changelog-renderer'; -import { readNxJson } from '../../config/nx-json'; +import { + NxReleaseChangelogConfiguration, + readNxJson, +} from '../../config/nx-json'; import { ProjectGraph, ProjectGraphProjectNode, @@ -38,17 +41,10 @@ import { gitTag, parseCommits, } from './utils/git'; -import { - GithubRelease, - GithubRequestConfig, - createOrUpdateGithubRelease, - getGitHubRepoSlug, - getGithubReleaseByTag, - resolveGithubToken, -} from './utils/github'; +import { createOrUpdateGithubRelease, getGitHubRepoSlug } from './utils/github'; import { launchEditor } from './utils/launch-editor'; import { parseChangelogMarkdown } from './utils/markdown'; -import { printAndFlushChanges, printDiff } from './utils/print-changes'; +import { printAndFlushChanges } from './utils/print-changes'; import { resolveNxJsonConfigErrorMessage } from './utils/resolve-nx-json-error-message'; import { ReleaseVersion, @@ -57,8 +53,22 @@ import { createCommitMessageValues, createGitTagValues, handleDuplicateGitTags, + noDiffInChangelogMessage, } from './utils/shared'; +export interface NxReleaseChangelogResult { + workspaceChangelog?: { + releaseVersion: ReleaseVersion; + contents: string; + }; + projectChangelogs?: { + [projectName: string]: { + releaseVersion: ReleaseVersion; + contents: string; + }; + }; +} + type PostGitTask = (latestCommit: string) => Promise; export const releaseChangelogCLIHandler = (args: ChangelogOptions) => @@ -71,7 +81,7 @@ export const releaseChangelogCLIHandler = (args: ChangelogOptions) => */ export async function releaseChangelog( args: ChangelogOptions -): Promise { +): Promise { const projectGraph = await createProjectGraphAsync({ exitOnError: true }); const nxJson = readNxJson(); @@ -134,7 +144,7 @@ export async function releaseChangelog( `To explicitly enable changelog generation, configure "release.changelog.workspaceChangelog" or "release.changelog.projectChangelogs" in nx.json.`, ], }); - return 0; + return {}; } const useAutomaticFromRef = @@ -230,16 +240,51 @@ export async function releaseChangelog( toSHA ); - await generateChangelogForWorkspace( + const workspaceChangelog = await generateChangelogForWorkspace( tree, args, projectGraph, nxReleaseConfig, workspaceChangelogVersion, - workspaceChangelogCommits, - postGitTasks + workspaceChangelogCommits ); + if ( + workspaceChangelog && + shouldCreateGitHubRelease( + nxReleaseConfig.changelog.workspaceChangelog, + args.createRelease + ) + ) { + let hasPushed = false; + + postGitTasks.push(async (latestCommit) => { + if (!hasPushed) { + output.logSingleLine(`Pushing to git remote`); + + // Before we can create/update the release we need to ensure the commit exists on the remote + await gitPush({ + gitRemote: args.gitRemote, + dryRun: args.dryRun, + verbose: args.verbose, + }); + hasPushed = true; + } + + output.logSingleLine(`Creating GitHub Release`); + + await createOrUpdateGithubRelease( + workspaceChangelog.releaseVersion, + workspaceChangelog.contents, + latestCommit, + { dryRun: args.dryRun } + ); + }); + } + + const allProjectChangelogs: NxReleaseChangelogResult['projectChangelogs'] = + {}; + for (const releaseGroup of releaseGroups) { const config = releaseGroup.changelog; // The entire feature is disabled at the release group level, exit early @@ -292,7 +337,7 @@ export async function releaseChangelog( commits = await getCommits(fromRef, toSHA); } - await generateChangelogForProjects( + const projectChangelogs = await generateChangelogForProjects( tree, args, projectGraph, @@ -302,6 +347,43 @@ export async function releaseChangelog( releaseGroup, [project] ); + + let hasPushed = false; + for (const [projectName, projectChangelog] of Object.entries( + projectChangelogs + )) { + if ( + projectChangelogs && + shouldCreateGitHubRelease( + releaseGroup.changelog, + args.createRelease + ) + ) { + postGitTasks.push(async (latestCommit) => { + if (!hasPushed) { + output.logSingleLine(`Pushing to git remote`); + + // Before we can create/update the release we need to ensure the commit exists on the remote + await gitPush({ + gitRemote: args.gitRemote, + dryRun: args.dryRun, + verbose: args.verbose, + }); + hasPushed = true; + } + + output.logSingleLine(`Creating GitHub Release`); + + await createOrUpdateGithubRelease( + projectChangelog.releaseVersion, + projectChangelog.contents, + latestCommit, + { dryRun: args.dryRun } + ); + }); + } + allProjectChangelogs[projectName] = projectChangelog; + } } } else { const fromRef = @@ -318,7 +400,7 @@ export async function releaseChangelog( const commits = await getCommits(fromSHA, toSHA); - await generateChangelogForProjects( + const projectChangelogs = await generateChangelogForProjects( tree, args, projectGraph, @@ -328,10 +410,44 @@ export async function releaseChangelog( releaseGroup, projectNodes ); + + let hasPushed = false; + for (const [projectName, projectChangelog] of Object.entries( + projectChangelogs + )) { + if ( + projectChangelogs && + shouldCreateGitHubRelease(releaseGroup.changelog, args.createRelease) + ) { + postGitTasks.push(async (latestCommit) => { + if (!hasPushed) { + output.logSingleLine(`Pushing to git remote`); + + // Before we can create/update the release we need to ensure the commit exists on the remote + await gitPush({ + gitRemote: args.gitRemote, + dryRun: args.dryRun, + verbose: args.verbose, + }); + hasPushed = true; + } + + output.logSingleLine(`Creating GitHub Release`); + + await createOrUpdateGithubRelease( + projectChangelog.releaseVersion, + projectChangelog.contents, + latestCommit, + { dryRun: args.dryRun } + ); + }); + } + allProjectChangelogs[projectName] = projectChangelog; + } } } - return await applyChangesAndExit( + await applyChangesAndExit( args, nxReleaseConfig, tree, @@ -340,6 +456,11 @@ export async function releaseChangelog( commitMessageValues, gitTagValues ); + + return { + workspaceChangelog, + projectChangelogs: allProjectChangelogs, + }; } function resolveChangelogVersions( @@ -429,7 +550,7 @@ async function applyChangesAndExit( `No changes were detected for any changelog files, so no changelog entries will be generated.`, ], }); - return 0; + return; } // Generate a new commit for the changes, if configured to do so @@ -475,7 +596,7 @@ async function applyChangesAndExit( await postGitTask(latestCommit); } - return 0; + return; } function resolveChangelogRenderer( @@ -504,9 +625,8 @@ async function generateChangelogForWorkspace( projectGraph: ProjectGraph, nxReleaseConfig: NxReleaseConfig, workspaceChangelogVersion: (string | null) | undefined, - commits: GitCommit[], - postGitTasks: PostGitTask[] -) { + commits: GitCommit[] +): Promise { const config = nxReleaseConfig.changelog.workspaceChangelog; // The entire feature is disabled at the workspace level, exit early if (config === false) { @@ -572,27 +692,15 @@ async function generateChangelogForWorkspace( releaseTagPattern: nxReleaseConfig.releaseTagPattern, }); - // We are either creating/previewing a changelog file, a GitHub release, or both - let logTitle = dryRun ? 'Previewing a' : 'Generating a'; - switch (true) { - case interpolatedTreePath && config.createRelease === 'github': - logTitle += ` GitHub release and an entry in ${interpolatedTreePath} for ${chalk.white( - releaseVersion.gitTag - )}`; - break; - case !!interpolatedTreePath: - logTitle += `n entry in ${interpolatedTreePath} for ${chalk.white( + if (interpolatedTreePath) { + const prefix = dryRun ? 'Previewing' : 'Generating'; + output.log({ + title: `${prefix} an entry in ${interpolatedTreePath} for ${chalk.white( releaseVersion.gitTag - )}`; - break; - case config.createRelease === 'github': - logTitle += ` GitHub release for ${chalk.white(releaseVersion.gitTag)}`; + )}`, + }); } - output.log({ - title: logTitle, - }); - const githubRepoSlug = getGitHubRepoSlug(gitRemote); let contents = await changelogRenderer({ @@ -621,15 +729,6 @@ async function generateChangelogForWorkspace( contents = readFileSync(changelogPath, 'utf-8'); } - /** - * The exact logic we use for printing the summary/diff to the user is dependent upon whether they are creating - * a changelog file, a GitHub release, or both. - */ - let printSummary = () => {}; - const noDiffInChangelogMessage = chalk.yellow( - `NOTE: There was no diff detected for the changelog entry. Maybe you intended to pass alternative git references via --from and --to?` - ); - if (interpolatedTreePath) { let rootChangelogContents = tree.exists(interpolatedTreePath) ? tree.read(interpolatedTreePath).toString() @@ -659,104 +758,13 @@ async function generateChangelogForWorkspace( tree.write(interpolatedTreePath, rootChangelogContents); - printSummary = () => - printAndFlushChanges(tree, !!dryRun, 3, false, noDiffInChangelogMessage); + printAndFlushChanges(tree, !!dryRun, 3, false, noDiffInChangelogMessage); } - if (config.createRelease === 'github') { - if (!githubRepoSlug) { - output.error({ - title: `Unable to create a GitHub release because the GitHub repo slug could not be determined.`, - bodyLines: [ - `Please ensure you have a valid GitHub remote configured. You can run \`git remote -v\` to list your current remotes.`, - ], - }); - process.exit(1); - } - - const token = await resolveGithubToken(); - const githubRequestConfig: GithubRequestConfig = { - repo: githubRepoSlug, - token, - }; - - let existingGithubReleaseForVersion: GithubRelease; - try { - existingGithubReleaseForVersion = await getGithubReleaseByTag( - githubRequestConfig, - releaseVersion.gitTag - ); - } catch (err) { - if (err.response?.status === 401) { - output.error({ - title: `Unable to resolve data via the GitHub API. You can use any of the following options to resolve this:`, - bodyLines: [ - '- Set the `GITHUB_TOKEN` or `GH_TOKEN` environment variable to a valid GitHub token with `repo` scope', - '- Have an active session via the official gh CLI tool (https://cli.github.com) in your current terminal', - ], - }); - process.exit(1); - } - if (err.response?.status === 404) { - // No existing release found, this is fine - } else { - // Rethrow unknown errors for now - throw err; - } - } - - let existingPrintSummaryFn = printSummary; - printSummary = () => { - const logTitle = `https://github.com/${githubRepoSlug}/releases/tag/${releaseVersion.gitTag}`; - if (existingGithubReleaseForVersion) { - console.error( - `${chalk.white('UPDATE')} ${logTitle}${ - dryRun ? chalk.keyword('orange')(' [dry-run]') : '' - }` - ); - } else { - console.error( - `${chalk.green('CREATE')} ${logTitle}${ - dryRun ? chalk.keyword('orange')(' [dry-run]') : '' - }` - ); - } - // Only print the diff here if we are not already going to be printing changes from the Tree - if (!interpolatedTreePath) { - console.log(''); - printDiff( - existingGithubReleaseForVersion - ? existingGithubReleaseForVersion.body - : '', - contents, - 3, - noDiffInChangelogMessage - ); - } - existingPrintSummaryFn(); - }; - - // Only schedule the actual GitHub update when not in dry-run mode - if (!dryRun) { - postGitTasks.push(async (latestCommit) => { - // Before we can create/update the release we need to ensure the commit exists on the remote - await gitPush(); - - await createOrUpdateGithubRelease( - githubRequestConfig, - { - version: releaseVersion.gitTag, - prerelease: releaseVersion.isPrerelease, - body: contents, - commit: latestCommit, - }, - existingGithubReleaseForVersion - ); - }); - } - } - - printSummary(); + return { + releaseVersion, + contents, + }; } async function generateChangelogForProjects( @@ -768,7 +776,7 @@ async function generateChangelogForProjects( postGitTasks: PostGitTask[], releaseGroup: ReleaseGroupWithName, projects: ProjectGraphProjectNode[] -) { +): Promise { const config = releaseGroup.changelog; // The entire feature is disabled at the release group level, exit early if (config === false) { @@ -783,6 +791,8 @@ async function generateChangelogForProjects( const changelogRenderer = resolveChangelogRenderer(config.renderer); + const projectChangelogs: NxReleaseChangelogResult['projectChangelogs'] = {}; + for (const project of projects) { let interpolatedTreePath = config.file || ''; if (interpolatedTreePath) { @@ -807,27 +817,15 @@ async function generateChangelogForProjects( projectName: project.name, }); - // We are either creating/previewing a changelog file, a GitHub release, or both - let logTitle = dryRun ? 'Previewing a' : 'Generating a'; - switch (true) { - case interpolatedTreePath && config.createRelease === 'github': - logTitle += ` GitHub release and an entry in ${interpolatedTreePath} for ${chalk.white( - releaseVersion.gitTag - )}`; - break; - case !!interpolatedTreePath: - logTitle += `n entry in ${interpolatedTreePath} for ${chalk.white( + if (interpolatedTreePath) { + const prefix = dryRun ? 'Previewing' : 'Generating'; + output.log({ + title: `${prefix} an entry in ${interpolatedTreePath} for ${chalk.white( releaseVersion.gitTag - )}`; - break; - case config.createRelease === 'github': - logTitle += ` GitHub release for ${chalk.white(releaseVersion.gitTag)}`; + )}`, + }); } - output.log({ - title: logTitle, - }); - const githubRepoSlug = config.createRelease === 'github' ? getGitHubRepoSlug(gitRemote) @@ -866,15 +864,6 @@ async function generateChangelogForProjects( contents = readFileSync(changelogPath, 'utf-8'); } - /** - * The exact logic we use for printing the summary/diff to the user is dependent upon whether they are creating - * a changelog file, a GitHub release, or both. - */ - let printSummary = () => {}; - const noDiffInChangelogMessage = chalk.yellow( - `NOTE: There was no diff detected for the changelog entry. Maybe you intended to pass alternative git references via --from and --to?` - ); - if (interpolatedTreePath) { let changelogContents = tree.exists(interpolatedTreePath) ? tree.read(interpolatedTreePath).toString() @@ -903,113 +892,24 @@ async function generateChangelogForProjects( tree.write(interpolatedTreePath, changelogContents); - printSummary = () => - printAndFlushChanges( - tree, - !!dryRun, - 3, - false, - noDiffInChangelogMessage, - // Only print the change for the current changelog file at this point - (f) => f.path === interpolatedTreePath - ); - } - - if (config.createRelease === 'github') { - if (!githubRepoSlug) { - output.error({ - title: `Unable to create a GitHub release because the GitHub repo slug could not be determined.`, - bodyLines: [ - `Please ensure you have a valid GitHub remote configured. You can run \`git remote -v\` to list your current remotes.`, - ], - }); - process.exit(1); - } - - const token = await resolveGithubToken(); - const githubRequestConfig: GithubRequestConfig = { - repo: githubRepoSlug, - token, - }; - - let existingGithubReleaseForVersion: GithubRelease; - try { - existingGithubReleaseForVersion = await getGithubReleaseByTag( - githubRequestConfig, - releaseVersion.gitTag - ); - } catch (err) { - if (err.response?.status === 401) { - output.error({ - title: `Unable to resolve data via the GitHub API. You can use any of the following options to resolve this:`, - bodyLines: [ - '- Set the `GITHUB_TOKEN` or `GH_TOKEN` environment variable to a valid GitHub token with `repo` scope', - '- Have an active session via the official gh CLI tool (https://cli.github.com) in your current terminal', - ], - }); - process.exit(1); - } - if (err.response?.status === 404) { - // No existing release found, this is fine - } else { - // Rethrow unknown errors for now - throw err; - } - } - - let existingPrintSummaryFn = printSummary; - printSummary = () => { - const logTitle = `https://github.com/${githubRepoSlug}/releases/tag/${releaseVersion.gitTag}`; - if (existingGithubReleaseForVersion) { - console.error( - `${chalk.white('UPDATE')} ${logTitle}${ - dryRun ? chalk.keyword('orange')(' [dry-run]') : '' - }` - ); - } else { - console.error( - `${chalk.green('CREATE')} ${logTitle}${ - dryRun ? chalk.keyword('orange')(' [dry-run]') : '' - }` - ); - } - // Only print the diff here if we are not already going to be printing changes from the Tree - if (!interpolatedTreePath) { - console.log(''); - printDiff( - existingGithubReleaseForVersion - ? existingGithubReleaseForVersion.body - : '', - contents, - 3, - noDiffInChangelogMessage - ); - } - existingPrintSummaryFn(); - }; - - // Only schedule the actual GitHub update when not in dry-run mode - if (!dryRun) { - postGitTasks.push(async (latestCommit) => { - // Before we can create/update the release we need to ensure the commit exists on the remote - await gitPush(gitRemote); - - await createOrUpdateGithubRelease( - githubRequestConfig, - { - version: releaseVersion.gitTag, - prerelease: releaseVersion.isPrerelease, - body: contents, - commit: latestCommit, - }, - existingGithubReleaseForVersion - ); - }); - } + printAndFlushChanges( + tree, + !!dryRun, + 3, + false, + noDiffInChangelogMessage, + // Only print the change for the current changelog file at this point + (f) => f.path === interpolatedTreePath + ); } - printSummary(); + projectChangelogs[project.name] = { + releaseVersion, + contents, + }; } + + return projectChangelogs; } function checkChangelogFilesEnabled(nxReleaseConfig: NxReleaseConfig): boolean { @@ -1040,3 +940,14 @@ async function getCommits(fromSHA: string, toSHA: string) { return false; }); } + +export function shouldCreateGitHubRelease( + changelogConfig: NxReleaseChangelogConfiguration | false | undefined, + createReleaseArg: ChangelogOptions['createRelease'] | undefined = undefined +): boolean { + if (createReleaseArg !== undefined) { + return createReleaseArg === 'github'; + } + + return (changelogConfig || {}).createRelease === 'github'; +} diff --git a/packages/nx/src/command-line/release/command-object.ts b/packages/nx/src/command-line/release/command-object.ts index bdce6a41d83b6..18c734b3a7de1 100644 --- a/packages/nx/src/command-line/release/command-object.ts +++ b/packages/nx/src/command-line/release/command-object.ts @@ -45,6 +45,7 @@ export type ChangelogOptions = NxReleaseArgs & from?: string; interactive?: string; gitRemote?: string; + createRelease?: false | 'github'; }; export type PublishOptions = NxReleaseArgs & diff --git a/packages/nx/src/command-line/release/release.ts b/packages/nx/src/command-line/release/release.ts index cc2f190399cee..e7ebd75ba8904 100644 --- a/packages/nx/src/command-line/release/release.ts +++ b/packages/nx/src/command-line/release/release.ts @@ -3,7 +3,7 @@ import { readNxJson } from '../../config/nx-json'; import { output } from '../../devkit-exports'; import { createProjectGraphAsync } from '../../project-graph/project-graph'; import { handleErrors } from '../../utils/params'; -import { releaseChangelog } from './changelog'; +import { releaseChangelog, shouldCreateGitHubRelease } from './changelog'; import { ReleaseOptions, VersionOptions } from './command-object'; import { createNxReleaseConfig, @@ -11,7 +11,8 @@ import { } from './config/config'; import { filterReleaseGroups } from './config/filter-release-groups'; import { releasePublish } from './publish'; -import { gitCommit, gitTag } from './utils/git'; +import { getCommitHash, gitCommit, gitPush, gitTag } from './utils/git'; +import { createOrUpdateGithubRelease } from './utils/github'; import { resolveNxJsonConfigErrorMessage } from './utils/resolve-nx-json-error-message'; import { createCommitMessageValues, @@ -74,13 +75,14 @@ export async function release( gitTag: false, }); - await releaseChangelog({ + const changelogResult = await releaseChangelog({ ...args, versionData: versionResult.projectsVersionData, version: versionResult.workspaceVersion, stageChanges: shouldStage, gitCommit: false, gitTag: false, + createRelease: false, }); const { @@ -140,6 +142,82 @@ export async function release( } } + const shouldCreateWorkspaceRelease = shouldCreateGitHubRelease( + nxReleaseConfig.changelog.workspaceChangelog + ); + + let hasPushedChanges = false; + let latestCommit: string | undefined; + + if (shouldCreateWorkspaceRelease && changelogResult.workspaceChangelog) { + output.logSingleLine(`Pushing to git remote`); + + // Before we can create/update the release we need to ensure the commit exists on the remote + await gitPush({ + dryRun: args.dryRun, + verbose: args.verbose, + }); + + hasPushedChanges = true; + + output.logSingleLine(`Creating GitHub Release`); + + latestCommit = await getCommitHash('HEAD'); + await createOrUpdateGithubRelease( + changelogResult.workspaceChangelog.releaseVersion, + changelogResult.workspaceChangelog.contents, + latestCommit, + { dryRun: args.dryRun } + ); + } + + for (const releaseGroup of releaseGroups) { + const shouldCreateProjectReleases = shouldCreateGitHubRelease( + releaseGroup.changelog + ); + + if (shouldCreateProjectReleases && changelogResult.projectChangelogs) { + const projects = args.projects?.length + ? // If the user has passed a list of projects, we need to use the filtered list of projects within the release group + Array.from(releaseGroupToFilteredProjects.get(releaseGroup)) + : // Otherwise, we use the full list of projects within the release group + releaseGroup.projects; + const projectNodes = projects.map((name) => projectGraph.nodes[name]); + + for (const project of projectNodes) { + const changelog = changelogResult.projectChangelogs[project.name]; + if (!changelog) { + continue; + } + + if (!hasPushedChanges) { + output.logSingleLine(`Pushing to git remote`); + + // Before we can create/update the release we need to ensure the commit exists on the remote + await gitPush({ + dryRun: args.dryRun, + verbose: args.verbose, + }); + + hasPushedChanges = true; + } + + output.logSingleLine(`Creating GitHub Release`); + + if (!latestCommit) { + latestCommit = await getCommitHash('HEAD'); + } + + await createOrUpdateGithubRelease( + changelog.releaseVersion, + changelog.contents, + latestCommit, + { dryRun: args.dryRun } + ); + } + } + } + let hasNewVersion = false; // null means that all projects are versioned together but there were no changes if (versionResult.workspaceVersion !== null) { diff --git a/packages/nx/src/command-line/release/utils/git.ts b/packages/nx/src/command-line/release/utils/git.ts index 4b9e6d42e7e4b..40af41bacbdfb 100644 --- a/packages/nx/src/command-line/release/utils/git.ts +++ b/packages/nx/src/command-line/release/utils/git.ts @@ -249,17 +249,40 @@ export async function gitTag({ } } -export async function gitPush(gitRemote?: string) { +export async function gitPush({ + gitRemote, + dryRun, + verbose, +}: { + gitRemote?: string; + dryRun?: boolean; + verbose?: boolean; +}) { + const commandArgs = [ + 'push', + // NOTE: It's important we use --follow-tags, and not --tags, so that we are precise about what we are pushing + '--follow-tags', + '--no-verify', + '--atomic', + // Set custom git remote if provided + ...(gitRemote ? [gitRemote] : []), + ]; + + if (verbose) { + console.log( + dryRun + ? `Would push the current branch to the remote with the following command, but --dry-run was set:` + : `Pushing the current branch to the remote with the following command:` + ); + console.log(`git ${commandArgs.join(' ')}`); + } + + if (dryRun) { + return; + } + try { - await execCommand('git', [ - 'push', - // NOTE: It's important we use --follow-tags, and not --tags, so that we are precise about what we are pushing - '--follow-tags', - '--no-verify', - '--atomic', - // Set custom git remote if provided - ...(gitRemote ? [gitRemote] : []), - ]); + await execCommand('git', commandArgs); } catch (err) { throw new Error(`Unexpected git push error: ${err}`); } diff --git a/packages/nx/src/command-line/release/utils/github.ts b/packages/nx/src/command-line/release/utils/github.ts index 9ea2009cf62b8..6bfc992ce3468 100644 --- a/packages/nx/src/command-line/release/utils/github.ts +++ b/packages/nx/src/command-line/release/utils/github.ts @@ -11,6 +11,8 @@ import { homedir } from 'node:os'; import { output } from '../../../utils/output'; import { joinPathFragments } from '../../../utils/path'; import { Reference } from './git'; +import { printDiff } from './print-changes'; +import { ReleaseVersion, noDiffInChangelogMessage } from './shared'; // axios types and values don't seem to match import _axios = require('axios'); @@ -56,6 +58,91 @@ export function getGitHubRepoSlug(remoteName = 'origin'): RepoSlug { } } +export async function createOrUpdateGithubRelease( + releaseVersion: ReleaseVersion, + changelogContents: string, + latestCommit: string, + { dryRun }: { dryRun: boolean } +): Promise { + const githubRepoSlug = getGitHubRepoSlug(); + if (!githubRepoSlug) { + output.error({ + title: `Unable to create a GitHub release because the GitHub repo slug could not be determined.`, + bodyLines: [ + `Please ensure you have a valid GitHub remote configured. You can run \`git remote -v\` to list your current remotes.`, + ], + }); + process.exit(1); + } + + const token = await resolveGithubToken(); + const githubRequestConfig: GithubRequestConfig = { + repo: githubRepoSlug, + token, + }; + + let existingGithubReleaseForVersion: GithubRelease; + try { + existingGithubReleaseForVersion = await getGithubReleaseByTag( + githubRequestConfig, + releaseVersion.gitTag + ); + } catch (err) { + if (err.response?.status === 401) { + output.error({ + title: `Unable to resolve data via the GitHub API. You can use any of the following options to resolve this:`, + bodyLines: [ + '- Set the `GITHUB_TOKEN` or `GH_TOKEN` environment variable to a valid GitHub token with `repo` scope', + '- Have an active session via the official gh CLI tool (https://cli.github.com) in your current terminal', + ], + }); + process.exit(1); + } + if (err.response?.status === 404) { + // No existing release found, this is fine + } else { + // Rethrow unknown errors for now + throw err; + } + } + + const logTitle = `https://github.com/${githubRepoSlug}/releases/tag/${releaseVersion.gitTag}`; + if (existingGithubReleaseForVersion) { + console.error( + `${chalk.white('UPDATE')} ${logTitle}${ + dryRun ? chalk.keyword('orange')(' [dry-run]') : '' + }` + ); + } else { + console.error( + `${chalk.green('CREATE')} ${logTitle}${ + dryRun ? chalk.keyword('orange')(' [dry-run]') : '' + }` + ); + } + + console.log(''); + printDiff( + existingGithubReleaseForVersion ? existingGithubReleaseForVersion.body : '', + changelogContents, + 3, + noDiffInChangelogMessage + ); + + if (!dryRun) { + await createOrUpdateGithubReleaseInternal( + githubRequestConfig, + { + version: releaseVersion.gitTag, + prerelease: releaseVersion.isPrerelease, + body: changelogContents, + commit: latestCommit, + }, + existingGithubReleaseForVersion + ); + } +} + interface GithubReleaseOptions { version: string; body: string; @@ -63,7 +150,7 @@ interface GithubReleaseOptions { commit: string; } -export async function createOrUpdateGithubRelease( +async function createOrUpdateGithubReleaseInternal( githubRequestConfig: GithubRequestConfig, release: GithubReleaseOptions, existingGithubReleaseForVersion?: GithubRelease diff --git a/packages/nx/src/command-line/release/utils/shared.ts b/packages/nx/src/command-line/release/utils/shared.ts index 3113d826c898f..9f152d27de161 100644 --- a/packages/nx/src/command-line/release/utils/shared.ts +++ b/packages/nx/src/command-line/release/utils/shared.ts @@ -1,3 +1,4 @@ +import * as chalk from 'chalk'; import { prerelease } from 'semver'; import { ProjectGraph } from '../../../config/project-graph'; import { Tree } from '../../../generators/tree'; @@ -7,6 +8,10 @@ import { output } from '../../../utils/output'; import type { ReleaseGroupWithName } from '../config/filter-release-groups'; import { GitCommit, gitAdd, gitCommit } from './git'; +export const noDiffInChangelogMessage = chalk.yellow( + `NOTE: There was no diff detected for the changelog entry. Maybe you intended to pass alternative git references via --from and --to?` +); + export type ReleaseVersionGeneratorResult = { data: VersionData; callback: (