feat(release)!: support gitlab releases (#30802)

This commit is contained in:
James Henry 2025-04-30 12:41:03 +04:00 committed by GitHub
parent d5a1918eb6
commit 9dcab79b10
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 2535 additions and 914 deletions

View File

@ -1308,6 +1308,14 @@
"children": [], "children": [],
"disableCollapsible": false "disableCollapsible": false
}, },
{
"name": "Automate GitLab Releases",
"path": "/recipes/nx-release/automate-gitlab-releases",
"id": "automate-gitlab-releases",
"isExternal": false,
"children": [],
"disableCollapsible": false
},
{ {
"name": "Publish Rust Crates", "name": "Publish Rust Crates",
"path": "/recipes/nx-release/publish-rust-crates", "path": "/recipes/nx-release/publish-rust-crates",
@ -2554,6 +2562,14 @@
"children": [], "children": [],
"disableCollapsible": false "disableCollapsible": false
}, },
{
"name": "Automate GitLab Releases",
"path": "/recipes/nx-release/automate-gitlab-releases",
"id": "automate-gitlab-releases",
"isExternal": false,
"children": [],
"disableCollapsible": false
},
{ {
"name": "Publish Rust Crates", "name": "Publish Rust Crates",
"path": "/recipes/nx-release/publish-rust-crates", "path": "/recipes/nx-release/publish-rust-crates",
@ -2669,6 +2685,14 @@
"children": [], "children": [],
"disableCollapsible": false "disableCollapsible": false
}, },
{
"name": "Automate GitLab Releases",
"path": "/recipes/nx-release/automate-gitlab-releases",
"id": "automate-gitlab-releases",
"isExternal": false,
"children": [],
"disableCollapsible": false
},
{ {
"name": "Publish Rust Crates", "name": "Publish Rust Crates",
"path": "/recipes/nx-release/publish-rust-crates", "path": "/recipes/nx-release/publish-rust-crates",

View File

@ -3394,6 +3394,26 @@
} }
}, },
"migrations": { "migrations": {
"/nx-api/nx/migrations/release-version-config-changes": {
"description": "Updates release version config based on the breaking changes in Nx v21",
"file": "generated/packages/nx/migrations/release-version-config-changes.json",
"hidden": false,
"name": "release-version-config-changes",
"version": "21.0.0-beta.11",
"originalFilePath": "/packages/nx",
"path": "/nx-api/nx/migrations/release-version-config-changes",
"type": "migration"
},
"/nx-api/nx/migrations/release-changelog-config-changes": {
"description": "Updates release changelog config based on the breaking changes in Nx v21",
"file": "generated/packages/nx/migrations/release-changelog-config-changes.json",
"hidden": false,
"name": "release-changelog-config-changes",
"version": "21.0.0-beta.11",
"originalFilePath": "/packages/nx",
"path": "/nx-api/nx/migrations/release-changelog-config-changes",
"type": "migration"
},
"/nx-api/nx/migrations/remove-legacy-cache": { "/nx-api/nx/migrations/remove-legacy-cache": {
"description": "Removes the legacy cache configuration from nx.json", "description": "Removes the legacy cache configuration from nx.json",
"file": "generated/packages/nx/migrations/remove-legacy-cache.json", "file": "generated/packages/nx/migrations/remove-legacy-cache.json",
@ -3414,16 +3434,6 @@
"path": "/nx-api/nx/migrations/remove-custom-tasks-runner", "path": "/nx-api/nx/migrations/remove-custom-tasks-runner",
"type": "migration" "type": "migration"
}, },
"/nx-api/nx/migrations/release-version-config-changes": {
"description": "Updates release version config based on the breaking changes in Nx v21",
"file": "generated/packages/nx/migrations/release-version-config-changes.json",
"hidden": false,
"name": "release-version-config-changes",
"version": "21.0.0-beta.1",
"originalFilePath": "/packages/nx",
"path": "/nx-api/nx/migrations/release-version-config-changes",
"type": "migration"
},
"/nx-api/nx/migrations/use-legacy-cache": { "/nx-api/nx/migrations/use-legacy-cache": {
"description": "Set `useLegacyCache` to true for migrating workspaces", "description": "Set `useLegacyCache` to true for migrating workspaces",
"file": "generated/packages/nx/migrations/use-legacy-cache.json", "file": "generated/packages/nx/migrations/use-legacy-cache.json",

View File

@ -1792,6 +1792,17 @@
"path": "/recipes/nx-release/automate-github-releases", "path": "/recipes/nx-release/automate-github-releases",
"tags": ["nx-release"] "tags": ["nx-release"]
}, },
{
"id": "automate-gitlab-releases",
"name": "Automate GitLab Releases",
"description": "",
"mediaImage": "",
"file": "shared/recipes/nx-release/automate-gitlab-releases",
"itemList": [],
"isExternal": false,
"path": "/recipes/nx-release/automate-gitlab-releases",
"tags": ["nx-release"]
},
{ {
"id": "publish-rust-crates", "id": "publish-rust-crates",
"name": "Publish Rust Crates", "name": "Publish Rust Crates",
@ -3499,6 +3510,17 @@
"path": "/recipes/nx-release/automate-github-releases", "path": "/recipes/nx-release/automate-github-releases",
"tags": ["nx-release"] "tags": ["nx-release"]
}, },
{
"id": "automate-gitlab-releases",
"name": "Automate GitLab Releases",
"description": "",
"mediaImage": "",
"file": "shared/recipes/nx-release/automate-gitlab-releases",
"itemList": [],
"isExternal": false,
"path": "/recipes/nx-release/automate-gitlab-releases",
"tags": ["nx-release"]
},
{ {
"id": "publish-rust-crates", "id": "publish-rust-crates",
"name": "Publish Rust Crates", "name": "Publish Rust Crates",
@ -3658,6 +3680,17 @@
"path": "/recipes/nx-release/automate-github-releases", "path": "/recipes/nx-release/automate-github-releases",
"tags": ["nx-release"] "tags": ["nx-release"]
}, },
"/recipes/nx-release/automate-gitlab-releases": {
"id": "automate-gitlab-releases",
"name": "Automate GitLab Releases",
"description": "",
"mediaImage": "",
"file": "shared/recipes/nx-release/automate-gitlab-releases",
"itemList": [],
"isExternal": false,
"path": "/recipes/nx-release/automate-gitlab-releases",
"tags": ["nx-release"]
},
"/recipes/nx-release/publish-rust-crates": { "/recipes/nx-release/publish-rust-crates": {
"id": "publish-rust-crates", "id": "publish-rust-crates",
"name": "Publish Rust Crates", "name": "Publish Rust Crates",

View File

@ -680,6 +680,13 @@
"name": "Automate GitHub Releases", "name": "Automate GitHub Releases",
"path": "/recipes/nx-release/automate-github-releases" "path": "/recipes/nx-release/automate-github-releases"
}, },
{
"description": "",
"file": "shared/recipes/nx-release/automate-gitlab-releases",
"id": "automate-gitlab-releases",
"name": "Automate GitLab Releases",
"path": "/recipes/nx-release/automate-gitlab-releases"
},
{ {
"description": "", "description": "",
"file": "shared/recipes/nx-release/publish-rust-crates", "file": "shared/recipes/nx-release/publish-rust-crates",

View File

@ -3370,6 +3370,26 @@
} }
], ],
"migrations": [ "migrations": [
{
"description": "Updates release version config based on the breaking changes in Nx v21",
"file": "generated/packages/nx/migrations/release-version-config-changes.json",
"hidden": false,
"name": "release-version-config-changes",
"version": "21.0.0-beta.11",
"originalFilePath": "/packages/nx",
"path": "nx/migrations/release-version-config-changes",
"type": "migration"
},
{
"description": "Updates release changelog config based on the breaking changes in Nx v21",
"file": "generated/packages/nx/migrations/release-changelog-config-changes.json",
"hidden": false,
"name": "release-changelog-config-changes",
"version": "21.0.0-beta.11",
"originalFilePath": "/packages/nx",
"path": "nx/migrations/release-changelog-config-changes",
"type": "migration"
},
{ {
"description": "Removes the legacy cache configuration from nx.json", "description": "Removes the legacy cache configuration from nx.json",
"file": "generated/packages/nx/migrations/remove-legacy-cache.json", "file": "generated/packages/nx/migrations/remove-legacy-cache.json",
@ -3390,16 +3410,6 @@
"path": "nx/migrations/remove-custom-tasks-runner", "path": "nx/migrations/remove-custom-tasks-runner",
"type": "migration" "type": "migration"
}, },
{
"description": "Updates release version config based on the breaking changes in Nx v21",
"file": "generated/packages/nx/migrations/release-version-config-changes.json",
"hidden": false,
"name": "release-version-config-changes",
"version": "21.0.0-beta.1",
"originalFilePath": "/packages/nx",
"path": "nx/migrations/release-version-config-changes",
"type": "migration"
},
{ {
"description": "Set `useLegacyCache` to true for migrating workspaces", "description": "Set `useLegacyCache` to true for migrating workspaces",
"file": "generated/packages/nx/migrations/use-legacy-cache.json", "file": "generated/packages/nx/migrations/use-legacy-cache.json",

View File

@ -0,0 +1,12 @@
{
"name": "release-changelog-config-changes",
"version": "21.0.0-beta.11",
"description": "Updates release changelog config based on the breaking changes in Nx v21",
"implementation": "/packages/nx/src/migrations/update-21-0-0/release-changelog-config-changes.ts",
"aliases": [],
"hidden": false,
"path": "/packages/nx",
"schema": null,
"type": "migration",
"examplesFile": "#### Nx Release Changelog Config Changes\n\nIn Nx v21, the `mapAuthorsToGitHubUsernames` changelog \"renderOption\" for the default changelog renderer was renamed to `applyUsernameToAuthors` to reflect the fact that it is no longer specific to GitHub. Most people were not setting this option directly, but if you were, it will be automatically migrated by this migration.\n\nThe migration will also update release groups changelog configuration, if applicable.\n\n#### Sample Code Changes\n\n{% tabs %}\n{% tab label=\"Before\" %}\n\n```json {% fileName=\"nx.json\" %}\n{\n \"release\": {\n \"changelog\": {\n \"workspaceChangelog\": {\n \"renderOptions\": {\n \"mapAuthorsToGitHubUsernames\": true\n }\n },\n \"projectChangelogs\": {\n \"renderOptions\": {\n \"mapAuthorsToGitHubUsernames\": false\n }\n }\n }\n }\n}\n```\n\n{% /tab %}\n{% tab label=\"After\" %}\n\n```json {% fileName=\"nx.json\" %}\n{\n \"release\": {\n \"changelog\": {\n \"workspaceChangelog\": {\n \"renderOptions\": {\n \"applyUsernameToAuthors\": true\n }\n },\n \"projectChangelogs\": {\n \"renderOptions\": {\n \"applyUsernameToAuthors\": false\n }\n }\n }\n }\n}\n```\n\n{% /tab %}\n{% /tabs %}\n"
}

View File

@ -1,6 +1,6 @@
{ {
"name": "release-version-config-changes", "name": "release-version-config-changes",
"version": "21.0.0-beta.1", "version": "21.0.0-beta.11",
"description": "Updates release version config based on the breaking changes in Nx v21", "description": "Updates release version config based on the breaking changes in Nx v21",
"implementation": "/packages/nx/src/migrations/update-21-0-0/release-version-config-changes.ts", "implementation": "/packages/nx/src/migrations/update-21-0-0/release-version-config-changes.ts",
"aliases": [], "aliases": [],

View File

@ -551,6 +551,12 @@
"tags": ["nx-release"], "tags": ["nx-release"],
"file": "shared/recipes/nx-release/automate-github-releases" "file": "shared/recipes/nx-release/automate-github-releases"
}, },
{
"name": "Automate GitLab Releases",
"id": "automate-gitlab-releases",
"tags": ["nx-release"],
"file": "shared/recipes/nx-release/automate-gitlab-releases"
},
{ {
"name": "Publish Rust Crates", "name": "Publish Rust Crates",
"id": "publish-rust-crates", "id": "publish-rust-crates",

View File

@ -75,7 +75,9 @@ Changelog render options can be passed as [an object](https://github.com/nrwl/nx
// Whether or not the commit references (such as commit and/or PR links) should be included in the changelog. // Whether or not the commit references (such as commit and/or PR links) should be included in the changelog.
"commitReferences": true, "commitReferences": true,
// Whether or not to include the date in the version title. It can be set to false to disable it, or true to enable with the default of (YYYY-MM-DD). // Whether or not to include the date in the version title. It can be set to false to disable it, or true to enable with the default of (YYYY-MM-DD).
"versionTitleDate": true "versionTitleDate": true,
// Whether to apply usernames to authors in the Thank You section. Note, this option was called mapAuthorsToGitHubUsernames prior to Nx v21.
"applyUsernameToAuthors": true
} }
}, },
"workspaceChangelog": { "workspaceChangelog": {
@ -85,7 +87,9 @@ Changelog render options can be passed as [an object](https://github.com/nrwl/nx
// Whether or not the commit references (such as commit and/or PR links) should be included in the changelog. // Whether or not the commit references (such as commit and/or PR links) should be included in the changelog.
"commitReferences": true, "commitReferences": true,
// Whether or not to include the date in the version title. It can be set to false to disable it, or true to enable with the default of (YYYY-MM-DD). // Whether or not to include the date in the version title. It can be set to false to disable it, or true to enable with the default of (YYYY-MM-DD).
"versionTitleDate": true "versionTitleDate": true,
// Whether to apply usernames to authors in the Thank You section. Note, this option was called mapAuthorsToGitHubUsernames prior to Nx v21.
"applyUsernameToAuthors": true
} }
} }
} }

View File

@ -0,0 +1,103 @@
---
title: Automate GitLab Releases
description: Learn how to configure Nx Release to automatically create GitLab releases with changelogs generated from your conventional commits, for both workspace and project-level releases.
---
# Automate GitLab Releases
Nx Release can automate the creation of [GitLab releases](https://docs.gitlab.com/user/project/releases/) for you. GitLab releases are a great way to communicate the changes in your projects to your users.
<!-- Prettier will mess up the end tag of the callout causing it to capture all content that follows it -->
<!-- prettier-ignore-start -->
{% callout type="note" title="Authenticating with GitLab" %}
In order to be able to create the release on GitLab, you need to provide a valid token which can be used for authenticating with the GitLab API.
Nx release supports two main ways of doing this:
1. In all environments it will preferentially check for an environment variable (the environment variable can either be called `GITLAB_TOKEN` or `GL_TOKEN`).
2. In GitLab CI it will check for and use the automatically created GitLab token in the `CI_JOB_TOKEN` environment variable.
{% /callout %}
<!-- prettier-ignore-end -->
## GitLab Release Contents
When a GitLab release is created, it will include the changelog that Nx Release generates with entries based on the changes since the last release. Nx Release will parse the `feat` and `fix` type commits according to the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification and sort them into appropriate sections of the changelog.
## Enable Release Creation
To enable GitLab release creation for your workspace, set `release.changelog.workspaceChangelog.createRelease` to `'gitlab'` in `nx.json`:
```json
{
"release": {
"changelog": {
"workspaceChangelog": {
"createRelease": "gitlab"
}
}
}
}
```
## Preview the Release
Use `nx release --dry-run` to preview the GitLab release instead of creating it. This allows you to see what the release will look like without pushing anything to GitLab.
## Disable File Creation
Since GitLab releases contain the changelog, you may wish to disable the generation and management of the local `CHANGELOG.md` file. To do this, set `release.changelog.workspaceChangelog.file` to `false` in `nx.json`:
```json
{
"release": {
"changelog": {
"workspaceChangelog": {
"file": false,
"createRelease": "gitlab"
}
}
}
}
```
Note: When configured this way, Nx Release will not delete existing changelog files, just ignore them.
## Project Level Changelogs
Nx Release supports creating GitLab releases for project level changelogs as well. This is particularly useful when [releasing projects independently](/recipes/nx-release/release-projects-independently). To enable this, set `release.changelog.projectChangelogs.createRelease` to `'gitlab'` in `nx.json`:
```json
{
"release": {
"changelog": {
"projectChangelogs": {
"createRelease": "gitlab"
}
}
}
}
```
{% callout type="warning" title="Project and Workspace GitLab Releases" %}
Nx Release does not support creating GitLab releases for both project level changelogs and the workspace changelog. You will need to choose one or the other.
{% /callout %}
## Customizing the GitLab instance
If you are not using gitlab.com, and are instead using a self-hosted GitLab instance, you can use a configuration object instead of the string for "createRelease" to provide the relevant hostname, and optionally override the API base URL, although this is not typically needed as it will default to `https://${hostname}/api/v4`.
```json
{
"release": {
"changelog": {
"workspaceChangelog": {
"createRelease": {
"provider": "gitlab",
"hostname": "gitlab.example.com"
}
}
}
}
}
```

View File

@ -32,7 +32,7 @@ There are a few options available to modify the default changelog renderer outpu
"projectChangelogs": { "projectChangelogs": {
"renderOptions": { "renderOptions": {
"authors": true, "authors": true,
"mapAuthorsToGitHubUsernames": true, "applyUsernameToAuthors": true,
"commitReferences": true, "commitReferences": true,
"versionTitleDate": true "versionTitleDate": true
} }
@ -46,12 +46,14 @@ There are a few options available to modify the default changelog renderer outpu
Whether the commit authors should be added to the bottom of the changelog in a "Thank You" section. Defaults to `true`. Whether the commit authors should be added to the bottom of the changelog in a "Thank You" section. Defaults to `true`.
#### `mapAuthorsToGitHubUsernames` #### `applyUsernameToAuthors`
If authors is enabled, controls whether or not to try to map the authors to their GitHub usernames using https://ungh.cc (from https://github.com/unjs/ungh) and the email addresses found in the commits. Defaults to `true`. If authors is enabled, controls whether or not to try to map the authors to their GitHub usernames using https://ungh.cc (from https://github.com/unjs/ungh) and the email addresses found in the commits. Defaults to `true`.
You should disable this option if you don't want to make any external requests to https://ungh.cc You should disable this option if you don't want to make any external requests to https://ungh.cc
NOTE: Prior to Nx v21, this option was named `mapAuthorsToGitHubUsernames`.
#### `commitReferences` #### `commitReferences`
Whether the commit references (such as commit and/or PR links) should be included in the changelog. Defaults to `true`. Whether the commit references (such as commit and/or PR links) should be included in the changelog. Defaults to `true`.
@ -71,7 +73,7 @@ If you prefer a more minimalist changelog, you can set all the options to false,
"projectChangelogs": { "projectChangelogs": {
"renderOptions": { "renderOptions": {
"authors": false, "authors": false,
"mapAuthorsToGitHubUsernames": false, "applyUsernameToAuthors": false,
"commitReferences": false, "commitReferences": false,
"versionTitleDate": false "versionTitleDate": false
} }

View File

@ -83,6 +83,7 @@
- [Configure Custom Registries](/recipes/nx-release/configure-custom-registries) - [Configure Custom Registries](/recipes/nx-release/configure-custom-registries)
- [Publish in CI/CD](/recipes/nx-release/publish-in-ci-cd) - [Publish in CI/CD](/recipes/nx-release/publish-in-ci-cd)
- [Automate GitHub Releases](/recipes/nx-release/automate-github-releases) - [Automate GitHub Releases](/recipes/nx-release/automate-github-releases)
- [Automate GitLab Releases](/recipes/nx-release/automate-gitlab-releases)
- [Publish Rust Crates](/recipes/nx-release/publish-rust-crates) - [Publish Rust Crates](/recipes/nx-release/publish-rust-crates)
- [Update Your Local Registry Setup to use Nx Release](/recipes/nx-release/update-local-registry-setup) - [Update Your Local Registry Setup to use Nx Release](/recipes/nx-release/update-local-registry-setup)
- [Configure Changelog Format](/recipes/nx-release/configure-changelog-format) - [Configure Changelog Format](/recipes/nx-release/configure-changelog-format)

View File

@ -29,11 +29,6 @@
"implementation": "./src/migrations/update-20-0-1/use-legacy-cache", "implementation": "./src/migrations/update-20-0-1/use-legacy-cache",
"x-repair-skip": true "x-repair-skip": true
}, },
"release-version-config-changes": {
"version": "21.0.0-beta.1",
"description": "Updates release version config based on the breaking changes in Nx v21",
"implementation": "./src/migrations/update-21-0-0/release-version-config-changes"
},
"remove-legacy-cache": { "remove-legacy-cache": {
"version": "21.0.0-beta.8", "version": "21.0.0-beta.8",
"description": "Removes the legacy cache configuration from nx.json", "description": "Removes the legacy cache configuration from nx.json",
@ -43,6 +38,16 @@
"version": "21.0.0-beta.8", "version": "21.0.0-beta.8",
"description": "Removes the legacy cache configuration from nx.json", "description": "Removes the legacy cache configuration from nx.json",
"implementation": "./src/migrations/update-21-0-0/remove-custom-tasks-runner" "implementation": "./src/migrations/update-21-0-0/remove-custom-tasks-runner"
},
"release-version-config-changes": {
"version": "21.0.0-beta.11",
"description": "Updates release version config based on the breaking changes in Nx v21",
"implementation": "./src/migrations/update-21-0-0/release-version-config-changes"
},
"release-changelog-config-changes": {
"version": "21.0.0-beta.11",
"description": "Updates release changelog config based on the breaking changes in Nx v21",
"implementation": "./src/migrations/update-21-0-0/release-changelog-config-changes"
} }
} }
} }

View File

@ -1,5 +1,6 @@
import type { ChangelogChange } from '../../src/command-line/release/changelog'; import type { ChangelogChange } from '../../src/command-line/release/changelog';
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 { GithubRemoteReleaseClient } from '../../src/command-line/release/utils/remote-release-clients/github';
import DefaultChangelogRenderer from './index'; import DefaultChangelogRenderer from './index';
jest.mock('../../src/project-graph/file-map-utils', () => ({ jest.mock('../../src/project-graph/file-map-utils', () => ({
@ -26,6 +27,15 @@ jest.mock('../../src/project-graph/file-map-utils', () => ({
})); }));
describe('ChangelogRenderer', () => { describe('ChangelogRenderer', () => {
const remoteReleaseClient = new GithubRemoteReleaseClient(
{
hostname: 'example.com',
slug: 'example/example',
apiBaseUrl: 'https://api.example.com',
},
false,
null
);
const changes: ChangelogChange[] = [ const changes: ChangelogChange[] = [
{ {
shortHash: '4130f65', shortHash: '4130f65',
@ -144,6 +154,7 @@ describe('ChangelogRenderer', () => {
it('should generate markdown for all projects by organizing commits by type, then grouped by scope within the type (sorted alphabetically), then chronologically within the scope group', async () => { it('should generate markdown for all projects by organizing commits by type, then grouped by scope within the type (sorted alphabetically), then chronologically within the scope group', async () => {
const renderer = new DefaultChangelogRenderer({ const renderer = new DefaultChangelogRenderer({
changes, changes,
remoteReleaseClient,
changelogEntryVersion: 'v1.1.0', changelogEntryVersion: 'v1.1.0',
project: null, project: null,
isVersionPlans: false, isVersionPlans: false,
@ -177,6 +188,7 @@ describe('ChangelogRenderer', () => {
it('should not generate a Thank You section when changelogRenderOptions.authors is false', async () => { it('should not generate a Thank You section when changelogRenderOptions.authors is false', async () => {
const renderer = new DefaultChangelogRenderer({ const renderer = new DefaultChangelogRenderer({
changes, changes,
remoteReleaseClient,
// Major version, should use single # for generated heading // Major version, should use single # for generated heading
changelogEntryVersion: 'v1.0.0', changelogEntryVersion: 'v1.0.0',
project: null, project: null,
@ -209,6 +221,7 @@ describe('ChangelogRenderer', () => {
it('should generate markdown for the given project by organizing commits by type, then chronologically', async () => { it('should generate markdown for the given project by organizing commits by type, then chronologically', async () => {
const otherOpts = { const otherOpts = {
changes, changes,
remoteReleaseClient,
changelogEntryVersion: 'v1.1.0', changelogEntryVersion: 'v1.1.0',
entryWhenNoChanges: false as const, entryWhenNoChanges: false as const,
isVersionPlans: false, isVersionPlans: false,
@ -401,6 +414,7 @@ describe('ChangelogRenderer', () => {
const otherOpts = { const otherOpts = {
changes, changes,
remoteReleaseClient,
changelogEntryVersion: 'v1.1.0', changelogEntryVersion: 'v1.1.0',
entryWhenNoChanges: false as const, entryWhenNoChanges: false as const,
isVersionPlans: false, isVersionPlans: false,
@ -464,6 +478,7 @@ describe('ChangelogRenderer', () => {
it('should respect the entryWhenNoChanges option for the workspace changelog', async () => { it('should respect the entryWhenNoChanges option for the workspace changelog', async () => {
const otherOpts = { const otherOpts = {
changes: [], changes: [],
remoteReleaseClient,
changelogEntryVersion: 'v1.1.0', changelogEntryVersion: 'v1.1.0',
project: null, // workspace changelog project: null, // workspace changelog
isVersionPlans: false, isVersionPlans: false,
@ -495,6 +510,7 @@ describe('ChangelogRenderer', () => {
it('should respect the entryWhenNoChanges option for project changelogs', async () => { it('should respect the entryWhenNoChanges option for project changelogs', async () => {
const otherOpts = { const otherOpts = {
changes: [], changes: [],
remoteReleaseClient,
changelogEntryVersion: 'v1.1.0', changelogEntryVersion: 'v1.1.0',
project: 'pkg-a', project: 'pkg-a',
isVersionPlans: false, isVersionPlans: false,
@ -558,6 +574,7 @@ describe('ChangelogRenderer', () => {
const markdown = await new DefaultChangelogRenderer({ const markdown = await new DefaultChangelogRenderer({
changes: changesWithOnlyRevert, changes: changesWithOnlyRevert,
remoteReleaseClient,
changelogEntryVersion: 'v1.1.0', changelogEntryVersion: 'v1.1.0',
project: null, project: null,
isVersionPlans: false, isVersionPlans: false,
@ -640,6 +657,7 @@ describe('ChangelogRenderer', () => {
const markdown = await new DefaultChangelogRenderer({ const markdown = await new DefaultChangelogRenderer({
changes: changesWithRevertAndOriginal, changes: changesWithRevertAndOriginal,
remoteReleaseClient,
changelogEntryVersion: 'v1.1.0', changelogEntryVersion: 'v1.1.0',
project: null, project: null,
isVersionPlans: false, isVersionPlans: false,
@ -678,6 +696,7 @@ describe('ChangelogRenderer', () => {
const markdown = await new DefaultChangelogRenderer({ const markdown = await new DefaultChangelogRenderer({
changes: [breakingChangeWithExplanation], changes: [breakingChangeWithExplanation],
remoteReleaseClient,
changelogEntryVersion: 'v1.1.0', changelogEntryVersion: 'v1.1.0',
project: null, project: null,
isVersionPlans: false, isVersionPlans: false,
@ -731,6 +750,7 @@ describe('ChangelogRenderer', () => {
const markdown = await new DefaultChangelogRenderer({ const markdown = await new DefaultChangelogRenderer({
changes: [breakingChangeWithExplanation], changes: [breakingChangeWithExplanation],
remoteReleaseClient,
changelogEntryVersion: 'v1.1.0', changelogEntryVersion: 'v1.1.0',
project: null, project: null,
isVersionPlans: false, isVersionPlans: false,
@ -764,6 +784,7 @@ describe('ChangelogRenderer', () => {
expect( expect(
await new DefaultChangelogRenderer({ await new DefaultChangelogRenderer({
changes, changes,
remoteReleaseClient,
changelogEntryVersion: 'v1.1.0', changelogEntryVersion: 'v1.1.0',
entryWhenNoChanges: false as const, entryWhenNoChanges: false as const,
changelogRenderOptions: { changelogRenderOptions: {
@ -805,6 +826,7 @@ describe('ChangelogRenderer', () => {
expect( expect(
await new DefaultChangelogRenderer({ await new DefaultChangelogRenderer({
changes: [], changes: [],
remoteReleaseClient,
changelogEntryVersion: 'v3.1.0', changelogEntryVersion: 'v3.1.0',
entryWhenNoChanges: entryWhenNoChanges:
'should not be printed because we have dependency bumps', 'should not be printed because we have dependency bumps',
@ -842,6 +864,7 @@ describe('ChangelogRenderer', () => {
const renderer = new CustomChangelogRenderer({ const renderer = new CustomChangelogRenderer({
changes, changes,
remoteReleaseClient,
changelogEntryVersion: 'v1.1.0', changelogEntryVersion: 'v1.1.0',
project: null, project: null,
isVersionPlans: false, isVersionPlans: false,

View File

@ -1,15 +1,8 @@
import { major } from 'semver'; import { major } from 'semver';
import { ChangelogChange } from '../../src/command-line/release/changelog'; import type { ChangelogChange } from '../../src/command-line/release/changelog';
import { NxReleaseConfig } from '../../src/command-line/release/config/config'; import type { 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 { import type { RemoteReleaseClient } from '../../src/command-line/release/utils/remote-release-clients/remote-release-client';
GithubRepoData,
formatReferences,
} from '../../src/command-line/release/utils/github';
// axios types and values don't seem to match
import _axios = require('axios');
const axios = _axios as any as (typeof _axios)['default'];
/** /**
* The ChangelogRenderOptions are specific to each ChangelogRenderer implementation, and are taken * The ChangelogRenderOptions are specific to each ChangelogRenderer implementation, and are taken
@ -42,7 +35,7 @@ export interface DefaultChangelogRenderOptions extends ChangelogRenderOptions {
* using https://ungh.cc (from https://github.com/unjs/ungh) and the email addresses found in the commits. * using https://ungh.cc (from https://github.com/unjs/ungh) and the email addresses found in the commits.
* Defaults to true. * Defaults to true.
*/ */
mapAuthorsToGitHubUsernames?: boolean; applyUsernameToAuthors?: boolean;
/** /**
* Whether or not the commit references (such as commit and/or PR links) should be included in the changelog. * Whether or not the commit references (such as commit and/or PR links) should be included in the changelog.
* Defaults to true. * Defaults to true.
@ -63,13 +56,13 @@ export default class DefaultChangelogRenderer {
protected changelogRenderOptions: DefaultChangelogRenderOptions; protected changelogRenderOptions: DefaultChangelogRenderOptions;
protected isVersionPlans: boolean; protected isVersionPlans: boolean;
protected dependencyBumps?: DependencyBump[]; protected dependencyBumps?: DependencyBump[];
protected repoData?: GithubRepoData;
protected conventionalCommitsConfig: protected conventionalCommitsConfig:
| NxReleaseConfig['conventionalCommits'] | NxReleaseConfig['conventionalCommits']
| null; | null;
protected relevantChanges: ChangelogChange[]; protected relevantChanges: ChangelogChange[];
protected breakingChanges: string[]; protected breakingChanges: string[];
protected additionalChangesForAuthorsSection: ChangelogChange[]; protected additionalChangesForAuthorsSection: ChangelogChange[];
protected remoteReleaseClient: RemoteReleaseClient<unknown>;
/** /**
* A ChangelogRenderer class takes in the determined changes and other relevant metadata * A ChangelogRenderer class takes in the determined changes and other relevant metadata
@ -83,8 +76,8 @@ export default class DefaultChangelogRenderer {
* @param {boolean} config.isVersionPlans Whether or not Nx release version plans are the source of truth for the changelog entry * @param {boolean} config.isVersionPlans Whether or not Nx release version plans are the source of truth for the changelog entry
* @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 change data * @param {DependencyBump[]} config.dependencyBumps Optional list of additional dependency bumps that occurred as part of the release, outside of the change data
* @param {GithubRepoData} config.repoData Resolved data for the current GitHub repository
* @param {NxReleaseConfig['conventionalCommits'] | null} config.conventionalCommitsConfig The configuration for conventional commits, or null if version plans are being used * @param {NxReleaseConfig['conventionalCommits'] | null} config.conventionalCommitsConfig The configuration for conventional commits, or null if version plans are being used
* @param {RemoteReleaseClient} config.remoteReleaseClient The remote release client to use for formatting references
*/ */
constructor(config: { constructor(config: {
changes: ChangelogChange[]; changes: ChangelogChange[];
@ -94,8 +87,8 @@ export default class DefaultChangelogRenderer {
isVersionPlans: boolean; isVersionPlans: boolean;
changelogRenderOptions: DefaultChangelogRenderOptions; changelogRenderOptions: DefaultChangelogRenderOptions;
dependencyBumps?: DependencyBump[]; dependencyBumps?: DependencyBump[];
repoData?: GithubRepoData;
conventionalCommitsConfig: NxReleaseConfig['conventionalCommits'] | null; conventionalCommitsConfig: NxReleaseConfig['conventionalCommits'] | null;
remoteReleaseClient: RemoteReleaseClient<unknown>;
}) { }) {
this.changes = this.filterChanges(config.changes, config.project); this.changes = this.filterChanges(config.changes, config.project);
this.changelogEntryVersion = config.changelogEntryVersion; this.changelogEntryVersion = config.changelogEntryVersion;
@ -104,8 +97,8 @@ export default class DefaultChangelogRenderer {
this.isVersionPlans = config.isVersionPlans; this.isVersionPlans = config.isVersionPlans;
this.changelogRenderOptions = config.changelogRenderOptions; this.changelogRenderOptions = config.changelogRenderOptions;
this.dependencyBumps = config.dependencyBumps; this.dependencyBumps = config.dependencyBumps;
this.repoData = config.repoData;
this.conventionalCommitsConfig = config.conventionalCommitsConfig; this.conventionalCommitsConfig = config.conventionalCommitsConfig;
this.remoteReleaseClient = config.remoteReleaseClient;
this.relevantChanges = []; this.relevantChanges = [];
this.breakingChanges = []; this.breakingChanges = [];
@ -341,7 +334,10 @@ export default class DefaultChangelogRenderer {
protected async renderAuthors(): Promise<string[]> { protected async renderAuthors(): Promise<string[]> {
const markdownLines: string[] = []; const markdownLines: string[] = [];
const _authors = new Map<string, { email: Set<string>; github?: string }>(); const _authors = new Map<
string,
{ email: Set<string>; username?: string }
>();
for (const change of [ for (const change of [
...this.relevantChanges, ...this.relevantChanges,
@ -365,34 +361,12 @@ export default class DefaultChangelogRenderer {
} }
if ( if (
this.repoData && this.remoteReleaseClient.getRemoteRepoData() &&
this.changelogRenderOptions.mapAuthorsToGitHubUsernames this.changelogRenderOptions.applyUsernameToAuthors &&
// TODO: Explore if it is possible to support GitLab username resolution
this.remoteReleaseClient.remoteReleaseProviderName === 'GitHub'
) { ) {
await Promise.all( await this.remoteReleaseClient.applyUsernameToAuthors(_authors);
[..._authors.keys()].map(async (authorName) => {
const meta = _authors.get(authorName);
for (const email of meta.email) {
if (email.endsWith('@users.noreply.github.com')) {
const match = email.match(
/^(\d+\+)?([^@]+)@users\.noreply\.github\.com$/
);
if (match && match[2]) {
meta.github = match[2];
break;
}
}
const { data } = await axios
.get<any, { data?: { user?: { username: string } } }>(
`https://ungh.cc/users/find/${email}`
)
.catch(() => ({ data: { user: null } }));
if (data?.user) {
meta.github = data.user.username;
break;
}
}
})
);
} }
const authors = [..._authors.entries()].map((e) => ({ const authors = [..._authors.entries()].map((e) => ({
@ -408,8 +382,8 @@ export default class DefaultChangelogRenderer {
...authors ...authors
.sort((a, b) => a.name.localeCompare(b.name)) .sort((a, b) => a.name.localeCompare(b.name))
.map((i) => { .map((i) => {
const github = i.github ? ` @${i.github}` : ''; const username = i.username ? ` @${i.username}` : '';
return `- ${i.name}${github}`; return `- ${i.name}${username}`;
}) })
); );
} }
@ -437,8 +411,13 @@ export default class DefaultChangelogRenderer {
? `**${change.scope.trim()}:** ` ? `**${change.scope.trim()}:** `
: '') + : '') +
description; description;
if (this.repoData && this.changelogRenderOptions.commitReferences) { if (
changeLine += formatReferences(change.githubReferences, this.repoData); this.remoteReleaseClient.getRemoteRepoData() &&
this.changelogRenderOptions.commitReferences
) {
changeLine += this.remoteReleaseClient.formatReferences(
change.githubReferences
);
} }
if (extraLinesStr) { if (extraLinesStr) {
changeLine += '\n\n' + extraLinesStr; changeLine += '\n\n' + extraLinesStr;

View File

@ -854,7 +854,7 @@
"oneOf": [ "oneOf": [
{ {
"type": "string", "type": "string",
"enum": ["github"] "enum": ["github", "gitlab"]
}, },
{ {
"type": "boolean", "type": "boolean",
@ -900,7 +900,7 @@
"properties": { "properties": {
"provider": { "provider": {
"type": "string", "type": "string",
"enum": ["github-enterprise-server"] "enum": ["github-enterprise-server", "gitlab"]
}, },
"hostname": { "hostname": {
"type": "string", "type": "string",
@ -908,7 +908,7 @@
}, },
"apiBaseUrl": { "apiBaseUrl": {
"type": "string", "type": "string",
"description": "The base URL for the relevant VCS provider API. If not set, this will default to `https://${hostname}/api/v3`" "description": "The base URL for the relevant VCS provider API. If not set, this will default to `https://${hostname}/api/v3` (github) or `https://${hostname}/api/v4` (gitlab)."
} }
}, },
"required": ["provider", "hostname"] "required": ["provider", "hostname"]

View File

@ -8,7 +8,6 @@ import { NxReleaseConfiguration, readNxJson } from '../../config/nx-json';
import { import {
FileData, FileData,
ProjectFileMap, ProjectFileMap,
ProjectGraph,
ProjectGraphProjectNode, ProjectGraphProjectNode,
} from '../../config/project-graph'; } from '../../config/project-graph';
import { FsTree, Tree } from '../../generators/tree'; import { FsTree, Tree } from '../../generators/tree';
@ -26,8 +25,8 @@ import { workspaceRoot } from '../../utils/workspace-root';
import { ChangelogOptions } from './command-object'; import { ChangelogOptions } from './command-object';
import { import {
NxReleaseConfig, NxReleaseConfig,
ResolvedCreateRemoteReleaseProvider,
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';
@ -55,11 +54,11 @@ import {
parseCommits, parseCommits,
parseGitCommit, parseGitCommit,
} from './utils/git'; } from './utils/git';
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';
import { printConfigAndExit } from './utils/print-config'; import { printConfigAndExit } from './utils/print-config';
import { createRemoteReleaseClient } from './utils/remote-release-clients/remote-release-client';
import { resolveChangelogRenderer } from './utils/resolve-changelog-renderer'; import { resolveChangelogRenderer } from './utils/resolve-changelog-renderer';
import { resolveNxJsonConfigErrorMessage } from './utils/resolve-nx-json-error-message'; import { resolveNxJsonConfigErrorMessage } from './utils/resolve-nx-json-error-message';
import { import {
@ -76,11 +75,13 @@ export interface NxReleaseChangelogResult {
workspaceChangelog?: { workspaceChangelog?: {
releaseVersion: ReleaseVersion; releaseVersion: ReleaseVersion;
contents: string; contents: string;
postGitTask: PostGitTask | null;
}; };
projectChangelogs?: { projectChangelogs?: {
[projectName: string]: { [projectName: string]: {
releaseVersion: ReleaseVersion; releaseVersion: ReleaseVersion;
contents: string; contents: string;
postGitTask: PostGitTask | null;
}; };
}; };
} }
@ -98,7 +99,7 @@ export interface ChangelogChange {
revertedHashes?: string[]; revertedHashes?: string[];
} }
type PostGitTask = (latestCommit: string) => Promise<void>; export type PostGitTask = (latestCommit: string) => Promise<void>;
export const releaseChangelogCLIHandler = (args: ChangelogOptions) => export const releaseChangelogCLIHandler = (args: ChangelogOptions) =>
handleErrors(args.verbose, () => createAPI({})(args)); handleErrors(args.verbose, () => createAPI({})(args));
@ -392,37 +393,14 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) {
const workspaceChangelog = await generateChangelogForWorkspace({ const workspaceChangelog = await generateChangelogForWorkspace({
tree, tree,
args, args,
projectGraph,
nxReleaseConfig, nxReleaseConfig,
workspaceChangelogVersion, workspaceChangelogVersion,
changes: workspaceChangelogChanges, changes: workspaceChangelogChanges,
// TODO(v22): remove this after the changelog renderer is refactored to remove coupling with git commits
commits: filterHiddenCommits(
workspaceChangelogCommits,
nxReleaseConfig.conventionalCommits
),
}); });
if ( // Add the post git task (e.g. create a remote release) for the workspace changelog, if applicable
workspaceChangelog && if (workspaceChangelog && workspaceChangelog.postGitTask) {
shouldCreateGitHubRelease( postGitTasks.push(workspaceChangelog.postGitTask);
nxReleaseConfig.changelog.workspaceChangelog,
args.createRelease
)
) {
postGitTasks.push(async (latestCommit) => {
output.logSingleLine(`Creating GitHub Release`);
await createOrUpdateGithubRelease(
nxReleaseConfig.changelog.workspaceChangelog
? nxReleaseConfig.changelog.workspaceChangelog.createRelease
: defaultCreateReleaseProvider,
workspaceChangelog.releaseVersion,
workspaceChangelog.contents,
latestCommit,
{ dryRun: args.dryRun }
);
});
} }
/** /**
@ -620,33 +598,18 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) {
projectToAdditionalDependencyBumps, projectToAdditionalDependencyBumps,
}); });
if (projectChangelogs) {
for (const [projectName, projectChangelog] of Object.entries( for (const [projectName, projectChangelog] of Object.entries(
projectChangelogs projectChangelogs
)) { )) {
if ( // Add the post git task (e.g. create a remote release) for the project changelog, if applicable
projectChangelogs && if (projectChangelog.postGitTask) {
shouldCreateGitHubRelease( postGitTasks.push(projectChangelog.postGitTask);
releaseGroup.changelog,
args.createRelease
)
) {
postGitTasks.push(async (latestCommit) => {
output.logSingleLine(`Creating GitHub Release`);
await createOrUpdateGithubRelease(
releaseGroup.changelog
? releaseGroup.changelog.createRelease
: defaultCreateReleaseProvider,
projectChangelog.releaseVersion,
projectChangelog.contents,
latestCommit,
{ dryRun: args.dryRun }
);
});
} }
allProjectChangelogs[projectName] = projectChangelog; allProjectChangelogs[projectName] = projectChangelog;
} }
} }
}
} else { } else {
let changes: ChangelogChange[] = []; let changes: ChangelogChange[] = [];
// TODO(v22): remove this after the changelog renderer is refactored to remove coupling with git commits // TODO(v22): remove this after the changelog renderer is refactored to remove coupling with git commits
@ -765,34 +728,19 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) {
projectToAdditionalDependencyBumps, projectToAdditionalDependencyBumps,
}); });
if (projectChangelogs) {
for (const [projectName, projectChangelog] of Object.entries( for (const [projectName, projectChangelog] of Object.entries(
projectChangelogs projectChangelogs
)) { )) {
if ( // Add the post git task (e.g. create a remote release) for the project changelog, if applicable
projectChangelogs && if (projectChangelog.postGitTask) {
shouldCreateGitHubRelease( postGitTasks.push(projectChangelog.postGitTask);
releaseGroup.changelog,
args.createRelease
)
) {
postGitTasks.push(async (latestCommit) => {
output.logSingleLine(`Creating GitHub Release`);
await createOrUpdateGithubRelease(
releaseGroup.changelog
? releaseGroup.changelog.createRelease
: defaultCreateReleaseProvider,
projectChangelog.releaseVersion,
projectChangelog.contents,
latestCommit,
{ dryRun: args.dryRun }
);
});
} }
allProjectChangelogs[projectName] = projectChangelog; allProjectChangelogs[projectName] = projectChangelog;
} }
} }
} }
}
await applyChangesAndExit( await applyChangesAndExit(
args, args,
@ -873,6 +821,20 @@ function resolveChangelogVersions(
}; };
} }
// Can be overridden to something more specific as we resolve the remote release client within nested logic
let remoteReleaseProviderName: undefined | string;
// If already set, and not the same as the remote release client, append
function applyRemoteReleaseProviderName(newRemoteReleaseProviderName: string) {
if (remoteReleaseProviderName) {
if (remoteReleaseProviderName !== newRemoteReleaseProviderName) {
remoteReleaseProviderName = `${remoteReleaseProviderName}/${newRemoteReleaseProviderName}`;
}
} else {
remoteReleaseProviderName = newRemoteReleaseProviderName;
}
}
async function applyChangesAndExit( async function applyChangesAndExit(
args: ChangelogOptions, args: ChangelogOptions,
nxReleaseConfig: NxReleaseConfig, nxReleaseConfig: NxReleaseConfig,
@ -902,22 +864,25 @@ async function applyChangesAndExit(
}); });
if (!postGitTasks.length) { if (!postGitTasks.length) {
// no GitHub releases to create so we can just exit // No post git tasks (e.g. remote release creation) to perform so we can just exit
return; return;
} }
if (isCI()) { if (isCI()) {
output.warn({ output.warn({
title: `Skipped GitHub release creation because no changes were detected for any changelog files.`, title: `Skipped ${
remoteReleaseProviderName ?? 'remote'
} release creation because no changes were detected for any changelog files.`,
}); });
return; return;
} }
// prompt the user to see if they want to create a GitHub release anyway /**
// we know that the user has configured GitHub releases because we have postGitTasks * Prompt the user to see if they want to create a remote release anyway.
const shouldCreateGitHubReleaseAnyway = await promptForGitHubRelease(); * We know that the user has configured remote releases because we have postGitTasks.
*/
if (!shouldCreateGitHubReleaseAnyway) { const shouldCreateRemoteReleaseAnyway = await promptForRemoteRelease();
if (!shouldCreateRemoteReleaseAnyway) {
return; return;
} }
@ -1020,20 +985,16 @@ async function applyChangesAndExit(
async function generateChangelogForWorkspace({ async function generateChangelogForWorkspace({
tree, tree,
args, args,
projectGraph,
nxReleaseConfig, nxReleaseConfig,
workspaceChangelogVersion, workspaceChangelogVersion,
changes, changes,
commits,
}: { }: {
tree: Tree; tree: Tree;
args: ChangelogOptions; args: ChangelogOptions;
projectGraph: ProjectGraph;
nxReleaseConfig: NxReleaseConfig; nxReleaseConfig: NxReleaseConfig;
workspaceChangelogVersion: (string | null) | undefined; workspaceChangelogVersion: (string | null) | undefined;
changes: ChangelogChange[]; changes: ChangelogChange[];
commits: GitCommit[]; }): Promise<NxReleaseChangelogResult['workspaceChangelog'] | undefined> {
}): 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) {
@ -1108,17 +1069,23 @@ async function generateChangelogForWorkspace({
}); });
} }
const githubRepoData = getGitHubRepoData(gitRemote, config.createRelease); const remoteReleaseClient = await createRemoteReleaseClient(
config.createRelease as unknown as
| false
| ResolvedCreateRemoteReleaseProvider,
gitRemote
);
applyRemoteReleaseProviderName(remoteReleaseClient.remoteReleaseProviderName);
const changelogRenderer = new ChangelogRendererClass({ const changelogRenderer = new ChangelogRendererClass({
changes, changes,
changelogEntryVersion: releaseVersion.rawVersion, changelogEntryVersion: releaseVersion.rawVersion,
project: null, project: null,
isVersionPlans: false, isVersionPlans: false,
repoData: githubRepoData,
entryWhenNoChanges: config.entryWhenNoChanges, entryWhenNoChanges: config.entryWhenNoChanges,
changelogRenderOptions: config.renderOptions, changelogRenderOptions: config.renderOptions,
conventionalCommitsConfig: nxReleaseConfig.conventionalCommits, conventionalCommitsConfig: nxReleaseConfig.conventionalCommits,
remoteReleaseClient,
}); });
let contents = await changelogRenderer.render(); let contents = await changelogRenderer.render();
@ -1170,9 +1137,15 @@ async function generateChangelogForWorkspace({
printAndFlushChanges(tree, !!dryRun, 3, false, noDiffInChangelogMessage); printAndFlushChanges(tree, !!dryRun, 3, false, noDiffInChangelogMessage);
} }
const postGitTask: PostGitTask | null =
args.createRelease !== false && config.createRelease
? remoteReleaseClient.createPostGitTask(releaseVersion, contents, dryRun)
: null;
return { return {
releaseVersion, releaseVersion,
contents, contents,
postGitTask,
}; };
} }
@ -1194,7 +1167,7 @@ async function generateChangelogForProjects({
projects: ProjectGraphProjectNode[]; projects: ProjectGraphProjectNode[];
nxReleaseConfig: NxReleaseConfig; nxReleaseConfig: NxReleaseConfig;
projectToAdditionalDependencyBumps: Map<string, DependencyBump[]>; projectToAdditionalDependencyBumps: Map<string, DependencyBump[]>;
}): Promise<NxReleaseChangelogResult['projectChangelogs']> { }): Promise<NxReleaseChangelogResult['projectChangelogs'] | undefined> {
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) {
@ -1209,6 +1182,15 @@ async function generateChangelogForProjects({
const ChangelogRendererClass = resolveChangelogRenderer(config.renderer); const ChangelogRendererClass = resolveChangelogRenderer(config.renderer);
// Maximum of one remote release client per release group
const remoteReleaseClient = await createRemoteReleaseClient(
config.createRelease as unknown as
| false
| ResolvedCreateRemoteReleaseProvider,
gitRemote
);
applyRemoteReleaseProviderName(remoteReleaseClient.remoteReleaseProviderName);
const projectChangelogs: NxReleaseChangelogResult['projectChangelogs'] = {}; const projectChangelogs: NxReleaseChangelogResult['projectChangelogs'] = {};
for (const project of projects) { for (const project of projects) {
@ -1247,13 +1229,10 @@ async function generateChangelogForProjects({
}); });
} }
const githubRepoData = getGitHubRepoData(gitRemote, config.createRelease);
const changelogRenderer = new ChangelogRendererClass({ const changelogRenderer = new ChangelogRendererClass({
changes, changes,
changelogEntryVersion: releaseVersion.rawVersion, changelogEntryVersion: releaseVersion.rawVersion,
project: project.name, project: project.name,
repoData: githubRepoData,
entryWhenNoChanges: entryWhenNoChanges:
typeof config.entryWhenNoChanges === 'string' typeof config.entryWhenNoChanges === 'string'
? interpolate(config.entryWhenNoChanges, { ? interpolate(config.entryWhenNoChanges, {
@ -1268,6 +1247,7 @@ async function generateChangelogForProjects({
? null ? null
: nxReleaseConfig.conventionalCommits, : nxReleaseConfig.conventionalCommits,
dependencyBumps: projectToAdditionalDependencyBumps.get(project.name), dependencyBumps: projectToAdditionalDependencyBumps.get(project.name),
remoteReleaseClient,
}); });
let contents = await changelogRenderer.render(); let contents = await changelogRenderer.render();
@ -1326,9 +1306,19 @@ async function generateChangelogForProjects({
); );
} }
const postGitTask: PostGitTask | null =
args.createRelease !== false && config.createRelease
? remoteReleaseClient.createPostGitTask(
releaseVersion,
contents,
dryRun
)
: null;
projectChangelogs[project.name] = { projectChangelogs[project.name] = {
releaseVersion, releaseVersion,
contents, contents,
postGitTask,
}; };
} }
@ -1375,48 +1365,14 @@ function filterHiddenChanges(
}); });
} }
// TODO(v22): remove this after the changelog renderer is refactored to remove coupling with git commits async function promptForRemoteRelease(): Promise<boolean> {
function filterHiddenCommits(
commits: GitCommit[],
conventionalCommitsConfig: NxReleaseConfig['conventionalCommits']
): GitCommit[] {
if (!commits) {
return [];
}
return commits.filter((commit) => {
const type = commit.type;
const typeConfig = conventionalCommitsConfig.types[type];
if (!typeConfig) {
// don't include commits with unknown types
return false;
}
return !typeConfig.changelog.hidden;
});
}
export function shouldCreateGitHubRelease(
changelogConfig:
| NxReleaseConfig['changelog']['workspaceChangelog']
| NxReleaseConfig['changelog']['projectChangelogs']
| NxReleaseConfig['groups'][number]['changelog'],
createReleaseArg: ChangelogOptions['createRelease'] | undefined = undefined
): boolean {
if (createReleaseArg !== undefined) {
return createReleaseArg === 'github';
}
if (changelogConfig === false) {
return false;
}
return changelogConfig.createRelease !== false;
}
async function promptForGitHubRelease(): Promise<boolean> {
try { try {
const result = await prompt<{ confirmation: boolean }>([ const result = await prompt<{ confirmation: boolean }>([
{ {
name: 'confirmation', name: 'confirmation',
message: 'Do you want to create a GitHub release anyway?', message: `Do you want to create a ${
remoteReleaseProviderName ?? 'remote'
} release anyway?`,
type: 'confirm', type: 'confirm',
}, },
]); ]);

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -28,6 +28,8 @@ import { output } from '../../../utils/output';
import { PackageJson } from '../../../utils/package-json'; import { PackageJson } from '../../../utils/package-json';
import { normalizePath } from '../../../utils/path'; import { normalizePath } from '../../../utils/path';
import { workspaceRoot } from '../../../utils/workspace-root'; import { workspaceRoot } from '../../../utils/workspace-root';
import { defaultCreateReleaseProvider as defaultGitHubCreateReleaseProvider } from '../utils/remote-release-clients/github';
import { defaultCreateReleaseProvider as defaultGitLabCreateReleaseProvider } from '../utils/remote-release-clients/gitlab';
import { resolveChangelogRenderer } from '../utils/resolve-changelog-renderer'; import { resolveChangelogRenderer } from '../utils/resolve-changelog-renderer';
import { resolveNxJsonConfigErrorMessage } from '../utils/resolve-nx-json-error-message'; import { resolveNxJsonConfigErrorMessage } from '../utils/resolve-nx-json-error-message';
import { DEFAULT_CONVENTIONAL_COMMITS_CONFIG } from './conventional-commits'; import { DEFAULT_CONVENTIONAL_COMMITS_CONFIG } from './conventional-commits';
@ -308,7 +310,7 @@ export async function createNxReleaseConfig(
renderer: defaultRendererPath, renderer: defaultRendererPath,
renderOptions: { renderOptions: {
authors: true, authors: true,
mapAuthorsToGitHubUsernames: true, applyUsernameToAuthors: true,
commitReferences: true, commitReferences: true,
versionTitleDate: true, versionTitleDate: true,
}, },
@ -323,7 +325,7 @@ export async function createNxReleaseConfig(
renderer: defaultRendererPath, renderer: defaultRendererPath,
renderOptions: { renderOptions: {
authors: true, authors: true,
mapAuthorsToGitHubUsernames: true, applyUsernameToAuthors: true,
commitReferences: true, commitReferences: true,
versionTitleDate: true, versionTitleDate: true,
}, },
@ -374,7 +376,7 @@ export async function createNxReleaseConfig(
renderer: defaultRendererPath, renderer: defaultRendererPath,
renderOptions: { renderOptions: {
authors: true, authors: true,
mapAuthorsToGitHubUsernames: true, applyUsernameToAuthors: true,
commitReferences: true, commitReferences: true,
versionTitleDate: true, versionTitleDate: true,
}, },
@ -1276,14 +1278,20 @@ const supportedCreateReleaseProviders = [
name: 'github-enterprise-server', name: 'github-enterprise-server',
defaultApiBaseUrl: 'https://__hostname__/api/v3', defaultApiBaseUrl: 'https://__hostname__/api/v3',
}, },
{
name: 'gitlab',
defaultApiBaseUrl: 'https://__hostname__/api/v4',
},
]; ];
// User opts into the default by specifying the string value 'github' /**
export const defaultCreateReleaseProvider = { * Full form of the createRelease config, with the provider, hostname, and apiBaseUrl resolved.
provider: 'github', */
hostname: 'github.com', export interface ResolvedCreateRemoteReleaseProvider {
apiBaseUrl: 'https://api.github.com', provider: string;
} as any; hostname: string;
apiBaseUrl: string;
}
function validateCreateReleaseConfig( function validateCreateReleaseConfig(
changelogConfig: NxReleaseChangelogConfiguration changelogConfig: NxReleaseChangelogConfiguration
@ -1295,7 +1303,14 @@ function validateCreateReleaseConfig(
} }
// GitHub shorthand, expand to full object form, mark as valid // GitHub shorthand, expand to full object form, mark as valid
if (createRelease === 'github') { if (createRelease === 'github') {
changelogConfig.createRelease = defaultCreateReleaseProvider; changelogConfig.createRelease =
defaultGitHubCreateReleaseProvider as unknown as NxReleaseChangelogConfiguration['createRelease'];
return null;
}
// Gitlab shorthand, expand to full object form, mark as valid
if (createRelease === 'gitlab') {
changelogConfig.createRelease =
defaultGitLabCreateReleaseProvider as unknown as NxReleaseChangelogConfiguration['createRelease'];
return null; return null;
} }
// Object config, ensure that properties are valid // Object config, ensure that properties are valid

View File

@ -5,13 +5,12 @@ import { createProjectFileMapUsingProjectGraph } from '../../project-graph/file-
import { createProjectGraphAsync } from '../../project-graph/project-graph'; import { createProjectGraphAsync } from '../../project-graph/project-graph';
import { handleErrors } from '../../utils/handle-errors'; import { handleErrors } from '../../utils/handle-errors';
import { output } from '../../utils/output'; import { output } from '../../utils/output';
import { import { createAPI as createReleaseChangelogAPI } from './changelog';
createAPI as createReleaseChangelogAPI,
shouldCreateGitHubRelease,
} from './changelog';
import { ReleaseOptions, VersionOptions } from './command-object'; import { ReleaseOptions, VersionOptions } from './command-object';
import { import {
IMPLICIT_DEFAULT_RELEASE_GROUP, IMPLICIT_DEFAULT_RELEASE_GROUP,
NxReleaseConfig,
ResolvedCreateRemoteReleaseProvider,
createNxReleaseConfig, createNxReleaseConfig,
handleNxReleaseConfigError, handleNxReleaseConfigError,
} from './config/config'; } from './config/config';
@ -24,8 +23,8 @@ import {
} from './config/version-plans'; } from './config/version-plans';
import { createAPI as createReleasePublishAPI } from './publish'; import { createAPI as createReleasePublishAPI } from './publish';
import { getCommitHash, gitAdd, gitCommit, gitPush, gitTag } from './utils/git'; import { getCommitHash, gitAdd, gitCommit, gitPush, gitTag } from './utils/git';
import { createOrUpdateGithubRelease } from './utils/github';
import { printConfigAndExit } from './utils/print-config'; import { printConfigAndExit } from './utils/print-config';
import { createRemoteReleaseClient } from './utils/remote-release-clients/remote-release-client';
import { resolveNxJsonConfigErrorMessage } from './utils/resolve-nx-json-error-message'; import { resolveNxJsonConfigErrorMessage } from './utils/resolve-nx-json-error-message';
import { import {
createCommitMessageValues, createCommitMessageValues,
@ -137,14 +136,14 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) {
(shouldCommit || userProvidedReleaseConfig.git?.stageChanges) ?? false; (shouldCommit || userProvidedReleaseConfig.git?.stageChanges) ?? false;
const shouldTag = userProvidedReleaseConfig.git?.tag ?? true; const shouldTag = userProvidedReleaseConfig.git?.tag ?? true;
const shouldCreateWorkspaceRelease = shouldCreateGitHubRelease( const shouldCreateWorkspaceRemoteRelease = shouldCreateRemoteRelease(
nxReleaseConfig.changelog.workspaceChangelog nxReleaseConfig.changelog.workspaceChangelog
); );
// If the workspace or any of the release groups specify that a github release should be created, we need to push the changes to the remote // If the workspace or any of the release groups specify that a remote release should be created, we need to push the changes to the remote
const shouldPush = const shouldPush =
(shouldCreateWorkspaceRelease || (shouldCreateWorkspaceRemoteRelease ||
releaseGroups.some((group) => releaseGroups.some((group) =>
shouldCreateGitHubRelease(group.changelog) shouldCreateRemoteRelease(group.changelog)
)) ?? )) ??
false; false;
@ -268,20 +267,28 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) {
let latestCommit: string | undefined; let latestCommit: string | undefined;
if (shouldCreateWorkspaceRelease && changelogResult.workspaceChangelog) { if (
shouldCreateWorkspaceRemoteRelease &&
changelogResult.workspaceChangelog
) {
const remoteReleaseClient = await createRemoteReleaseClient(
// shouldCreateWorkspaceRemoteRelease() ensures that the createRelease property exists and is not false
(nxReleaseConfig.changelog.workspaceChangelog as any)
.createRelease as ResolvedCreateRemoteReleaseProvider
);
if (!hasPushedChanges) { if (!hasPushedChanges) {
throw new Error( throw new Error(
'It is not possible to create a github release for the workspace without pushing the changes to the remote, please ensure that you have not disabled git push in your nx release config' `It is not possible to create a ${remoteReleaseClient.remoteReleaseProviderName} release for the workspace without pushing the changes to the remote, please ensure that you have not disabled git push in your nx release config`
); );
} }
output.logSingleLine(`Creating GitHub Release`); output.logSingleLine(
`Creating ${remoteReleaseClient.remoteReleaseProviderName} Release`
);
latestCommit = await getCommitHash('HEAD'); latestCommit = await getCommitHash('HEAD');
await createOrUpdateGithubRelease(
nxReleaseConfig.changelog.workspaceChangelog await remoteReleaseClient.createOrUpdateRelease(
? nxReleaseConfig.changelog.workspaceChangelog.createRelease
: false,
changelogResult.workspaceChangelog.releaseVersion, changelogResult.workspaceChangelog.releaseVersion,
changelogResult.workspaceChangelog.contents, changelogResult.workspaceChangelog.contents,
latestCommit, latestCommit,
@ -290,11 +297,19 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) {
} }
for (const releaseGroup of releaseGroups) { for (const releaseGroup of releaseGroups) {
const shouldCreateProjectReleases = shouldCreateGitHubRelease( const shouldCreateProjectRemoteReleases = shouldCreateRemoteRelease(
releaseGroup.changelog releaseGroup.changelog
); );
if (
shouldCreateProjectRemoteReleases &&
changelogResult.projectChangelogs
) {
const remoteReleaseClient = await createRemoteReleaseClient(
// shouldCreateProjectRemoteReleases() ensures that the createRelease property exists and is not false
(releaseGroup.changelog as any)
.createRelease as ResolvedCreateRemoteReleaseProvider
);
if (shouldCreateProjectReleases && changelogResult.projectChangelogs) {
const projects = args.projects?.length 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 ? // 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)) Array.from(releaseGroupToFilteredProjects.get(releaseGroup))
@ -310,20 +325,19 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) {
if (!hasPushedChanges) { if (!hasPushedChanges) {
throw new Error( throw new Error(
'It is not possible to create a github release for the project without pushing the changes to the remote, please ensure that you have not disabled git push in your nx release config' `It is not possible to create a ${remoteReleaseClient.remoteReleaseProviderName} release for the project without pushing the changes to the remote, please ensure that you have not disabled git push in your nx release config`
); );
} }
output.logSingleLine(`Creating GitHub Release`); output.logSingleLine(
`Creating ${remoteReleaseClient.remoteReleaseProviderName} Release`
);
if (!latestCommit) { if (!latestCommit) {
latestCommit = await getCommitHash('HEAD'); latestCommit = await getCommitHash('HEAD');
} }
await createOrUpdateGithubRelease( await remoteReleaseClient.createOrUpdateRelease(
releaseGroup.changelog
? releaseGroup.changelog.createRelease
: false,
changelog.releaseVersion, changelog.releaseVersion,
changelog.contents, changelog.contents,
latestCommit, latestCommit,
@ -376,3 +390,15 @@ async function promptForPublish(): Promise<boolean> {
return false; return false;
} }
} }
function shouldCreateRemoteRelease(
changelogConfig:
| NxReleaseConfig['changelog']['workspaceChangelog']
| NxReleaseConfig['changelog']['projectChangelogs']
| NxReleaseConfig['groups'][number]['changelog']
): boolean {
if (changelogConfig === false) {
return false;
}
return changelogConfig.createRelease !== false;
}

View File

@ -1,4 +1,4 @@
import { getLatestGitTagForPattern } from './git'; import { extractReferencesFromCommit, getLatestGitTagForPattern } from './git';
jest.mock('./exec-command', () => ({ jest.mock('./exec-command', () => ({
execCommand: jest.fn(() => execCommand: jest.fn(() =>
@ -20,7 +20,115 @@ my-lib-34.0.0-beta.1
), ),
})); }));
const releaseTagPatternTestCases = [ describe('git utils', () => {
describe('extractReferencesFromCommit', () => {
it('should include the given short commit hash even if no other references are found', () => {
const references = extractReferencesFromCommit({
message: 'test',
body: '',
shortHash: 'abc123',
author: { name: 'Test Author', email: 'test@example.com' },
});
expect(references).toMatchInlineSnapshot(`
[
{
"type": "hash",
"value": "abc123",
},
]
`);
});
it('should match GitHub style issue references', () => {
const references = extractReferencesFromCommit({
message: 'This fixed issue #789',
body: '',
shortHash: 'abc123',
author: { name: 'Test Author', email: 'test@example.com' },
});
expect(references).toMatchInlineSnapshot(`
[
{
"type": "issue",
"value": "#789",
},
{
"type": "hash",
"value": "abc123",
},
]
`);
});
it('should match GitHub style PR references', () => {
const references = extractReferencesFromCommit({
message: 'fix: all the things (#20607)',
body: '',
shortHash: 'abc123',
author: { name: 'Test Author', email: 'test@example.com' },
});
expect(references).toMatchInlineSnapshot(`
[
{
"type": "pull-request",
"value": "#20607",
},
{
"type": "hash",
"value": "abc123",
},
]
`);
});
it('should match GitLab style issue references', () => {
const references = extractReferencesFromCommit({
message: "Merge branch 'mr-to-fix-issue' into 'main'",
body: `fix: this should resolve the gitlab issue
Closes #1`,
shortHash: 'abc123',
author: { name: 'Test Author', email: 'test@example.com' },
});
expect(references).toMatchInlineSnapshot(`
[
{
"type": "issue",
"value": "#1",
},
{
"type": "hash",
"value": "abc123",
},
]
`);
});
it('should match GitLab style merge request references', () => {
const references = extractReferencesFromCommit({
message: "Merge branch 'mr-to-fix-issue' into 'main'",
body: `fix: this should resolve the gitlab issue
See merge request nx-release-test/nx-release-test!2`,
shortHash: 'abc123',
author: { name: 'Test Author', email: 'test@example.com' },
});
expect(references).toMatchInlineSnapshot(`
[
{
"type": "pull-request",
"value": "!2",
},
{
"type": "hash",
"value": "abc123",
},
]
`);
});
});
const releaseTagPatternTestCases = [
{ {
pattern: 'v{version}', pattern: 'v{version}',
projectName: 'my-lib-1', projectName: 'my-lib-1',
@ -81,9 +189,9 @@ const releaseTagPatternTestCases = [
expectedTag: '3.0.0-beta.0-alpha', expectedTag: '3.0.0-beta.0-alpha',
expectedVersion: '3.0.0-beta.0', expectedVersion: '3.0.0-beta.0',
}, },
]; ];
describe('getLatestGitTagForPattern', () => { describe('getLatestGitTagForPattern', () => {
afterEach(() => { afterEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
@ -102,11 +210,11 @@ describe('getLatestGitTagForPattern', () => {
it('should return null if execCommand throws an error', async () => { it('should return null if execCommand throws an error', async () => {
// should return null if execCommand throws an error // should return null if execCommand throws an error
(require('./exec-command').execCommand as jest.Mock).mockImplementationOnce( (
() => { require('./exec-command').execCommand as jest.Mock
).mockImplementationOnce(() => {
throw new Error('error'); throw new Error('error');
} });
);
const result = await getLatestGitTagForPattern('#{version}', { const result = await getLatestGitTagForPattern('#{version}', {
projectName: 'my-lib-1', projectName: 'my-lib-1',
}); });
@ -120,4 +228,5 @@ describe('getLatestGitTagForPattern', () => {
expect(result).toEqual(null); expect(result).toEqual(null);
}); });
});
}); });

View File

@ -488,20 +488,38 @@ export function parseConventionalCommitsMessage(message: string): {
}; };
} }
function extractReferencesFromCommitMessage( export function extractReferencesFromCommit(commit: RawGitCommit): Reference[] {
message: string,
shortHash: string
): Reference[] {
const references: Reference[] = []; const references: Reference[] = [];
for (const m of message.matchAll(PullRequestRE)) {
// Extract GitHub style PR references from commit message
for (const m of commit.message.matchAll(PullRequestRE)) {
references.push({ type: 'pull-request', value: m[1] }); references.push({ type: 'pull-request', value: m[1] });
} }
for (const m of message.matchAll(IssueRE)) {
// Extract GitLab style merge request references from commit body
for (const m of commit.body.matchAll(GitLabMergeRequestRE)) {
if (m[1]) {
references.push({ type: 'pull-request', value: m[1] });
}
}
// Extract issue references from commit message
for (const m of commit.message.matchAll(IssueRE)) {
if (!references.some((i) => i.value === m[1])) { if (!references.some((i) => i.value === m[1])) {
references.push({ type: 'issue', value: m[1] }); references.push({ type: 'issue', value: m[1] });
} }
} }
references.push({ value: shortHash, type: 'hash' });
// Extract issue references from commit body
for (const m of commit.body.matchAll(IssueRE)) {
if (!references.some((i) => i.value === m[1])) {
references.push({ type: 'issue', value: m[1] });
}
}
// Add commit hash reference
references.push({ value: commit.shortHash, type: 'hash' });
return references; return references;
} }
@ -522,7 +540,10 @@ function getAllAuthorsForCommit(commit: RawGitCommit): GitCommitAuthor[] {
const ConventionalCommitRegex = const ConventionalCommitRegex =
/(?<type>[a-z]+)(\((?<scope>.+)\))?(?<breaking>!)?: (?<description>.+)/i; /(?<type>[a-z]+)(\((?<scope>.+)\))?(?<breaking>!)?: (?<description>.+)/i;
const CoAuthoredByRegex = /co-authored-by:\s*(?<name>.+)(<(?<email>.+)>)/gim; const CoAuthoredByRegex = /co-authored-by:\s*(?<name>.+)(<(?<email>.+)>)/gim;
// GitHub style PR references
const PullRequestRE = /\([ a-z]*(#\d+)\s*\)/gm; const PullRequestRE = /\([ a-z]*(#\d+)\s*\)/gm;
// GitLab style merge request references
const GitLabMergeRequestRE = /See merge request (?:[a-z0-9/-]+)?(![\d]+)/gim;
const IssueRE = /(#\d+)/gm; const IssueRE = /(#\d+)/gm;
const ChangedFileRegex = /(A|M|D|R\d*|C\d*)\t([^\t\n]*)\t?(.*)?/gm; const ChangedFileRegex = /(A|M|D|R\d*|C\d*)\t([^\t\n]*)\t?(.*)?/gm;
const RevertHashRE = /This reverts commit (?<hash>[\da-f]{40})./gm; const RevertHashRE = /This reverts commit (?<hash>[\da-f]{40})./gm;
@ -538,10 +559,7 @@ export function parseGitCommit(
description: commit.message, description: commit.message,
type: '', type: '',
scope: '', scope: '',
references: extractReferencesFromCommitMessage( references: extractReferencesFromCommit(commit),
commit.message,
commit.shortHash
),
// The commit message is not the source of truth for a breaking (major) change in version plans, so the value is not relevant // The commit message is not the source of truth for a breaking (major) change in version plans, so the value is not relevant
// TODO(v22): Make the current GitCommit interface more clearly tied to conventional commits // TODO(v22): Make the current GitCommit interface more clearly tied to conventional commits
isBreaking: false, isBreaking: false,
@ -563,13 +581,10 @@ export function parseGitCommit(
parsedMessage.breaking || commit.body.includes('BREAKING CHANGE:'); parsedMessage.breaking || commit.body.includes('BREAKING CHANGE:');
let description = parsedMessage.description; let description = parsedMessage.description;
// Extract references from message // Extract issue and PR references from the commit
const references = extractReferencesFromCommitMessage( const references = extractReferencesFromCommit(commit);
description,
commit.shortHash
);
// Remove references and normalize // Remove GitHub style references from description (NOTE: GitLab style references only seem to appear in the body, so we don't need to remove them here)
description = description.replace(PullRequestRE, '').trim(); description = description.replace(PullRequestRE, '').trim();
let type = parsedMessage.type; let type = parsedMessage.type;

View File

@ -1,493 +0,0 @@
/**
* Special thanks to changelogen for the original inspiration for many of these utilities:
* https://github.com/unjs/changelogen
*/
import type { AxiosRequestConfig } from 'axios';
import * as chalk from 'chalk';
import { prompt } from 'enquirer';
import { execSync } from 'node:child_process';
import { existsSync, promises as fsp } from 'node:fs';
import { homedir } from 'node:os';
import { NxReleaseChangelogConfiguration } from '../../../config/nx-json';
import { output } from '../../../utils/output';
import { joinPathFragments } from '../../../utils/path';
import { defaultCreateReleaseProvider } from '../config/config';
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');
const axios = _axios as any as (typeof _axios)['default'];
export type RepoSlug = `${string}/${string}`;
interface GithubRequestConfig {
repo: string;
hostname: string;
apiBaseUrl: string;
token: string | null;
}
// https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#create-a-release--parameters
interface GithubRelease {
id?: string;
tag_name: string;
target_commitish?: string;
name?: string;
body?: string;
draft?: boolean;
prerelease?: boolean;
make_latest?: 'legacy' | boolean;
}
export interface GithubRepoData {
hostname: string;
slug: RepoSlug;
apiBaseUrl: string;
}
export function getGitHubRepoData(
remoteName = 'origin',
createReleaseConfig: NxReleaseChangelogConfiguration['createRelease']
): GithubRepoData | null {
try {
const remoteUrl = execSync(`git remote get-url ${remoteName}`, {
encoding: 'utf8',
stdio: 'pipe',
}).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
const escapedHostname = hostname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regexString = `${escapedHostname}[/:]([\\w.-]+/[\\w.-]+)(\\.git)?`;
const regex = new RegExp(regexString);
const match = remoteUrl.match(regex);
if (match && match[1]) {
return {
hostname,
apiBaseUrl,
// Ensure any trailing .git is stripped
slug: match[1].replace(/\.git$/, '') as RepoSlug,
};
} else {
throw new Error(
`Could not extract "user/repo" data from the resolved remote URL: ${remoteUrl}`
);
}
} catch (error) {
return null;
}
}
export async function createOrUpdateGithubRelease(
createReleaseConfig: NxReleaseChangelogConfiguration['createRelease'],
releaseVersion: ReleaseVersion,
changelogContents: string,
latestCommit: string,
{ dryRun }: { dryRun: boolean }
): Promise<void> {
const githubRepoData = getGitHubRepoData(undefined, createReleaseConfig);
if (!githubRepoData) {
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(githubRepoData.hostname);
const githubRequestConfig: GithubRequestConfig = {
repo: githubRepoData.slug,
hostname: githubRepoData.hostname,
apiBaseUrl: githubRepoData.apiBaseUrl,
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://${githubRepoData.hostname}/${githubRepoData.slug}/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;
prerelease: boolean;
commit: string;
}
async function createOrUpdateGithubReleaseInternal(
githubRequestConfig: GithubRequestConfig,
release: GithubReleaseOptions,
existingGithubReleaseForVersion?: GithubRelease
) {
const result = await syncGithubRelease(
githubRequestConfig,
release,
existingGithubReleaseForVersion
);
/**
* If something went wrong POSTing to Github we can still pre-populate the web form on github.com
* to allow the user to manually complete the release if they so choose.
*/
if (result.status === 'manual') {
if (result.error) {
process.exitCode = 1;
if (result.error.response?.data) {
// There's a nicely formatted error from GitHub we can display to the user
output.error({
title: `A GitHub API Error occurred when creating/updating the release`,
bodyLines: [
`GitHub Error: ${JSON.stringify(result.error.response.data)}`,
`---`,
`Request Data:`,
`Repo: ${githubRequestConfig.repo}`,
`Token: ${githubRequestConfig.token}`,
`Body: ${JSON.stringify(result.requestData)}`,
],
});
} else {
console.log(result.error);
console.error(
`An unknown error occurred while trying to create a release on GitHub, please report this on https://github.com/nrwl/nx (NOTE: make sure to redact your GitHub token from the error message!)`
);
}
}
const shouldContinueInGitHub = await promptForContinueInGitHub();
if (!shouldContinueInGitHub) {
return;
}
const open = require('open');
await open(result.url)
.then(() => {
console.info(
`\nFollow up in the browser to manually create the release:\n\n` +
chalk.underline(chalk.cyan(result.url)) +
`\n`
);
})
.catch(() => {
console.info(
`Open this link to manually create a release: \n` +
chalk.underline(chalk.cyan(result.url)) +
'\n'
);
});
}
/**
* If something went wrong POSTing to Github we can still pre-populate the web form on github.com
* to allow the user to manually complete the release.
*/
if (result.status === 'manual') {
if (result.error) {
console.error(result.error);
process.exitCode = 1;
}
const open = require('open');
await open(result.url)
.then(() => {
console.info(
`Follow up in the browser to manually create the release.`
);
})
.catch(() => {
console.info(
`Open this link to manually create a release: \n` +
chalk.underline(chalk.cyan(result.url)) +
'\n'
);
});
}
}
async function promptForContinueInGitHub(): Promise<boolean> {
try {
const reply = await prompt<{ open: 'Yes' | 'No' }>([
{
name: 'open',
message:
'Do you want to finish creating the release manually in your browser?',
type: 'autocomplete',
choices: [
{
name: 'Yes',
hint: 'It will pre-populate the form for you',
},
{
name: 'No',
},
],
initial: 0,
},
]);
return reply.open === 'Yes';
} catch {
// Ensure the cursor is always restored before exiting
process.stdout.write('\u001b[?25h');
// Handle the case where the user exits the prompt with ctrl+c
process.exit(1);
}
}
async function syncGithubRelease(
githubRequestConfig: GithubRequestConfig,
release: GithubReleaseOptions,
existingGithubReleaseForVersion?: GithubRelease
) {
const ghRelease: GithubRelease = {
tag_name: release.version,
name: release.version,
body: release.body,
prerelease: release.prerelease,
// legacy specifies that the latest release should be determined based on the release creation date and higher semantic version.
make_latest: 'legacy',
};
try {
const newGhRelease = await (existingGithubReleaseForVersion
? updateGithubRelease(
githubRequestConfig,
existingGithubReleaseForVersion.id,
ghRelease
)
: createGithubRelease(githubRequestConfig, {
...ghRelease,
target_commitish: release.commit,
}));
return {
status: existingGithubReleaseForVersion ? 'updated' : 'created',
id: newGhRelease.id,
url: newGhRelease.html_url,
};
} catch (error) {
return {
status: 'manual',
error,
url: githubNewReleaseURL(githubRequestConfig, release),
requestData: ghRelease,
};
}
}
async function resolveGithubToken(hostname: string): Promise<string | null> {
// Try and resolve from the environment
const tokenFromEnv = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
if (tokenFromEnv) {
return tokenFromEnv;
}
// Try and resolve from gh CLI installation
const ghCLIPath = joinPathFragments(
process.env.XDG_CONFIG_HOME || joinPathFragments(homedir(), '.config'),
'gh',
'hosts.yml'
);
if (existsSync(ghCLIPath)) {
const yamlContents = await fsp.readFile(ghCLIPath, 'utf8');
const { load } = require('@zkochan/js-yaml');
const ghCLIConfig = load(yamlContents);
if (ghCLIConfig[hostname]) {
// Web based session (the token is already embedded in the config)
if (ghCLIConfig[hostname].oauth_token) {
return ghCLIConfig[hostname].oauth_token;
}
// SSH based session (we need to dynamically resolve a token using the CLI)
if (
ghCLIConfig[hostname].user &&
ghCLIConfig[hostname].git_protocol === 'ssh'
) {
return execSync(`gh auth token`, {
encoding: 'utf8',
stdio: 'pipe',
windowsHide: false,
}).trim();
}
}
}
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;
}
export async function getGithubReleaseByTag(
config: GithubRequestConfig,
tag: string
): Promise<GithubRelease> {
return await makeGithubRequest(
config,
`/repos/${config.repo}/releases/tags/${tag}`,
{}
);
}
async function makeGithubRequest(
config: GithubRequestConfig,
url: string,
opts: AxiosRequestConfig = {}
) {
return (
await axios<any, any>(url, {
...opts,
baseURL: config.apiBaseUrl,
headers: {
...(opts.headers as any),
Authorization: config.token ? `Bearer ${config.token}` : undefined,
},
})
).data;
}
async function createGithubRelease(
config: GithubRequestConfig,
body: GithubRelease
) {
return await makeGithubRequest(config, `/repos/${config.repo}/releases`, {
method: 'POST',
data: body,
});
}
async function updateGithubRelease(
config: GithubRequestConfig,
id: string,
body: GithubRelease
) {
return await makeGithubRequest(
config,
`/repos/${config.repo}/releases/${id}`,
{
method: 'PATCH',
data: body,
}
);
}
function githubNewReleaseURL(
config: GithubRequestConfig,
release: GithubReleaseOptions
) {
// Parameters taken from https://github.com/isaacs/github/issues/1410#issuecomment-442240267
let url = `https://${config.hostname}/${config.repo}/releases/new?tag=${
release.version
}&title=${release.version}&body=${encodeURIComponent(release.body)}&target=${
release.commit
}`;
if (release.prerelease) {
url += '&prerelease=true';
}
return url;
}
type RepoProvider = 'github';
const providerToRefSpec: Record<
RepoProvider,
Record<Reference['type'], string>
> = {
github: { 'pull-request': 'pull', hash: 'commit', issue: 'issues' },
};
function formatReference(ref: Reference, repoData: GithubRepoData) {
const refSpec = providerToRefSpec['github'];
return `[${ref.value}](https://${repoData.hostname}/${repoData.slug}/${
refSpec[ref.type]
}/${ref.value.replace(/^#/, '')})`;
}
export function formatReferences(
references: Reference[],
repoData: GithubRepoData
) {
const pr = references.filter((ref) => ref.type === 'pull-request');
const issue = references.filter((ref) => ref.type === 'issue');
if (pr.length > 0 || issue.length > 0) {
return (
' (' +
[...pr, ...issue]
.map((ref) => formatReference(ref, repoData))
.join(', ') +
')'
);
}
if (references.length > 0) {
return ' (' + formatReference(references[0], repoData) + ')';
}
return '';
}

View File

@ -0,0 +1,439 @@
import * as chalk from 'chalk';
import { prompt } from 'enquirer';
import { execSync } from 'node:child_process';
import { existsSync, promises as fsp } from 'node:fs';
import { homedir } from 'node:os';
import { output } from '../../../../utils/output';
import { joinPathFragments } from '../../../../utils/path';
import type { PostGitTask } from '../../changelog';
import { type ResolvedCreateRemoteReleaseProvider } from '../../config/config';
import { Reference } from '../git';
import { ReleaseVersion } from '../shared';
import {
RemoteReleaseClient,
RemoteReleaseOptions,
RemoteReleaseResult,
RemoteRepoData,
RemoteRepoSlug,
} from './remote-release-client';
// axios types and values don't seem to match
import _axios = require('axios');
const axios = _axios as any as (typeof _axios)['default'];
export interface GithubRepoData extends RemoteRepoData {}
// https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#create-a-release--parameters
export interface GithubRemoteRelease {
id?: string;
body: string;
tag_name: string;
target_commitish?: string;
name?: string;
draft?: boolean;
prerelease?: boolean;
make_latest?: 'legacy' | boolean;
}
export const defaultCreateReleaseProvider: ResolvedCreateRemoteReleaseProvider =
{
provider: 'github',
hostname: 'github.com',
apiBaseUrl: 'https://api.github.com',
};
export class GithubRemoteReleaseClient extends RemoteReleaseClient<GithubRemoteRelease> {
remoteReleaseProviderName = 'GitHub';
/**
* Get GitHub repository data from git remote
*/
static resolveRepoData(
createReleaseConfig: false | ResolvedCreateRemoteReleaseProvider,
remoteName = 'origin'
): GithubRepoData | null {
try {
const remoteUrl = execSync(`git remote get-url ${remoteName}`, {
encoding: 'utf8',
stdio: 'pipe',
}).trim();
// Use the default provider 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
const escapedHostname = hostname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regexString = `${escapedHostname}[/:]([\\w.-]+/[\\w.-]+)(\\.git)?`;
const regex = new RegExp(regexString);
const match = remoteUrl.match(regex);
if (match && match[1]) {
return {
hostname,
apiBaseUrl,
// Ensure any trailing .git is stripped
slug: match[1].replace(/\.git$/, '') as RemoteRepoSlug,
};
} else {
throw new Error(
`Could not extract "user/repo" data from the resolved remote URL: ${remoteUrl}`
);
}
} catch (error) {
return null;
}
}
/**
* Resolve a GitHub token from environment variables or gh CLI
*/
static async resolveTokenData(
hostname: string
): Promise<{ token: string; headerName: string } | null> {
// Try and resolve from the environment
const tokenFromEnv = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
if (tokenFromEnv) {
return { token: tokenFromEnv, headerName: 'Authorization' };
}
// Try and resolve from gh CLI installation
const ghCLIPath = joinPathFragments(
process.env.XDG_CONFIG_HOME || joinPathFragments(homedir(), '.config'),
'gh',
'hosts.yml'
);
if (existsSync(ghCLIPath)) {
const yamlContents = await fsp.readFile(ghCLIPath, 'utf8');
const { load } = require('@zkochan/js-yaml');
const ghCLIConfig = load(yamlContents);
if (ghCLIConfig[hostname]) {
// Web based session (the token is already embedded in the config)
if (ghCLIConfig[hostname].oauth_token) {
return ghCLIConfig[hostname].oauth_token;
}
// SSH based session (we need to dynamically resolve a token using the CLI)
if (
ghCLIConfig[hostname].user &&
ghCLIConfig[hostname].git_protocol === 'ssh'
) {
const token = execSync(`gh auth token`, {
encoding: 'utf8',
stdio: 'pipe',
windowsHide: false,
}).trim();
return { token, headerName: 'Authorization' };
}
}
}
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;
}
createPostGitTask(
releaseVersion: ReleaseVersion,
changelogContents: string,
dryRun: boolean
): PostGitTask {
return async (latestCommit: string) => {
output.logSingleLine(`Creating GitHub Release`);
await this.createOrUpdateRelease(
releaseVersion,
changelogContents,
latestCommit,
{ dryRun }
);
};
}
async applyUsernameToAuthors(
authors: Map<string, { email: Set<string>; username?: string }>
): Promise<void> {
await Promise.all(
[...authors.keys()].map(async (authorName) => {
const meta = authors.get(authorName);
for (const email of meta.email) {
if (email.endsWith('@users.noreply.github.com')) {
const match = email.match(
/^(\d+\+)?([^@]+)@users\.noreply\.github\.com$/
);
if (match && match[2]) {
meta.username = match[2];
break;
}
}
const { data } = await axios
.get<any, { data?: { user?: { username: string } } }>(
`https://ungh.cc/users/find/${email}`
)
.catch(() => ({ data: { user: null } }));
if (data?.user) {
meta.username = data.user.username;
break;
}
}
})
);
}
/**
* Get a release by tag
*/
protected async getReleaseByTag(tag: string): Promise<GithubRemoteRelease> {
const githubRepoData = this.getRequiredRemoteRepoData();
return await this.makeRequest(
`/repos/${githubRepoData.slug}/releases/tags/${tag}`
);
}
/**
* Create a new release
*/
protected async createRelease(
remoteRelease: GithubRemoteRelease
): Promise<any> {
const githubRepoData = this.getRequiredRemoteRepoData();
return await this.makeRequest(`/repos/${githubRepoData.slug}/releases`, {
method: 'POST',
data: remoteRelease,
});
}
protected async updateRelease(
id: string,
remoteRelease: GithubRemoteRelease
): Promise<any> {
const githubRepoData = this.getRequiredRemoteRepoData();
return await this.makeRequest(
`/repos/${githubRepoData.slug}/releases/${id}`,
{
method: 'PATCH',
data: remoteRelease,
}
);
}
protected getManualRemoteReleaseURL(
remoteReleaseOptions: RemoteReleaseOptions
): string {
const githubRepoData = this.getRequiredRemoteRepoData();
// Parameters taken from https://github.com/isaacs/github/issues/1410#issuecomment-442240267
let url = `https://${githubRepoData.hostname}/${
githubRepoData.slug
}/releases/new?tag=${remoteReleaseOptions.version}&title=${
remoteReleaseOptions.version
}&body=${encodeURIComponent(remoteReleaseOptions.body)}&target=${
remoteReleaseOptions.commit
}`;
if (remoteReleaseOptions.prerelease) {
url += '&prerelease=true';
}
return url;
}
protected handleAuthError(): void {
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',
],
});
}
protected logReleaseAction(
existingRelease: GithubRemoteRelease | undefined,
gitTag: string,
dryRun: boolean
): void {
const githubRepoData = this.getRequiredRemoteRepoData();
const logTitle = `https://${githubRepoData.hostname}/${githubRepoData.slug}/releases/tag/${gitTag}`;
if (existingRelease) {
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]') : ''
}`
);
}
}
protected async handleError(
error: any,
result: RemoteReleaseResult
): Promise<void> {
if (error) {
process.exitCode = 1;
if (error.response?.data) {
// There's a nicely formatted error from GitHub we can display to the user
output.error({
title: `A GitHub API Error occurred when creating/updating the release`,
bodyLines: [
`GitHub Error: ${JSON.stringify(error.response.data)}`,
`---`,
`Request Data:`,
`Repo: ${this.getRemoteRepoData<GithubRepoData>()?.slug}`,
`Token Header Data: ${this.tokenHeader}`,
`Body: ${JSON.stringify(result.requestData)}`,
],
});
} else {
console.log(error);
console.error(
`An unknown error occurred while trying to create a release on GitHub, please report this on https://github.com/nrwl/nx (NOTE: make sure to redact your GitHub token from the error message!)`
);
}
}
const shouldContinueInGitHub = await this.promptForContinueInGitHub();
if (!shouldContinueInGitHub) {
return;
}
const open = require('open');
await open(result.url)
.then(() => {
console.info(
`\nFollow up in the browser to manually create the release:\n\n` +
chalk.underline(chalk.cyan(result.url)) +
`\n`
);
})
.catch(() => {
console.info(
`Open this link to manually create a release: \n` +
chalk.underline(chalk.cyan(result.url)) +
'\n'
);
});
}
private async promptForContinueInGitHub(): Promise<boolean> {
try {
const reply = await prompt<{ open: 'Yes' | 'No' }>([
{
name: 'open',
message:
'Do you want to finish creating the release manually in your browser?',
type: 'autocomplete',
choices: [
{
name: 'Yes',
hint: 'It will pre-populate the form for you',
},
{
name: 'No',
},
],
initial: 0,
},
]);
return reply.open === 'Yes';
} catch {
// Ensure the cursor is always restored before exiting
process.stdout.write('\u001b[?25h');
// Handle the case where the user exits the prompt with ctrl+c
process.exit(1);
}
}
/**
* Format references for the release (e.g., PRs, issues)
*/
formatReferences(references: Reference[]): string {
const githubRepoData = this.getRequiredRemoteRepoData();
const providerToRefSpec: Record<
string,
Record<Reference['type'], string>
> = {
github: { 'pull-request': 'pull', hash: 'commit', issue: 'issues' },
};
const refSpec = providerToRefSpec.github;
const formatSingleReference = (ref: Reference) => {
return `[${ref.value}](https://${githubRepoData.hostname}/${
githubRepoData.slug
}/${refSpec[ref.type]}/${ref.value.replace(/^#/, '')})`;
};
const pr = references.filter((ref) => ref.type === 'pull-request');
const issue = references.filter((ref) => ref.type === 'issue');
if (pr.length > 0 || issue.length > 0) {
return (
' (' +
[...pr, ...issue].map((ref) => formatSingleReference(ref)).join(', ') +
')'
);
}
if (references.length > 0) {
return ' (' + formatSingleReference(references[0]) + ')';
}
return '';
}
protected async syncRelease(
remoteReleaseOptions: RemoteReleaseOptions,
existingRelease?: GithubRemoteRelease
): Promise<RemoteReleaseResult> {
const githubReleaseData: GithubRemoteRelease = {
tag_name: remoteReleaseOptions.version,
name: remoteReleaseOptions.version,
body: remoteReleaseOptions.body,
prerelease: remoteReleaseOptions.prerelease,
// legacy specifies that the latest release should be determined based on the release creation date and higher semantic version.
make_latest: 'legacy',
};
try {
const newGhRelease = await (existingRelease
? this.updateRelease(existingRelease.id, githubReleaseData)
: this.createRelease({
...githubReleaseData,
target_commitish: remoteReleaseOptions.commit,
}));
return {
status: existingRelease ? 'updated' : 'created',
id: newGhRelease.id,
url: newGhRelease.html_url,
};
} catch (error) {
return {
status: 'manual',
error,
url: this.getManualRemoteReleaseURL(remoteReleaseOptions),
requestData: githubReleaseData,
};
}
}
private getRequiredRemoteRepoData(): GithubRepoData {
const githubRepoData = this.getRemoteRepoData<GithubRepoData>();
if (!githubRepoData) {
throw new Error(
`No remote repo data could be resolved for the current workspace`
);
}
return githubRepoData;
}
}

View File

@ -0,0 +1,404 @@
import * as chalk from 'chalk';
import { prompt } from 'enquirer';
import { execSync } from 'node:child_process';
import { output } from '../../../../utils/output';
import type { PostGitTask } from '../../changelog';
import type { ResolvedCreateRemoteReleaseProvider } from '../../config/config';
import type { Reference } from '../git';
import { ReleaseVersion } from '../shared';
import {
RemoteReleaseClient,
RemoteReleaseOptions,
RemoteReleaseResult,
RemoteRepoData,
RemoteRepoSlug,
} from './remote-release-client';
export interface GitLabRepoData extends RemoteRepoData {
projectId: string;
}
// https://docs.gitlab.com/api/releases/#create-a-release
export interface GitLabRelease {
id?: string;
name?: string;
tag_name: string;
ref: string;
assets?: {
links?: {
name: string;
url: string;
direct_asset_path?: string;
link_type?: string;
}[];
};
released_at?: string;
description?: string;
milestones?: string[];
prerelease?: boolean;
}
export const defaultCreateReleaseProvider: ResolvedCreateRemoteReleaseProvider =
{
provider: 'gitlab',
hostname: 'gitlab.com',
apiBaseUrl: 'https://gitlab.com/api/v4',
};
export class GitLabRemoteReleaseClient extends RemoteReleaseClient<GitLabRelease> {
remoteReleaseProviderName = 'GitLab';
/**
* Get GitLab repository data from git remote
*/
static resolveRepoData(
createReleaseConfig: false | ResolvedCreateRemoteReleaseProvider,
remoteName = 'origin'
): GitLabRepoData | null {
try {
const remoteUrl = execSync(`git remote get-url ${remoteName}`, {
encoding: 'utf8',
stdio: 'pipe',
}).trim();
// Use the default provider 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 || hostname;
apiBaseUrl = createReleaseConfig.apiBaseUrl || apiBaseUrl;
}
// Extract the project path from the URL
const escapedHostname = hostname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regexString = `${escapedHostname}[/:]([\\w.-]+/[\\w.-]+(?:/[\\w.-]+)*)(\\.git)?`;
const regex = new RegExp(regexString);
const match = remoteUrl.match(regex);
if (match && match[1]) {
// Remove trailing .git if present
const slug = match[1].replace(/\.git$/, '') as RemoteRepoSlug;
// Encode the project path for use in API URLs
const projectId = encodeURIComponent(slug);
return {
hostname,
apiBaseUrl,
slug,
projectId,
};
} else {
throw new Error(
`Could not extract project path data from the resolved remote URL: ${remoteUrl}`
);
}
} catch (err) {
if (process.env.NX_VERBOSE_LOGGING === 'true') {
console.error(err);
}
return null;
}
}
/**
* Resolve a GitLab token from various environment variables
*/
static async resolveTokenData(
hostname: string
): Promise<{ token: string; headerName: string } | null> {
// Try and resolve from the environment
const tokenFromEnv = process.env.GITLAB_TOKEN || process.env.GL_TOKEN;
if (tokenFromEnv) {
return { token: tokenFromEnv, headerName: 'PRIVATE-TOKEN' };
}
// Try and resolve from a CI environment
if (process.env.CI_JOB_TOKEN) {
return { token: process.env.CI_JOB_TOKEN, headerName: 'JOB-TOKEN' };
}
if (hostname !== 'gitlab.com') {
console.log(
`Warning: It was not possible to automatically resolve a GitLab token from your environment for hostname ${hostname}. If you set the GITLAB_TOKEN or GL_TOKEN environment variable (or you are in GitLab CI where CI_JOB_TOKEN is set automatically), that will be used for GitLab API requests.`
);
}
return null;
}
createPostGitTask(
releaseVersion: ReleaseVersion,
changelogContents: string,
dryRun: boolean
): PostGitTask {
return async (latestCommit: string) => {
output.logSingleLine(`Creating GitLab Release`);
await this.createOrUpdateRelease(
releaseVersion,
changelogContents,
latestCommit,
{ dryRun }
);
};
}
// Not implemented for GitLab yet, the changelog renderer should not call this method
async applyUsernameToAuthors(): Promise<void> {
throw new Error('applyUsernameToAuthors is not implemented for GitLab yet');
}
protected async getReleaseByTag(tag: string): Promise<GitLabRelease> {
const gitlabRepoData = this.getRequiredRemoteRepoData();
return await this.makeRequest(
`/projects/${gitlabRepoData.projectId}/releases/${encodeURIComponent(
tag
)}`
);
}
protected async createRelease(remoteRelease: GitLabRelease): Promise<any> {
const gitlabRepoData = this.getRequiredRemoteRepoData();
return await this.makeRequest(
`/projects/${gitlabRepoData.projectId}/releases`,
{
method: 'POST',
data: remoteRelease,
}
);
}
protected async updateRelease(
_id: string,
remoteRelease: GitLabRelease
): Promise<any> {
const gitlabRepoData = this.getRequiredRemoteRepoData();
return await this.makeRequest(
`/projects/${gitlabRepoData.projectId}/releases/${encodeURIComponent(
remoteRelease.tag_name
)}`,
{
method: 'PUT',
data: remoteRelease,
}
);
}
/**
* Generate a URL for manual release creation on GitLab. Sadly, unlike GitHub, GitLab does not
* seem to respect query string parameters for setting the UI form fields, so the user has to
* start from scratch.
*/
protected getManualRemoteReleaseURL(
_remoteReleaseOptions: RemoteReleaseOptions
): string {
const gitlabRepoData = this.getRequiredRemoteRepoData();
return `https://${gitlabRepoData.hostname}/${gitlabRepoData.slug}/-/releases/new`;
}
protected handleAuthError(): void {
output.error({
title: `Unable to resolve data via the GitLab API.`,
bodyLines: [
'- Set the `GITLAB_TOKEN` or `GL_TOKEN` environment variable to a valid GitLab token with `api` scope',
'- If running in GitLab CI, the automatically provisioned CI_JOB_TOKEN can also be used',
],
});
}
protected logReleaseAction(
existingRelease: GitLabRelease | undefined,
gitTag: string,
dryRun: boolean
): void {
const gitlabRepoData = this.getRequiredRemoteRepoData();
const logTitle = `https://${gitlabRepoData.hostname}/${
gitlabRepoData.slug
}/-/releases/${encodeURIComponent(gitTag)}`;
if (existingRelease) {
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]') : ''
}`
);
}
}
protected async handleError(
error: any,
result: RemoteReleaseResult
): Promise<void> {
if (error) {
process.exitCode = 1;
if (error.response?.data) {
output.error({
title: `A GitLab API Error occurred when creating/updating the release`,
bodyLines: [
`GitLab Error: ${JSON.stringify(error.response.data)}`,
`---`,
`Request Data:`,
`Repo: ${this.getRemoteRepoData<GitLabRepoData>()?.slug}`,
`Token Header Data: ${this.tokenHeader}`,
`Body: ${JSON.stringify(result.requestData)}`,
],
});
} else {
console.log(error);
console.error(
`An unknown error occurred while trying to create a release on GitLab, please report this on https://github.com/nrwl/nx (NOTE: make sure to redact your GitLab token from the error message!)`
);
}
}
const shouldContinueInGitLab = await this.promptForContinueInGitLab();
if (!shouldContinueInGitLab) {
return;
}
const open = require('open');
await open(result.url)
.then(() => {
console.info(
`\nFollow up in the browser to manually create the release:\n\n` +
chalk.underline(chalk.cyan(result.url)) +
`\n`
);
})
.catch(() => {
console.info(
`Open this link to manually create a release: \n` +
chalk.underline(chalk.cyan(result.url)) +
'\n'
);
});
}
private async promptForContinueInGitLab(): Promise<boolean> {
try {
const reply = await prompt<{ open: 'Yes' | 'No' }>([
{
name: 'open',
message:
'Do you want to create the release manually in your browser?',
type: 'autocomplete',
choices: [
{
name: 'Yes',
hint: 'It will open the GitLab release page for you',
},
{
name: 'No',
},
],
initial: 0,
},
]);
return reply.open === 'Yes';
} catch {
// Ensure the cursor is always restored before exiting
process.stdout.write('\u001b[?25h');
// Handle the case where the user exits the prompt with ctrl+c
process.exit(1);
}
}
/**
* Format references for the release (e.g., MRs, issues)
*/
formatReferences(references: Reference[]): string {
const gitlabRepoData = this.getRequiredRemoteRepoData();
const providerToRefSpec: Record<
string,
Record<Reference['type'], string>
> = {
gitlab: {
'pull-request': 'merge_requests',
hash: 'commit',
issue: 'issues',
},
};
const refSpec = providerToRefSpec.gitlab;
const formatSingleReference = (ref: Reference) => {
return `https://${gitlabRepoData.hostname}/${gitlabRepoData.slug}/-/${
refSpec[ref.type]
}/${ref.value.replace(/^[#!]/, '')}`;
};
const mr = references.filter((ref) => ref.type === 'pull-request');
const issue = references.filter((ref) => ref.type === 'issue');
if (mr.length > 0 || issue.length > 0) {
return (
' (' +
[...mr, ...issue].map((ref) => formatSingleReference(ref)).join(', ') +
')'
);
}
if (references.length > 0) {
return ' (' + formatSingleReference(references[0]) + ')';
}
return '';
}
protected async syncRelease(
remoteReleaseOptions: RemoteReleaseOptions,
existingRelease?: GitLabRelease
): Promise<RemoteReleaseResult> {
const gitlabReleaseData: GitLabRelease = {
tag_name: remoteReleaseOptions.version,
name: remoteReleaseOptions.version,
description: remoteReleaseOptions.body,
prerelease: remoteReleaseOptions.prerelease,
ref: remoteReleaseOptions.commit,
released_at: new Date().toISOString(),
assets: { links: [] },
milestones: [],
};
try {
const newGlRelease = await (existingRelease
? this.updateRelease(existingRelease.id, gitlabReleaseData)
: this.createRelease(gitlabReleaseData));
const gitlabRepoData = this.getRequiredRemoteRepoData();
return {
status: existingRelease ? 'updated' : 'created',
id: newGlRelease.tag_name,
url: `https://${gitlabRepoData.hostname}/${
gitlabRepoData.slug
}/-/tags/${encodeURIComponent(remoteReleaseOptions.version)}`,
};
} catch (error) {
return {
status: 'manual',
error,
url: this.getManualRemoteReleaseURL(remoteReleaseOptions),
requestData: gitlabReleaseData,
};
}
}
private getRequiredRemoteRepoData(): GitLabRepoData {
const gitlabRepoData = this.getRemoteRepoData<GitLabRepoData>();
if (!gitlabRepoData) {
throw new Error(
`No remote repo data could be resolved for the current workspace`
);
}
return gitlabRepoData;
}
}

View File

@ -0,0 +1,292 @@
import type { AxiosRequestConfig } from 'axios';
import axios from 'axios';
import type { PostGitTask } from '../../changelog';
import { ResolvedCreateRemoteReleaseProvider } from '../../config/config';
import type { Reference } from '../git';
import { printDiff } from '../print-changes';
import { noDiffInChangelogMessage, type ReleaseVersion } from '../shared';
import type { GithubRemoteReleaseClient } from './github';
import type { GitLabRemoteReleaseClient } from './gitlab';
export type RemoteRepoSlug = `${string}/${string}`;
// Base repository data interface
export interface RemoteRepoData {
hostname: string;
slug: RemoteRepoSlug;
apiBaseUrl: string;
}
// Release options for creating or updating releases
export interface RemoteReleaseOptions {
version: string;
body: string;
prerelease: boolean;
commit: string;
}
// Release creation result
export interface RemoteReleaseResult {
status: 'created' | 'updated' | 'manual';
id?: string;
url?: string;
error?: any;
requestData?: any;
}
/**
* Abstract base class for remote release clients
*/
export abstract class RemoteReleaseClient<
RemoteRelease extends Record<string, any>
> {
/**
* Used in user-facing messaging
*/
abstract remoteReleaseProviderName: string;
protected tokenHeader: Record<string, string>;
constructor(
// A workspace isn't guaranteed to have a remote
private remoteRepoData: RemoteRepoData | null,
protected createReleaseConfig: false | ResolvedCreateRemoteReleaseProvider,
protected tokenData: { token: string; headerName: string } | null
) {
this.tokenHeader = {};
if (tokenData) {
if (tokenData.headerName === 'Authorization') {
this.tokenHeader[tokenData.headerName] = `Bearer ${tokenData.token}`;
} else {
this.tokenHeader[tokenData.headerName] = tokenData.token;
}
}
}
getRemoteRepoData<T extends RemoteRepoData>(): T | null {
return this.remoteRepoData as T | null;
}
/**
* Create a post git task that will be executed by nx release changelog after performing any relevant
* git operations, if the user has opted into remote release creation.
*/
abstract createPostGitTask(
releaseVersion: ReleaseVersion,
changelogContents: string,
dryRun: boolean
): PostGitTask;
/**
* Apply authors' corresponding usernames, if applicable, on the remote release provider. It is designed to be
* invoked by a changelog renderer implementation.
*/
abstract applyUsernameToAuthors(
authors: Map<string, { email: Set<string>; username?: string }>
): Promise<void>;
/**
* Make an (optionally authenticated) API request to the remote release provider
*/
protected async makeRequest(
url: string,
opts: AxiosRequestConfig = {}
): Promise<any> {
const remoteRepoData = this.getRemoteRepoData<RemoteRepoData>();
if (!remoteRepoData) {
throw new Error(
`No remote repo data could be resolved for the current workspace`
);
}
const config: AxiosRequestConfig<any> = {
...opts,
baseURL: remoteRepoData.apiBaseUrl,
headers: {
...(opts.headers as any),
...this.tokenHeader,
},
};
return (await axios<any, any>(url, config)).data;
}
async createOrUpdateRelease(
releaseVersion: ReleaseVersion,
changelogContents: string,
latestCommit: string,
{ dryRun }: { dryRun: boolean }
): Promise<void> {
let existingRelease: RemoteRelease | undefined;
try {
existingRelease = await this.getReleaseByTag(releaseVersion.gitTag);
} catch (err) {
if (err.response?.status === 401) {
this.handleAuthError();
process.exit(1);
}
if (err.response?.status === 404) {
// No existing release found, this is fine
} else {
// Rethrow unknown errors for now
throw err;
}
}
this.logReleaseAction(existingRelease, releaseVersion.gitTag, dryRun);
this.printRemoteReleaseContents(
existingRelease
? 'body' in existingRelease
? existingRelease.body
: 'description' in existingRelease
? existingRelease.description
: ''
: '',
changelogContents
);
if (!dryRun) {
const remoteReleaseOptions: RemoteReleaseOptions = {
version: releaseVersion.gitTag,
prerelease: releaseVersion.isPrerelease,
body: changelogContents,
commit: latestCommit,
};
const result = await this.syncRelease(
remoteReleaseOptions,
existingRelease
);
if (result.status === 'manual') {
await this.handleError(result.error, result);
}
}
}
/**
* Format references for the release (e.g., PRs, issues)
*/
formatReferences(_references: Reference[]): string {
// Base implementation - to be overridden by specific providers
return '';
}
/**
* Handle non-auth related errors when creating releases
*/
protected abstract handleError(
error: any,
result: RemoteReleaseResult
): Promise<void>;
/**
* Display authentication error message
*/
protected abstract handleAuthError(): void;
/**
* Log the release action (create or update)
*/
protected abstract logReleaseAction(
existingRelease: RemoteRelease | undefined,
gitTag: string,
dryRun: boolean
): void;
/**
* Print changelog changes
*/
protected printRemoteReleaseContents(
existingBody: string,
newBody: string
): void {
console.log('');
printDiff(existingBody, newBody, 3, noDiffInChangelogMessage);
}
/**
* Get a release by tag
*/
protected abstract getReleaseByTag(tag: string): Promise<RemoteRelease>;
/**
* Create a manual release URL used to create/edit a release in the remote release provider's UI
*/
protected abstract getManualRemoteReleaseURL(
remoteReleaseOptions: RemoteReleaseOptions
): string;
/**
* Create a new release
*/
protected abstract createRelease(body: RemoteRelease): Promise<any>;
/**
* Update an existing release
*/
protected abstract updateRelease(
id: string,
body: RemoteRelease
): Promise<any>;
/**
* Synchronize a release with the remote release provider
*/
protected abstract syncRelease(
remoteReleaseOptions: RemoteReleaseOptions,
existingRelease?: RemoteRelease
): Promise<RemoteReleaseResult>;
}
/**
* Factory function to create a remote release client based on the given configuration
*/
export async function createRemoteReleaseClient(
createReleaseConfig: false | ResolvedCreateRemoteReleaseProvider,
remoteName = 'origin'
): Promise<GithubRemoteReleaseClient | GitLabRemoteReleaseClient | null> {
switch (true) {
// GitHub and GitHub Enterprise Server
case typeof createReleaseConfig === 'object' &&
(createReleaseConfig.provider === 'github-enterprise-server' ||
createReleaseConfig.provider === 'github'):
// If remote releases are disabled, assume GitHub repo data resolution (but don't attempt to resolve a token) to match existing behavior
case createReleaseConfig === false: {
const { GithubRemoteReleaseClient } = await import('./github');
const repoData = GithubRemoteReleaseClient.resolveRepoData(
createReleaseConfig,
remoteName
);
const token =
createReleaseConfig && repoData
? await GithubRemoteReleaseClient.resolveTokenData(repoData.hostname)
: null;
return new GithubRemoteReleaseClient(
repoData,
createReleaseConfig,
token
);
}
// GitLab
case typeof createReleaseConfig === 'object' &&
createReleaseConfig.provider === 'gitlab': {
const { GitLabRemoteReleaseClient } = await import('./gitlab');
const repoData = GitLabRemoteReleaseClient.resolveRepoData(
createReleaseConfig,
remoteName
);
const tokenData = repoData
? await GitLabRemoteReleaseClient.resolveTokenData(repoData.hostname)
: null;
return new GitLabRemoteReleaseClient(
repoData,
createReleaseConfig,
tokenData
);
}
default:
throw new Error(
`Unsupported remote release configuration: ${JSON.stringify(
createReleaseConfig
)}`
);
}
}

View File

@ -191,6 +191,7 @@ export interface NxReleaseChangelogConfiguration {
createRelease?: createRelease?:
| false | false
| 'github' | 'github'
| 'gitlab'
| { | {
provider: 'github-enterprise-server'; provider: 'github-enterprise-server';
hostname: string; hostname: string;
@ -198,6 +199,14 @@ export interface NxReleaseChangelogConfiguration {
* If not set, this will default to `https://${hostname}/api/v3` * If not set, this will default to `https://${hostname}/api/v3`
*/ */
apiBaseUrl?: string; apiBaseUrl?: string;
}
| {
provider: 'gitlab';
hostname: string;
/**
* If not set, this will default to `https://${hostname}/api/v4`
*/
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)

View File

@ -0,0 +1,54 @@
#### Nx Release Changelog Config Changes
In Nx v21, the `mapAuthorsToGitHubUsernames` changelog "renderOption" for the default changelog renderer was renamed to `applyUsernameToAuthors` to reflect the fact that it is no longer specific to GitHub. Most people were not setting this option directly, but if you were, it will be automatically migrated by this migration.
The migration will also update release groups changelog configuration, if applicable.
#### Sample Code Changes
{% tabs %}
{% tab label="Before" %}
```json {% fileName="nx.json" %}
{
"release": {
"changelog": {
"workspaceChangelog": {
"renderOptions": {
"mapAuthorsToGitHubUsernames": true
}
},
"projectChangelogs": {
"renderOptions": {
"mapAuthorsToGitHubUsernames": false
}
}
}
}
}
```
{% /tab %}
{% tab label="After" %}
```json {% fileName="nx.json" %}
{
"release": {
"changelog": {
"workspaceChangelog": {
"renderOptions": {
"applyUsernameToAuthors": true
}
},
"projectChangelogs": {
"renderOptions": {
"applyUsernameToAuthors": false
}
}
}
}
}
```
{% /tab %}
{% /tabs %}

View File

@ -0,0 +1,93 @@
import { createTreeWithEmptyWorkspace } from '../../generators/testing-utils/create-tree-with-empty-workspace';
import { Tree } from '../../generators/tree';
import { readNxJson, updateNxJson } from '../../generators/utils/nx-json';
import update from './release-changelog-config-changes';
describe('release changelog config changes', () => {
let tree: Tree;
beforeEach(() => {
tree = createTreeWithEmptyWorkspace();
});
it('should do nothing when nxJson.release is not set', async () => {
updateNxJson(tree, {});
await update(tree);
expect(readNxJson(tree)).toEqual({});
});
it('should do nothing when mapAuthorsToGitHubUsernames is not set', async () => {
const nxJsonBefore = {
release: {
changelog: {
workspaceChangelog: {
renderOptions: {},
},
},
},
};
updateNxJson(tree, nxJsonBefore);
await update(tree);
expect(readNxJson(tree)).toEqual(nxJsonBefore);
});
it('should update mapAuthorsToGitHubUsernames to applyUsernameToAuthors if set at any level', async () => {
updateNxJson(tree, {
release: {
changelog: {
workspaceChangelog: {
renderOptions: { mapAuthorsToGitHubUsernames: true },
},
projectChangelogs: {
renderOptions: { mapAuthorsToGitHubUsernames: false },
},
},
groups: {
group1: {
projects: ['project1', 'project2'],
changelog: {
renderOptions: { mapAuthorsToGitHubUsernames: true },
},
},
},
},
});
await update(tree);
expect(readNxJson(tree)).toMatchInlineSnapshot(`
{
"release": {
"changelog": {
"projectChangelogs": {
"renderOptions": {
"applyUsernameToAuthors": false,
},
},
"workspaceChangelog": {
"renderOptions": {
"applyUsernameToAuthors": true,
},
},
},
"groups": {
"group1": {
"changelog": {
"renderOptions": {
"applyUsernameToAuthors": true,
},
},
"projects": [
"project1",
"project2",
],
},
},
},
}
`);
});
});

View File

@ -0,0 +1,53 @@
import {
NxJsonConfiguration,
NxReleaseChangelogConfiguration,
NxReleaseVersionConfiguration,
} from '../../config/nx-json';
import { formatChangedFilesWithPrettierIfAvailable } from '../../generators/internal-utils/format-changed-files-with-prettier-if-available';
import { Tree } from '../../generators/tree';
import { readNxJson, updateNxJson } from '../../generators/utils/nx-json';
export default async function update(tree: Tree) {
const nxJson = readNxJson(tree) as NxJsonConfiguration;
if (!nxJson || !nxJson.release) {
return;
}
function updateProperty(changelogConfig: NxReleaseChangelogConfiguration) {
if (
changelogConfig.renderOptions &&
'mapAuthorsToGitHubUsernames' in changelogConfig.renderOptions
) {
changelogConfig.renderOptions.applyUsernameToAuthors =
changelogConfig.renderOptions.mapAuthorsToGitHubUsernames;
delete changelogConfig.renderOptions.mapAuthorsToGitHubUsernames;
}
}
if (nxJson.release.changelog) {
if (
nxJson.release.changelog.workspaceChangelog &&
typeof nxJson.release.changelog.workspaceChangelog !== 'boolean'
) {
updateProperty(nxJson.release.changelog.workspaceChangelog);
}
if (
nxJson.release.changelog.projectChangelogs &&
typeof nxJson.release.changelog.projectChangelogs !== 'boolean'
) {
updateProperty(nxJson.release.changelog.projectChangelogs);
}
}
if (nxJson.release.groups) {
for (const group of Object.values(nxJson.release.groups)) {
if (group.changelog && typeof group.changelog !== 'boolean') {
updateProperty(group.changelog);
}
}
}
updateNxJson(tree, nxJson);
await formatChangedFilesWithPrettierIfAvailable(tree);
}