feat(release): support github enterprise server (#26482)

This commit is contained in:
James Henry 2024-09-20 16:15:15 +04:00 committed by GitHub
parent 0d37ef98da
commit 21d1696ef5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 1750 additions and 79 deletions

View File

@ -4,6 +4,7 @@ import { NxReleaseConfig } from '../../src/command-line/release/config/config';
import { DEFAULT_CONVENTIONAL_COMMITS_CONFIG } from '../../src/command-line/release/config/conventional-commits'; import { DEFAULT_CONVENTIONAL_COMMITS_CONFIG } from '../../src/command-line/release/config/conventional-commits';
import { GitCommit } from '../../src/command-line/release/utils/git'; import { GitCommit } from '../../src/command-line/release/utils/git';
import { import {
GithubRepoData,
RepoSlug, RepoSlug,
formatReferences, formatReferences,
} from '../../src/command-line/release/utils/github'; } from '../../src/command-line/release/utils/github';
@ -42,6 +43,7 @@ export type DependencyBump = {
* @param {string | false} config.entryWhenNoChanges The (already interpolated) string to use as the changelog entry when there are no changes, or `false` if no entry should be generated * @param {string | false} config.entryWhenNoChanges The (already interpolated) string to use as the changelog entry when there are no changes, or `false` if no entry should be generated
* @param {ChangelogRenderOptions} config.changelogRenderOptions The options specific to the ChangelogRenderer implementation * @param {ChangelogRenderOptions} config.changelogRenderOptions The options specific to the ChangelogRenderer implementation
* @param {DependencyBump[]} config.dependencyBumps Optional list of additional dependency bumps that occurred as part of the release, outside of the commit data * @param {DependencyBump[]} config.dependencyBumps Optional list of additional dependency bumps that occurred as part of the release, outside of the commit data
* @param {GithubRepoData} config.repoData Resolved data for the current GitHub repository
*/ */
export type ChangelogRenderer = (config: { export type ChangelogRenderer = (config: {
projectGraph: ProjectGraph; projectGraph: ProjectGraph;
@ -53,7 +55,9 @@ export type ChangelogRenderer = (config: {
entryWhenNoChanges: string | false; entryWhenNoChanges: string | false;
changelogRenderOptions: DefaultChangelogRenderOptions; changelogRenderOptions: DefaultChangelogRenderOptions;
dependencyBumps?: DependencyBump[]; dependencyBumps?: DependencyBump[];
// TODO(v20): remove repoSlug in favour of repoData
repoSlug?: RepoSlug; repoSlug?: RepoSlug;
repoData?: GithubRepoData;
// TODO(v20): Evaluate if there is a cleaner way to configure this when breaking changes are allowed // TODO(v20): Evaluate if there is a cleaner way to configure this when breaking changes are allowed
// null if version plans are being used to generate the changelog // null if version plans are being used to generate the changelog
conventionalCommitsConfig: NxReleaseConfig['conventionalCommits'] | null; conventionalCommitsConfig: NxReleaseConfig['conventionalCommits'] | null;
@ -101,6 +105,7 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({
dependencyBumps, dependencyBumps,
repoSlug, repoSlug,
conventionalCommitsConfig, conventionalCommitsConfig,
repoData,
}): Promise<string> => { }): Promise<string> => {
const markdownLines: string[] = []; const markdownLines: string[] = [];
@ -148,7 +153,7 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({
change, change,
changelogRenderOptions, changelogRenderOptions,
isVersionPlans, isVersionPlans,
repoSlug repoData
); );
breakingChanges.push(line); breakingChanges.push(line);
relevantChanges.splice(i, 1); relevantChanges.splice(i, 1);
@ -222,7 +227,7 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({
change, change,
changelogRenderOptions, changelogRenderOptions,
isVersionPlans, isVersionPlans,
repoSlug repoData
); );
markdownLines.push(line); markdownLines.push(line);
if (change.isBreaking) { if (change.isBreaking) {
@ -295,7 +300,7 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({
change, change,
changelogRenderOptions, changelogRenderOptions,
isVersionPlans, isVersionPlans,
repoSlug repoData
); );
markdownLines.push(line + '\n'); markdownLines.push(line + '\n');
if (change.isBreaking) { if (change.isBreaking) {
@ -350,7 +355,7 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({
} }
// Try to map authors to github usernames // Try to map authors to github usernames
if (repoSlug && changelogRenderOptions.mapAuthorsToGitHubUsernames) { if (repoData && changelogRenderOptions.mapAuthorsToGitHubUsernames) {
await Promise.all( await Promise.all(
[..._authors.keys()].map(async (authorName) => { [..._authors.keys()].map(async (authorName) => {
const meta = _authors.get(authorName); const meta = _authors.get(authorName);
@ -455,7 +460,7 @@ function formatChange(
change: ChangelogChange, change: ChangelogChange,
changelogRenderOptions: DefaultChangelogRenderOptions, changelogRenderOptions: DefaultChangelogRenderOptions,
isVersionPlans: boolean, isVersionPlans: boolean,
repoSlug?: RepoSlug repoData?: GithubRepoData
): string { ): string {
let description = change.description; let description = change.description;
let extraLines = []; let extraLines = [];
@ -480,8 +485,8 @@ function formatChange(
(!isVersionPlans && change.isBreaking ? '⚠️ ' : '') + (!isVersionPlans && change.isBreaking ? '⚠️ ' : '') +
(!isVersionPlans && change.scope ? `**${change.scope.trim()}:** ` : '') + (!isVersionPlans && change.scope ? `**${change.scope.trim()}:** ` : '') +
description; description;
if (repoSlug && changelogRenderOptions.commitReferences) { if (repoData && changelogRenderOptions.commitReferences) {
changeLine += formatReferences(change.githubReferences, repoSlug); changeLine += formatReferences(change.githubReferences, repoData);
} }
if (extraLinesStr) { if (extraLinesStr) {
changeLine += '\n\n' + extraLinesStr; changeLine += '\n\n' + extraLinesStr;

View File

@ -691,6 +691,9 @@
{ {
"type": "boolean", "type": "boolean",
"enum": [false] "enum": [false]
},
{
"$ref": "#/definitions/CreateReleaseProviderConfiguration"
} }
] ]
}, },
@ -724,6 +727,24 @@
} }
} }
}, },
"CreateReleaseProviderConfiguration": {
"type": "object",
"properties": {
"provider": {
"type": "string",
"enum": ["github-enterprise-server"]
},
"hostname": {
"type": "string",
"description": "The hostname of the VCS provider instance, e.g. github.example.com"
},
"apiBaseUrl": {
"type": "string",
"description": "The base URL for the relevant VCS provider API. If not set, this will default to `https://${hostname}/api/v3`"
}
},
"required": ["provider", "hostname"]
},
"NxReleaseVersionPlansConfiguration": { "NxReleaseVersionPlansConfiguration": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -32,6 +32,7 @@ import { ChangelogOptions } from './command-object';
import { import {
NxReleaseConfig, NxReleaseConfig,
createNxReleaseConfig, createNxReleaseConfig,
defaultCreateReleaseProvider,
handleNxReleaseConfigError, handleNxReleaseConfigError,
} from './config/config'; } from './config/config';
import { deepMergeJson } from './config/deep-merge-json'; import { deepMergeJson } from './config/deep-merge-json';
@ -58,7 +59,7 @@ import {
parseCommits, parseCommits,
parseGitCommit, parseGitCommit,
} from './utils/git'; } from './utils/git';
import { createOrUpdateGithubRelease, getGitHubRepoSlug } from './utils/github'; import { createOrUpdateGithubRelease, getGitHubRepoData } 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 } from './utils/print-changes'; import { printAndFlushChanges } from './utils/print-changes';
@ -411,6 +412,9 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) {
output.logSingleLine(`Creating GitHub Release`); output.logSingleLine(`Creating GitHub Release`);
await createOrUpdateGithubRelease( await createOrUpdateGithubRelease(
nxReleaseConfig.changelog.workspaceChangelog
? nxReleaseConfig.changelog.workspaceChangelog.createRelease
: defaultCreateReleaseProvider,
workspaceChangelog.releaseVersion, workspaceChangelog.releaseVersion,
workspaceChangelog.contents, workspaceChangelog.contents,
latestCommit, latestCommit,
@ -644,6 +648,9 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) {
output.logSingleLine(`Creating GitHub Release`); output.logSingleLine(`Creating GitHub Release`);
await createOrUpdateGithubRelease( await createOrUpdateGithubRelease(
releaseGroup.changelog
? releaseGroup.changelog.createRelease
: defaultCreateReleaseProvider,
projectChangelog.releaseVersion, projectChangelog.releaseVersion,
projectChangelog.contents, projectChangelog.contents,
latestCommit, latestCommit,
@ -797,6 +804,9 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) {
output.logSingleLine(`Creating GitHub Release`); output.logSingleLine(`Creating GitHub Release`);
await createOrUpdateGithubRelease( await createOrUpdateGithubRelease(
releaseGroup.changelog
? releaseGroup.changelog.createRelease
: defaultCreateReleaseProvider,
projectChangelog.releaseVersion, projectChangelog.releaseVersion,
projectChangelog.contents, projectChangelog.contents,
latestCommit, latestCommit,
@ -1110,7 +1120,7 @@ async function generateChangelogForWorkspace({
}); });
} }
const githubRepoSlug = getGitHubRepoSlug(gitRemote); const githubRepoData = getGitHubRepoData(gitRemote, config.createRelease);
let contents = await changelogRenderer({ let contents = await changelogRenderer({
projectGraph, projectGraph,
@ -1118,7 +1128,8 @@ async function generateChangelogForWorkspace({
commits, commits,
releaseVersion: releaseVersion.rawVersion, releaseVersion: releaseVersion.rawVersion,
project: null, project: null,
repoSlug: githubRepoSlug, repoSlug: githubRepoData?.slug,
repoData: githubRepoData,
entryWhenNoChanges: config.entryWhenNoChanges, entryWhenNoChanges: config.entryWhenNoChanges,
changelogRenderOptions: config.renderOptions, changelogRenderOptions: config.renderOptions,
conventionalCommitsConfig: nxReleaseConfig.conventionalCommits, conventionalCommitsConfig: nxReleaseConfig.conventionalCommits,
@ -1250,10 +1261,7 @@ async function generateChangelogForProjects({
}); });
} }
const githubRepoSlug = const githubRepoData = getGitHubRepoData(gitRemote, config.createRelease);
config.createRelease === 'github'
? getGitHubRepoSlug(gitRemote)
: undefined;
let contents = await changelogRenderer({ let contents = await changelogRenderer({
projectGraph, projectGraph,
@ -1261,7 +1269,8 @@ async function generateChangelogForProjects({
commits, commits,
releaseVersion: releaseVersion.rawVersion, releaseVersion: releaseVersion.rawVersion,
project: project.name, project: project.name,
repoSlug: githubRepoSlug, repoSlug: githubRepoData?.slug,
repoData: githubRepoData,
entryWhenNoChanges: entryWhenNoChanges:
typeof config.entryWhenNoChanges === 'string' typeof config.entryWhenNoChanges === 'string'
? interpolate(config.entryWhenNoChanges, { ? interpolate(config.entryWhenNoChanges, {
@ -1409,7 +1418,7 @@ export function shouldCreateGitHubRelease(
return createReleaseArg === 'github'; return createReleaseArg === 'github';
} }
return (changelogConfig || {}).createRelease === 'github'; return (changelogConfig || {}).createRelease !== false;
} }
async function promptForGitHubRelease(): Promise<boolean> { async function promptForGitHubRelease(): Promise<boolean> {

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,11 @@
* and easy to consume config object for all the `nx release` command implementations. * and easy to consume config object for all the `nx release` command implementations.
*/ */
import { join, relative } from 'node:path'; import { join, relative } from 'node:path';
import { NxJsonConfiguration } from '../../../config/nx-json'; import { URL } from 'node:url';
import {
NxJsonConfiguration,
NxReleaseChangelogConfiguration,
} from '../../../config/nx-json';
import { ProjectFileMap, ProjectGraph } from '../../../config/project-graph'; import { ProjectFileMap, ProjectGraph } from '../../../config/project-graph';
import { readJsonFile } from '../../../utils/fileutils'; import { readJsonFile } from '../../../utils/fileutils';
import { findMatchingProjects } from '../../../utils/find-matching-projects'; import { findMatchingProjects } from '../../../utils/find-matching-projects';
@ -41,15 +45,6 @@ type RemoveTrueFromProperties<T, K extends keyof T> = {
type RemoveTrueFromPropertiesOnEach<T, K extends keyof T[keyof T]> = { type RemoveTrueFromPropertiesOnEach<T, K extends keyof T[keyof T]> = {
[U in keyof T]: RemoveTrueFromProperties<T[U], K>; [U in keyof T]: RemoveTrueFromProperties<T[U], K>;
}; };
type RemoveFalseFromType<T> = T extends false ? never : T;
type RemoveFalseFromProperties<T, K extends keyof T> = {
[P in keyof T]: P extends K ? RemoveFalseFromType<T[P]> : T[P];
};
type RemoveFalseFromPropertiesOnEach<T, K extends keyof T[keyof T]> = {
[U in keyof T]: RemoveFalseFromProperties<T[U], K>;
};
type RemoveBooleanFromType<T> = T extends boolean ? never : T; type RemoveBooleanFromType<T> = T extends boolean ? never : T;
type RemoveBooleanFromProperties<T, K extends keyof T> = { type RemoveBooleanFromProperties<T, K extends keyof T> = {
[P in keyof T]: P extends K ? RemoveBooleanFromType<T[P]> : T[P]; [P in keyof T]: P extends K ? RemoveBooleanFromType<T[P]> : T[P];
@ -111,7 +106,11 @@ export interface CreateNxReleaseConfigError {
| 'RELEASE_GROUP_RELEASE_TAG_PATTERN_VERSION_PLACEHOLDER_MISSING_OR_EXCESSIVE' | 'RELEASE_GROUP_RELEASE_TAG_PATTERN_VERSION_PLACEHOLDER_MISSING_OR_EXCESSIVE'
| 'PROJECT_MATCHES_MULTIPLE_GROUPS' | 'PROJECT_MATCHES_MULTIPLE_GROUPS'
| 'CONVENTIONAL_COMMITS_SHORTHAND_MIXED_WITH_OVERLAPPING_GENERATOR_OPTIONS' | 'CONVENTIONAL_COMMITS_SHORTHAND_MIXED_WITH_OVERLAPPING_GENERATOR_OPTIONS'
| 'GLOBAL_GIT_CONFIG_MIXED_WITH_GRANULAR_GIT_CONFIG'; | 'GLOBAL_GIT_CONFIG_MIXED_WITH_GRANULAR_GIT_CONFIG'
| 'CANNOT_RESOLVE_CHANGELOG_RENDERER'
| 'INVALID_CHANGELOG_CREATE_RELEASE_PROVIDER'
| 'INVALID_CHANGELOG_CREATE_RELEASE_HOSTNAME'
| 'INVALID_CHANGELOG_CREATE_RELEASE_API_BASE_URL';
data: Record<string, string | string[]>; data: Record<string, string | string[]>;
} }
@ -566,7 +565,16 @@ export async function createNxReleaseConfig(
releaseGroups[releaseGroupName] = finalReleaseGroup; releaseGroups[releaseGroupName] = finalReleaseGroup;
} }
ensureChangelogRenderersAreResolvable(releaseGroups, rootChangelogConfig); const configError = validateChangelogConfig(
releaseGroups,
rootChangelogConfig
);
if (configError) {
return {
error: configError,
nxReleaseConfig: null,
};
}
return { return {
error: null, error: null,
@ -766,6 +774,52 @@ export async function handleNxReleaseConfigError(
}); });
} }
break; break;
case 'CANNOT_RESOLVE_CHANGELOG_RENDERER': {
const nxJsonMessage = await resolveNxJsonConfigErrorMessage(['release']);
output.error({
title: `There was an error when resolving the configured changelog renderer at path: ${error.data.workspaceRelativePath}`,
bodyLines: [nxJsonMessage],
});
}
case 'INVALID_CHANGELOG_CREATE_RELEASE_PROVIDER':
{
const nxJsonMessage = await resolveNxJsonConfigErrorMessage([
'release',
]);
output.error({
title: `Your "changelog.createRelease" config specifies an unsupported provider "${
error.data.provider
}". The supported providers are ${(
error.data.supportedProviders as string[]
)
.map((p) => `"${p}"`)
.join(', ')}`,
bodyLines: [nxJsonMessage],
});
}
break;
case 'INVALID_CHANGELOG_CREATE_RELEASE_HOSTNAME':
{
const nxJsonMessage = await resolveNxJsonConfigErrorMessage([
'release',
]);
output.error({
title: `Your "changelog.createRelease" config specifies an invalid hostname "${error.data.hostname}". Please ensure you provide a valid hostname value, such as "example.com"`,
bodyLines: [nxJsonMessage],
});
}
break;
case 'INVALID_CHANGELOG_CREATE_RELEASE_API_BASE_URL':
{
const nxJsonMessage = await resolveNxJsonConfigErrorMessage([
'release',
]);
output.error({
title: `Your "changelog.createRelease" config specifies an invalid apiBaseUrl "${error.data.apiBaseUrl}". Please ensure you provide a valid URL value, such as "https://example.com"`,
bodyLines: [nxJsonMessage],
});
}
break;
default: default:
throw new Error(`Unhandled error code: ${error.code}`); throw new Error(`Unhandled error code: ${error.code}`);
} }
@ -950,10 +1004,16 @@ function isProjectPublic(
} }
} }
function ensureChangelogRenderersAreResolvable( /**
* We need to ensure that changelog renderers are resolvable up front so that we do not end up erroring after performing
* actions later, and we also make sure that any configured createRelease options are valid.
*
* For the createRelease config, we also set a default apiBaseUrl if applicable.
*/
function validateChangelogConfig(
releaseGroups: NxReleaseConfig['groups'], releaseGroups: NxReleaseConfig['groups'],
rootChangelogConfig: NxReleaseConfig['changelog'] rootChangelogConfig: NxReleaseConfig['changelog']
) { ): CreateNxReleaseConfigError | null {
/** /**
* If any form of changelog config is enabled, ensure that any provided changelog renderers are resolvable * If any form of changelog config is enabled, ensure that any provided changelog renderers are resolvable
* up front so that we do not end up erroring only after the versioning step has been completed. * up front so that we do not end up erroring only after the versioning step has been completed.
@ -962,42 +1022,148 @@ function ensureChangelogRenderersAreResolvable(
if ( if (
rootChangelogConfig.workspaceChangelog && rootChangelogConfig.workspaceChangelog &&
typeof rootChangelogConfig.workspaceChangelog !== 'boolean' && typeof rootChangelogConfig.workspaceChangelog !== 'boolean'
rootChangelogConfig.workspaceChangelog.renderer?.length
) { ) {
uniqueRendererPaths.add(rootChangelogConfig.workspaceChangelog.renderer); if (rootChangelogConfig.workspaceChangelog.renderer?.length) {
uniqueRendererPaths.add(rootChangelogConfig.workspaceChangelog.renderer);
}
const createReleaseError = validateCreateReleaseConfig(
rootChangelogConfig.workspaceChangelog
);
if (createReleaseError) {
return createReleaseError;
}
} }
if ( if (
rootChangelogConfig.projectChangelogs && rootChangelogConfig.projectChangelogs &&
typeof rootChangelogConfig.projectChangelogs !== 'boolean' && typeof rootChangelogConfig.projectChangelogs !== 'boolean'
rootChangelogConfig.projectChangelogs.renderer?.length
) { ) {
uniqueRendererPaths.add(rootChangelogConfig.projectChangelogs.renderer); if (rootChangelogConfig.projectChangelogs.renderer?.length) {
uniqueRendererPaths.add(rootChangelogConfig.projectChangelogs.renderer);
}
const createReleaseError = validateCreateReleaseConfig(
rootChangelogConfig.projectChangelogs
);
if (createReleaseError) {
return createReleaseError;
}
} }
for (const group of Object.values(releaseGroups)) { for (const group of Object.values(releaseGroups)) {
if ( if (group.changelog && typeof group.changelog !== 'boolean') {
group.changelog && if (group.changelog.renderer?.length) {
typeof group.changelog !== 'boolean' && uniqueRendererPaths.add(group.changelog.renderer);
group.changelog.renderer?.length }
) { const createReleaseError = validateCreateReleaseConfig(group.changelog);
uniqueRendererPaths.add(group.changelog.renderer); if (createReleaseError) {
return createReleaseError;
}
} }
} }
if (!uniqueRendererPaths.size) { if (!uniqueRendererPaths.size) {
return; return null;
} }
for (const rendererPath of uniqueRendererPaths) { for (const rendererPath of uniqueRendererPaths) {
try { try {
resolveChangelogRenderer(rendererPath); resolveChangelogRenderer(rendererPath);
} catch (e) { } catch {
const workspaceRelativePath = relative(workspaceRoot, rendererPath); return {
output.error({ code: 'CANNOT_RESOLVE_CHANGELOG_RENDERER',
title: `There was an error when resolving the configured changelog renderer at path: ${workspaceRelativePath}`, data: {
}); workspaceRelativePath: relative(workspaceRoot, rendererPath),
throw e; },
};
} }
} }
return null;
}
const supportedCreateReleaseProviders = [
{
name: 'github-enterprise-server',
defaultApiBaseUrl: 'https://__hostname__/api/v3',
},
];
// User opts into the default by specifying the string value 'github'
export const defaultCreateReleaseProvider = {
provider: 'github',
hostname: 'github.com',
apiBaseUrl: 'https://api.github.com',
} as any;
function validateCreateReleaseConfig(
changelogConfig: NxReleaseChangelogConfiguration
): CreateNxReleaseConfigError | null {
const createRelease = changelogConfig.createRelease;
// Disabled: valid
if (!createRelease) {
return null;
}
// GitHub shorthand, expand to full object form, mark as valid
if (createRelease === 'github') {
changelogConfig.createRelease = defaultCreateReleaseProvider;
return null;
}
// Object config, ensure that properties are valid
const supportedProvider = supportedCreateReleaseProviders.find(
(p) => p.name === createRelease.provider
);
if (!supportedProvider) {
return {
code: 'INVALID_CHANGELOG_CREATE_RELEASE_PROVIDER',
data: {
provider: createRelease.provider,
supportedProviders: supportedCreateReleaseProviders.map((p) => p.name),
},
};
}
if (!isValidHostname(createRelease.hostname)) {
return {
code: 'INVALID_CHANGELOG_CREATE_RELEASE_HOSTNAME',
data: {
hostname: createRelease.hostname,
},
};
}
// user provided a custom apiBaseUrl, ensure it is valid (accounting for empty string case)
if (
createRelease.apiBaseUrl ||
typeof createRelease.apiBaseUrl === 'string'
) {
if (!isValidUrl(createRelease.apiBaseUrl)) {
return {
code: 'INVALID_CHANGELOG_CREATE_RELEASE_API_BASE_URL',
data: {
apiBaseUrl: createRelease.apiBaseUrl,
},
};
}
} else {
// Set default apiBaseUrl when not provided by the user
createRelease.apiBaseUrl = supportedProvider.defaultApiBaseUrl.replace(
'__hostname__',
createRelease.hostname
);
}
return null;
}
function isValidHostname(hostname) {
// Regular expression to match a valid hostname
const hostnameRegex =
/^(?!:\/\/)(?=.{1,255}$)(?!.*\.$)(?!.*?\.\.)(?!.*?-$)(?!^-)([a-zA-Z0-9-]{1,63}\.?)+[a-zA-Z]{2,}$/;
return hostnameRegex.test(hostname);
}
function isValidUrl(str: string): boolean {
try {
new URL(str);
return true;
} catch {
return false;
}
} }

View File

@ -252,6 +252,9 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) {
latestCommit = await getCommitHash('HEAD'); latestCommit = await getCommitHash('HEAD');
await createOrUpdateGithubRelease( await createOrUpdateGithubRelease(
nxReleaseConfig.changelog.workspaceChangelog
? nxReleaseConfig.changelog.workspaceChangelog.createRelease
: false,
changelogResult.workspaceChangelog.releaseVersion, changelogResult.workspaceChangelog.releaseVersion,
changelogResult.workspaceChangelog.contents, changelogResult.workspaceChangelog.contents,
latestCommit, latestCommit,
@ -297,6 +300,9 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) {
} }
await createOrUpdateGithubRelease( await createOrUpdateGithubRelease(
releaseGroup.changelog
? releaseGroup.changelog.createRelease
: false,
changelog.releaseVersion, changelog.releaseVersion,
changelog.contents, changelog.contents,
latestCommit, latestCommit,

View File

@ -8,8 +8,10 @@ import { prompt } from 'enquirer';
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { existsSync, promises as fsp } from 'node:fs'; import { existsSync, promises as fsp } from 'node:fs';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { NxReleaseChangelogConfiguration } from '../../../config/nx-json';
import { output } from '../../../utils/output'; import { output } from '../../../utils/output';
import { joinPathFragments } from '../../../utils/path'; import { joinPathFragments } from '../../../utils/path';
import { defaultCreateReleaseProvider } from '../config/config';
import { Reference } from './git'; import { Reference } from './git';
import { printDiff } from './print-changes'; import { printDiff } from './print-changes';
import { ReleaseVersion, noDiffInChangelogMessage } from './shared'; import { ReleaseVersion, noDiffInChangelogMessage } from './shared';
@ -20,12 +22,14 @@ const axios = _axios as any as (typeof _axios)['default'];
export type RepoSlug = `${string}/${string}`; export type RepoSlug = `${string}/${string}`;
export interface GithubRequestConfig { interface GithubRequestConfig {
repo: string; repo: string;
hostname: string;
apiBaseUrl: string;
token: string | null; token: string | null;
} }
export interface GithubRelease { interface GithubRelease {
id?: string; id?: string;
tag_name: string; tag_name: string;
target_commitish?: string; target_commitish?: string;
@ -35,19 +39,46 @@ export interface GithubRelease {
prerelease?: boolean; prerelease?: boolean;
} }
export function getGitHubRepoSlug(remoteName = 'origin'): RepoSlug { export interface GithubRepoData {
hostname: string;
slug: RepoSlug;
apiBaseUrl: string;
}
export function getGitHubRepoData(
remoteName = 'origin',
createReleaseConfig: NxReleaseChangelogConfiguration['createRelease']
): GithubRepoData | null {
try { try {
const remoteUrl = execSync(`git remote get-url ${remoteName}`, { const remoteUrl = execSync(`git remote get-url ${remoteName}`, {
encoding: 'utf8', encoding: 'utf8',
stdio: 'pipe', stdio: 'pipe',
}).trim(); }).trim();
// Use the default provider (github.com) if custom one is not specified or releases are disabled
let hostname = defaultCreateReleaseProvider.hostname;
let apiBaseUrl = defaultCreateReleaseProvider.apiBaseUrl;
if (
createReleaseConfig !== false &&
typeof createReleaseConfig !== 'string'
) {
hostname = createReleaseConfig.hostname;
apiBaseUrl = createReleaseConfig.apiBaseUrl;
}
// Extract the 'user/repo' part from the URL // Extract the 'user/repo' part from the URL
const regex = /github\.com[/:]([\w-]+\/[\w-]+)/; const escapedHostname = hostname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regexString = `${escapedHostname}[/:]([\\w.-]+/[\\w.-]+)(\\.git)?`;
const regex = new RegExp(regexString);
const match = remoteUrl.match(regex); const match = remoteUrl.match(regex);
if (match && match[1]) { if (match && match[1]) {
return match[1] as RepoSlug; return {
hostname,
apiBaseUrl,
// Ensure any trailing .git is stripped
slug: match[1].replace(/\.git$/, '') as RepoSlug,
};
} else { } else {
throw new Error( throw new Error(
`Could not extract "user/repo" data from the resolved remote URL: ${remoteUrl}` `Could not extract "user/repo" data from the resolved remote URL: ${remoteUrl}`
@ -59,13 +90,14 @@ export function getGitHubRepoSlug(remoteName = 'origin'): RepoSlug {
} }
export async function createOrUpdateGithubRelease( export async function createOrUpdateGithubRelease(
createReleaseConfig: NxReleaseChangelogConfiguration['createRelease'],
releaseVersion: ReleaseVersion, releaseVersion: ReleaseVersion,
changelogContents: string, changelogContents: string,
latestCommit: string, latestCommit: string,
{ dryRun }: { dryRun: boolean } { dryRun }: { dryRun: boolean }
): Promise<void> { ): Promise<void> {
const githubRepoSlug = getGitHubRepoSlug(); const githubRepoData = getGitHubRepoData(undefined, createReleaseConfig);
if (!githubRepoSlug) { if (!githubRepoData) {
output.error({ output.error({
title: `Unable to create a GitHub release because the GitHub repo slug could not be determined.`, title: `Unable to create a GitHub release because the GitHub repo slug could not be determined.`,
bodyLines: [ bodyLines: [
@ -75,9 +107,11 @@ export async function createOrUpdateGithubRelease(
process.exit(1); process.exit(1);
} }
const token = await resolveGithubToken(); const token = await resolveGithubToken(githubRepoData.hostname);
const githubRequestConfig: GithubRequestConfig = { const githubRequestConfig: GithubRequestConfig = {
repo: githubRepoSlug, repo: githubRepoData.slug,
hostname: githubRepoData.hostname,
apiBaseUrl: githubRepoData.apiBaseUrl,
token, token,
}; };
@ -106,7 +140,7 @@ export async function createOrUpdateGithubRelease(
} }
} }
const logTitle = `https://github.com/${githubRepoSlug}/releases/tag/${releaseVersion.gitTag}`; const logTitle = `https://${githubRepoData.hostname}/${githubRepoData.slug}/releases/tag/${releaseVersion.gitTag}`;
if (existingGithubReleaseForVersion) { if (existingGithubReleaseForVersion) {
console.error( console.error(
`${chalk.white('UPDATE')} ${logTitle}${ `${chalk.white('UPDATE')} ${logTitle}${
@ -304,7 +338,7 @@ async function syncGithubRelease(
} }
} }
export async function resolveGithubToken(): Promise<string | null> { async function resolveGithubToken(hostname: string): Promise<string | null> {
// Try and resolve from the environment // Try and resolve from the environment
const tokenFromEnv = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; const tokenFromEnv = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
if (tokenFromEnv) { if (tokenFromEnv) {
@ -320,15 +354,15 @@ export async function resolveGithubToken(): Promise<string | null> {
const yamlContents = await fsp.readFile(ghCLIPath, 'utf8'); const yamlContents = await fsp.readFile(ghCLIPath, 'utf8');
const { load } = require('@zkochan/js-yaml'); const { load } = require('@zkochan/js-yaml');
const ghCLIConfig = load(yamlContents); const ghCLIConfig = load(yamlContents);
if (ghCLIConfig['github.com']) { if (ghCLIConfig[hostname]) {
// Web based session (the token is already embedded in the config) // Web based session (the token is already embedded in the config)
if (ghCLIConfig['github.com'].oauth_token) { if (ghCLIConfig[hostname].oauth_token) {
return ghCLIConfig['github.com'].oauth_token; return ghCLIConfig[hostname].oauth_token;
} }
// SSH based session (we need to dynamically resolve a token using the CLI) // SSH based session (we need to dynamically resolve a token using the CLI)
if ( if (
ghCLIConfig['github.com'].user && ghCLIConfig[hostname].user &&
ghCLIConfig['github.com'].git_protocol === 'ssh' ghCLIConfig[hostname].git_protocol === 'ssh'
) { ) {
return execSync(`gh auth token`, { return execSync(`gh auth token`, {
encoding: 'utf8', encoding: 'utf8',
@ -337,6 +371,11 @@ export async function resolveGithubToken(): Promise<string | null> {
} }
} }
} }
if (hostname !== 'github.com') {
console.log(
`Warning: It was not possible to automatically resolve a GitHub token from your environment for hostname ${hostname}. If you set the GITHUB_TOKEN or GH_TOKEN environment variable, that will be used for GitHub API requests.`
);
}
return null; return null;
} }
@ -359,7 +398,7 @@ async function makeGithubRequest(
return ( return (
await axios<any, any>(url, { await axios<any, any>(url, {
...opts, ...opts,
baseURL: 'https://api.github.com', baseURL: config.apiBaseUrl,
headers: { headers: {
...(opts.headers as any), ...(opts.headers as any),
Authorization: config.token ? `Bearer ${config.token}` : undefined, Authorization: config.token ? `Bearer ${config.token}` : undefined,
@ -395,11 +434,18 @@ async function updateGithubRelease(
function githubNewReleaseURL( function githubNewReleaseURL(
config: GithubRequestConfig, config: GithubRequestConfig,
release: { version: string; body: string } release: GithubReleaseOptions
) { ) {
return `https://github.com/${config.repo}/releases/new?tag=${ // Parameters taken from https://github.com/isaacs/github/issues/1410#issuecomment-442240267
let url = `https://${config.hostname}/${config.repo}/releases/new?tag=${
release.version release.version
}&title=${release.version}&body=${encodeURIComponent(release.body)}`; }&title=${release.version}&body=${encodeURIComponent(release.body)}&target=${
release.commit
}`;
if (release.prerelease) {
url += '&prerelease=true';
}
return url;
} }
type RepoProvider = 'github'; type RepoProvider = 'github';
@ -411,27 +457,30 @@ const providerToRefSpec: Record<
github: { 'pull-request': 'pull', hash: 'commit', issue: 'issues' }, github: { 'pull-request': 'pull', hash: 'commit', issue: 'issues' },
}; };
function formatReference(ref: Reference, repoSlug: `${string}/${string}`) { function formatReference(ref: Reference, repoData: GithubRepoData) {
const refSpec = providerToRefSpec['github']; const refSpec = providerToRefSpec['github'];
return `[${ref.value}](https://github.com/${repoSlug}/${ return `[${ref.value}](https://${repoData.hostname}/${repoData.slug}/${
refSpec[ref.type] refSpec[ref.type]
}/${ref.value.replace(/^#/, '')})`; }/${ref.value.replace(/^#/, '')})`;
} }
export function formatReferences(references: Reference[], repoSlug: RepoSlug) { export function formatReferences(
references: Reference[],
repoData: GithubRepoData
) {
const pr = references.filter((ref) => ref.type === 'pull-request'); const pr = references.filter((ref) => ref.type === 'pull-request');
const issue = references.filter((ref) => ref.type === 'issue'); const issue = references.filter((ref) => ref.type === 'issue');
if (pr.length > 0 || issue.length > 0) { if (pr.length > 0 || issue.length > 0) {
return ( return (
' (' + ' (' +
[...pr, ...issue] [...pr, ...issue]
.map((ref) => formatReference(ref, repoSlug)) .map((ref) => formatReference(ref, repoData))
.join(', ') + .join(', ') +
')' ')'
); );
} }
if (references.length > 0) { if (references.length > 0) {
return ' (' + formatReference(references[0], repoSlug) + ')'; return ' (' + formatReference(references[0], repoData) + ')';
} }
return ''; return '';
} }

View File

@ -79,7 +79,17 @@ export interface NxReleaseChangelogConfiguration {
* NOTE: if createRelease is set on a group of projects, it will cause the default releaseTagPattern of * NOTE: if createRelease is set on a group of projects, it will cause the default releaseTagPattern of
* "{projectName}@{version}" to be used for those projects, even when versioning everything together. * "{projectName}@{version}" to be used for those projects, even when versioning everything together.
*/ */
createRelease?: 'github' | false; createRelease?:
| false
| 'github'
| {
provider: 'github-enterprise-server';
hostname: string;
/**
* If not set, this will default to `https://${hostname}/api/v3`
*/
apiBaseUrl?: string;
};
/** /**
* This can either be set to a string value that will be written to the changelog file(s) * This can either be set to a string value that will be written to the changelog file(s)
* at the workspace root and/or within project directories, or set to `false` to specify * at the workspace root and/or within project directories, or set to `false` to specify