fix(release): move github release creation to git tasks (#21510)

This commit is contained in:
Austin Fahsl 2024-02-13 17:32:35 -07:00 committed by GitHub
parent f4dd4403f5
commit b625a79cca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 595 additions and 302 deletions

View File

@ -9,7 +9,7 @@ Description of a file change in the Nx virtual file system/
- [content](../../devkit/documents/FileChange#content): Buffer - [content](../../devkit/documents/FileChange#content): Buffer
- [options](../../devkit/documents/FileChange#options): TreeWriteOptions - [options](../../devkit/documents/FileChange#options): TreeWriteOptions
- [path](../../devkit/documents/FileChange#path): string - [path](../../devkit/documents/FileChange#path): string
- [type](../../devkit/documents/FileChange#type): "DELETE" | "CREATE" | "UPDATE" - [type](../../devkit/documents/FileChange#type): "CREATE" | "DELETE" | "UPDATE"
## Properties ## Properties
@ -39,6 +39,6 @@ Path relative to the workspace root
### type ### type
**type**: `"DELETE"` \| `"CREATE"` \| `"UPDATE"` **type**: `"CREATE"` \| `"DELETE"` \| `"UPDATE"`
Type of change: 'CREATE' | 'DELETE' | 'UPDATE' Type of change: 'CREATE' | 'DELETE' | 'UPDATE'

View File

@ -105,16 +105,12 @@ import * as yargs from 'yargs';
verbose: options.verbose, verbose: options.verbose,
}); });
// The returned number value from releaseChangelog will be non-zero if something went wrong await releaseChangelog({
const changelogStatus = await releaseChangelog({
versionData: projectsVersionData, versionData: projectsVersionData,
version: workspaceVersion, version: workspaceVersion,
dryRun: options.dryRun, dryRun: options.dryRun,
verbose: options.verbose, 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 // The returned number value from releasePublish will be zero if all projects are published successfully, non-zero if not
const publishStatus = await releasePublish({ const publishStatus = await releasePublish({

View File

@ -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<NxJsonConfiguration>('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<NxJsonConfiguration>('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);
});
});

View File

@ -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 + ## 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. + 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 + ## 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. + 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 + ## 1000.0.0-next.0
@ -756,6 +756,33 @@ ${JSON.stringify(
> NX Tagging commit with git > 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 // port and process cleanup

View File

@ -3,7 +3,10 @@ import { readFileSync, writeFileSync } from 'node:fs';
import { valid } from 'semver'; import { valid } from 'semver';
import { dirSync } from 'tmp'; import { dirSync } from 'tmp';
import type { ChangelogRenderer } from '../../../release/changelog-renderer'; import type { ChangelogRenderer } from '../../../release/changelog-renderer';
import { readNxJson } from '../../config/nx-json'; import {
NxReleaseChangelogConfiguration,
readNxJson,
} from '../../config/nx-json';
import { import {
ProjectGraph, ProjectGraph,
ProjectGraphProjectNode, ProjectGraphProjectNode,
@ -38,17 +41,10 @@ import {
gitTag, gitTag,
parseCommits, parseCommits,
} from './utils/git'; } from './utils/git';
import { import { createOrUpdateGithubRelease, getGitHubRepoSlug } from './utils/github';
GithubRelease,
GithubRequestConfig,
createOrUpdateGithubRelease,
getGitHubRepoSlug,
getGithubReleaseByTag,
resolveGithubToken,
} from './utils/github';
import { launchEditor } from './utils/launch-editor'; import { launchEditor } from './utils/launch-editor';
import { parseChangelogMarkdown } from './utils/markdown'; 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 { resolveNxJsonConfigErrorMessage } from './utils/resolve-nx-json-error-message';
import { import {
ReleaseVersion, ReleaseVersion,
@ -57,8 +53,22 @@ import {
createCommitMessageValues, createCommitMessageValues,
createGitTagValues, createGitTagValues,
handleDuplicateGitTags, handleDuplicateGitTags,
noDiffInChangelogMessage,
} from './utils/shared'; } from './utils/shared';
export interface NxReleaseChangelogResult {
workspaceChangelog?: {
releaseVersion: ReleaseVersion;
contents: string;
};
projectChangelogs?: {
[projectName: string]: {
releaseVersion: ReleaseVersion;
contents: string;
};
};
}
type PostGitTask = (latestCommit: string) => Promise<void>; type PostGitTask = (latestCommit: string) => Promise<void>;
export const releaseChangelogCLIHandler = (args: ChangelogOptions) => export const releaseChangelogCLIHandler = (args: ChangelogOptions) =>
@ -71,7 +81,7 @@ export const releaseChangelogCLIHandler = (args: ChangelogOptions) =>
*/ */
export async function releaseChangelog( export async function releaseChangelog(
args: ChangelogOptions args: ChangelogOptions
): Promise<number> { ): Promise<NxReleaseChangelogResult> {
const projectGraph = await createProjectGraphAsync({ exitOnError: true }); const projectGraph = await createProjectGraphAsync({ exitOnError: true });
const nxJson = readNxJson(); 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.`, `To explicitly enable changelog generation, configure "release.changelog.workspaceChangelog" or "release.changelog.projectChangelogs" in nx.json.`,
], ],
}); });
return 0; return {};
} }
const useAutomaticFromRef = const useAutomaticFromRef =
@ -230,16 +240,51 @@ export async function releaseChangelog(
toSHA toSHA
); );
await generateChangelogForWorkspace( const workspaceChangelog = await generateChangelogForWorkspace(
tree, tree,
args, args,
projectGraph, projectGraph,
nxReleaseConfig, nxReleaseConfig,
workspaceChangelogVersion, workspaceChangelogVersion,
workspaceChangelogCommits, workspaceChangelogCommits
postGitTasks
); );
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) { for (const releaseGroup of releaseGroups) {
const config = releaseGroup.changelog; const config = releaseGroup.changelog;
// The entire feature is disabled at the release group level, exit early // 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); commits = await getCommits(fromRef, toSHA);
} }
await generateChangelogForProjects( const projectChangelogs = await generateChangelogForProjects(
tree, tree,
args, args,
projectGraph, projectGraph,
@ -302,6 +347,43 @@ export async function releaseChangelog(
releaseGroup, releaseGroup,
[project] [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 { } else {
const fromRef = const fromRef =
@ -318,7 +400,7 @@ export async function releaseChangelog(
const commits = await getCommits(fromSHA, toSHA); const commits = await getCommits(fromSHA, toSHA);
await generateChangelogForProjects( const projectChangelogs = await generateChangelogForProjects(
tree, tree,
args, args,
projectGraph, projectGraph,
@ -328,10 +410,44 @@ export async function releaseChangelog(
releaseGroup, releaseGroup,
projectNodes 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, args,
nxReleaseConfig, nxReleaseConfig,
tree, tree,
@ -340,6 +456,11 @@ export async function releaseChangelog(
commitMessageValues, commitMessageValues,
gitTagValues gitTagValues
); );
return {
workspaceChangelog,
projectChangelogs: allProjectChangelogs,
};
} }
function resolveChangelogVersions( function resolveChangelogVersions(
@ -429,7 +550,7 @@ async function applyChangesAndExit(
`No changes were detected for any changelog files, so no changelog entries will be generated.`, `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 // Generate a new commit for the changes, if configured to do so
@ -475,7 +596,7 @@ async function applyChangesAndExit(
await postGitTask(latestCommit); await postGitTask(latestCommit);
} }
return 0; return;
} }
function resolveChangelogRenderer( function resolveChangelogRenderer(
@ -504,9 +625,8 @@ async function generateChangelogForWorkspace(
projectGraph: ProjectGraph, projectGraph: ProjectGraph,
nxReleaseConfig: NxReleaseConfig, nxReleaseConfig: NxReleaseConfig,
workspaceChangelogVersion: (string | null) | undefined, workspaceChangelogVersion: (string | null) | undefined,
commits: GitCommit[], commits: GitCommit[]
postGitTasks: PostGitTask[] ): Promise<NxReleaseChangelogResult['workspaceChangelog']> {
) {
const config = nxReleaseConfig.changelog.workspaceChangelog; const config = nxReleaseConfig.changelog.workspaceChangelog;
// The entire feature is disabled at the workspace level, exit early // The entire feature is disabled at the workspace level, exit early
if (config === false) { if (config === false) {
@ -572,27 +692,15 @@ async function generateChangelogForWorkspace(
releaseTagPattern: nxReleaseConfig.releaseTagPattern, releaseTagPattern: nxReleaseConfig.releaseTagPattern,
}); });
// We are either creating/previewing a changelog file, a GitHub release, or both if (interpolatedTreePath) {
let logTitle = dryRun ? 'Previewing a' : 'Generating a'; const prefix = dryRun ? 'Previewing' : 'Generating';
switch (true) { output.log({
case interpolatedTreePath && config.createRelease === 'github': title: `${prefix} an entry in ${interpolatedTreePath} for ${chalk.white(
logTitle += ` GitHub release and an entry in ${interpolatedTreePath} for ${chalk.white(
releaseVersion.gitTag releaseVersion.gitTag
)}`; )}`,
break; });
case !!interpolatedTreePath:
logTitle += `n 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); const githubRepoSlug = getGitHubRepoSlug(gitRemote);
let contents = await changelogRenderer({ let contents = await changelogRenderer({
@ -621,15 +729,6 @@ async function generateChangelogForWorkspace(
contents = readFileSync(changelogPath, 'utf-8'); 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) { if (interpolatedTreePath) {
let rootChangelogContents = tree.exists(interpolatedTreePath) let rootChangelogContents = tree.exists(interpolatedTreePath)
? tree.read(interpolatedTreePath).toString() ? tree.read(interpolatedTreePath).toString()
@ -659,104 +758,13 @@ async function generateChangelogForWorkspace(
tree.write(interpolatedTreePath, rootChangelogContents); tree.write(interpolatedTreePath, rootChangelogContents);
printSummary = () => printAndFlushChanges(tree, !!dryRun, 3, false, noDiffInChangelogMessage);
printAndFlushChanges(tree, !!dryRun, 3, false, noDiffInChangelogMessage);
} }
if (config.createRelease === 'github') { return {
if (!githubRepoSlug) { releaseVersion,
output.error({ contents,
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();
} }
async function generateChangelogForProjects( async function generateChangelogForProjects(
@ -768,7 +776,7 @@ async function generateChangelogForProjects(
postGitTasks: PostGitTask[], postGitTasks: PostGitTask[],
releaseGroup: ReleaseGroupWithName, releaseGroup: ReleaseGroupWithName,
projects: ProjectGraphProjectNode[] projects: ProjectGraphProjectNode[]
) { ): Promise<NxReleaseChangelogResult['projectChangelogs']> {
const config = releaseGroup.changelog; const config = releaseGroup.changelog;
// The entire feature is disabled at the release group level, exit early // The entire feature is disabled at the release group level, exit early
if (config === false) { if (config === false) {
@ -783,6 +791,8 @@ async function generateChangelogForProjects(
const changelogRenderer = resolveChangelogRenderer(config.renderer); const changelogRenderer = resolveChangelogRenderer(config.renderer);
const projectChangelogs: NxReleaseChangelogResult['projectChangelogs'] = {};
for (const project of projects) { for (const project of projects) {
let interpolatedTreePath = config.file || ''; let interpolatedTreePath = config.file || '';
if (interpolatedTreePath) { if (interpolatedTreePath) {
@ -807,27 +817,15 @@ async function generateChangelogForProjects(
projectName: project.name, projectName: project.name,
}); });
// We are either creating/previewing a changelog file, a GitHub release, or both if (interpolatedTreePath) {
let logTitle = dryRun ? 'Previewing a' : 'Generating a'; const prefix = dryRun ? 'Previewing' : 'Generating';
switch (true) { output.log({
case interpolatedTreePath && config.createRelease === 'github': title: `${prefix} an entry in ${interpolatedTreePath} for ${chalk.white(
logTitle += ` GitHub release and an entry in ${interpolatedTreePath} for ${chalk.white(
releaseVersion.gitTag releaseVersion.gitTag
)}`; )}`,
break; });
case !!interpolatedTreePath:
logTitle += `n 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 = const githubRepoSlug =
config.createRelease === 'github' config.createRelease === 'github'
? getGitHubRepoSlug(gitRemote) ? getGitHubRepoSlug(gitRemote)
@ -866,15 +864,6 @@ async function generateChangelogForProjects(
contents = readFileSync(changelogPath, 'utf-8'); 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) { if (interpolatedTreePath) {
let changelogContents = tree.exists(interpolatedTreePath) let changelogContents = tree.exists(interpolatedTreePath)
? tree.read(interpolatedTreePath).toString() ? tree.read(interpolatedTreePath).toString()
@ -903,113 +892,24 @@ async function generateChangelogForProjects(
tree.write(interpolatedTreePath, changelogContents); tree.write(interpolatedTreePath, changelogContents);
printSummary = () => printAndFlushChanges(
printAndFlushChanges( tree,
tree, !!dryRun,
!!dryRun, 3,
3, false,
false, noDiffInChangelogMessage,
noDiffInChangelogMessage, // Only print the change for the current changelog file at this point
// Only print the change for the current changelog file at this point (f) => f.path === interpolatedTreePath
(f) => f.path === interpolatedTreePath );
);
} }
if (config.createRelease === 'github') { projectChangelogs[project.name] = {
if (!githubRepoSlug) { releaseVersion,
output.error({ contents,
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
);
});
}
}
printSummary();
} }
return projectChangelogs;
} }
function checkChangelogFilesEnabled(nxReleaseConfig: NxReleaseConfig): boolean { function checkChangelogFilesEnabled(nxReleaseConfig: NxReleaseConfig): boolean {
@ -1040,3 +940,14 @@ async function getCommits(fromSHA: string, toSHA: string) {
return false; 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';
}

View File

@ -45,6 +45,7 @@ export type ChangelogOptions = NxReleaseArgs &
from?: string; from?: string;
interactive?: string; interactive?: string;
gitRemote?: string; gitRemote?: string;
createRelease?: false | 'github';
}; };
export type PublishOptions = NxReleaseArgs & export type PublishOptions = NxReleaseArgs &

View File

@ -3,7 +3,7 @@ import { readNxJson } from '../../config/nx-json';
import { output } from '../../devkit-exports'; import { output } from '../../devkit-exports';
import { createProjectGraphAsync } from '../../project-graph/project-graph'; import { createProjectGraphAsync } from '../../project-graph/project-graph';
import { handleErrors } from '../../utils/params'; import { handleErrors } from '../../utils/params';
import { releaseChangelog } from './changelog'; import { releaseChangelog, shouldCreateGitHubRelease } from './changelog';
import { ReleaseOptions, VersionOptions } from './command-object'; import { ReleaseOptions, VersionOptions } from './command-object';
import { import {
createNxReleaseConfig, createNxReleaseConfig,
@ -11,7 +11,8 @@ import {
} from './config/config'; } from './config/config';
import { filterReleaseGroups } from './config/filter-release-groups'; import { filterReleaseGroups } from './config/filter-release-groups';
import { releasePublish } from './publish'; 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 { resolveNxJsonConfigErrorMessage } from './utils/resolve-nx-json-error-message';
import { import {
createCommitMessageValues, createCommitMessageValues,
@ -74,13 +75,14 @@ export async function release(
gitTag: false, gitTag: false,
}); });
await releaseChangelog({ const changelogResult = await releaseChangelog({
...args, ...args,
versionData: versionResult.projectsVersionData, versionData: versionResult.projectsVersionData,
version: versionResult.workspaceVersion, version: versionResult.workspaceVersion,
stageChanges: shouldStage, stageChanges: shouldStage,
gitCommit: false, gitCommit: false,
gitTag: false, gitTag: false,
createRelease: false,
}); });
const { 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; let hasNewVersion = false;
// null means that all projects are versioned together but there were no changes // null means that all projects are versioned together but there were no changes
if (versionResult.workspaceVersion !== null) { if (versionResult.workspaceVersion !== null) {

View File

@ -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 { try {
await execCommand('git', [ await execCommand('git', 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] : []),
]);
} catch (err) { } catch (err) {
throw new Error(`Unexpected git push error: ${err}`); throw new Error(`Unexpected git push error: ${err}`);
} }

View File

@ -11,6 +11,8 @@ import { homedir } from 'node:os';
import { output } from '../../../utils/output'; import { output } from '../../../utils/output';
import { joinPathFragments } from '../../../utils/path'; import { joinPathFragments } from '../../../utils/path';
import { Reference } from './git'; import { Reference } from './git';
import { printDiff } from './print-changes';
import { ReleaseVersion, noDiffInChangelogMessage } from './shared';
// axios types and values don't seem to match // axios types and values don't seem to match
import _axios = require('axios'); 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<void> {
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 { interface GithubReleaseOptions {
version: string; version: string;
body: string; body: string;
@ -63,7 +150,7 @@ interface GithubReleaseOptions {
commit: string; commit: string;
} }
export async function createOrUpdateGithubRelease( async function createOrUpdateGithubReleaseInternal(
githubRequestConfig: GithubRequestConfig, githubRequestConfig: GithubRequestConfig,
release: GithubReleaseOptions, release: GithubReleaseOptions,
existingGithubReleaseForVersion?: GithubRelease existingGithubReleaseForVersion?: GithubRelease

View File

@ -1,3 +1,4 @@
import * as chalk from 'chalk';
import { prerelease } from 'semver'; import { prerelease } from 'semver';
import { ProjectGraph } from '../../../config/project-graph'; import { ProjectGraph } from '../../../config/project-graph';
import { Tree } from '../../../generators/tree'; import { Tree } from '../../../generators/tree';
@ -7,6 +8,10 @@ import { output } from '../../../utils/output';
import type { ReleaseGroupWithName } from '../config/filter-release-groups'; import type { ReleaseGroupWithName } from '../config/filter-release-groups';
import { GitCommit, gitAdd, gitCommit } from './git'; 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 = { export type ReleaseVersionGeneratorResult = {
data: VersionData; data: VersionData;
callback: ( callback: (