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
- [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'

View File

@ -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({

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
@ -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

View File

@ -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<void>;
export const releaseChangelogCLIHandler = (args: ChangelogOptions) =>
@ -71,7 +81,7 @@ export const releaseChangelogCLIHandler = (args: ChangelogOptions) =>
*/
export async function releaseChangelog(
args: ChangelogOptions
): Promise<number> {
): Promise<NxReleaseChangelogResult> {
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<NxReleaseChangelogResult['workspaceChangelog']> {
const config = nxReleaseConfig.changelog.workspaceChangelog;
// The entire feature is disabled at the workspace level, exit early
if (config === false) {
@ -572,26 +692,14 @@ 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(
releaseVersion.gitTag
)}`;
break;
case config.createRelease === 'github':
logTitle += ` GitHub release for ${chalk.white(releaseVersion.gitTag)}`;
}
if (interpolatedTreePath) {
const prefix = dryRun ? 'Previewing' : 'Generating';
output.log({
title: logTitle,
title: `${prefix} an entry in ${interpolatedTreePath} for ${chalk.white(
releaseVersion.gitTag
)}`,
});
}
const githubRepoSlug = getGitHubRepoSlug(gitRemote);
@ -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);
}
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
: '',
return {
releaseVersion,
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(
@ -768,7 +776,7 @@ async function generateChangelogForProjects(
postGitTasks: PostGitTask[],
releaseGroup: ReleaseGroupWithName,
projects: ProjectGraphProjectNode[]
) {
): Promise<NxReleaseChangelogResult['projectChangelogs']> {
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,26 +817,14 @@ 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(
releaseVersion.gitTag
)}`;
break;
case config.createRelease === 'github':
logTitle += ` GitHub release for ${chalk.white(releaseVersion.gitTag)}`;
}
if (interpolatedTreePath) {
const prefix = dryRun ? 'Previewing' : 'Generating';
output.log({
title: logTitle,
title: `${prefix} an entry in ${interpolatedTreePath} for ${chalk.white(
releaseVersion.gitTag
)}`,
});
}
const githubRepoSlug =
config.createRelease === 'github'
@ -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,7 +892,6 @@ async function generateChangelogForProjects(
tree.write(interpolatedTreePath, changelogContents);
printSummary = () =>
printAndFlushChanges(
tree,
!!dryRun,
@ -915,101 +903,13 @@ async function generateChangelogForProjects(
);
}
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
: '',
projectChangelogs[project.name] = {
releaseVersion,
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 {
@ -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';
}

View File

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

View File

@ -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) {

View File

@ -249,9 +249,16 @@ export async function gitTag({
}
}
export async function gitPush(gitRemote?: string) {
try {
await execCommand('git', [
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',
@ -259,7 +266,23 @@ export async function gitPush(gitRemote?: string) {
'--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', commandArgs);
} catch (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 { 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<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 {
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

View File

@ -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: (