feat(core): initial implementation of nx release (#19110)

Co-authored-by: FrozenPandaz <jasonjean1993@gmail.com>
This commit is contained in:
James Henry 2023-09-18 23:11:44 +04:00 committed by GitHub
parent 11fcb8f2d4
commit 9116c29c18
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
74 changed files with 6026 additions and 928 deletions

View File

@ -115,7 +115,7 @@ Type: `boolean`
Show help
### nx-bail
### nxBail
Type: `boolean`
@ -123,7 +123,7 @@ Default: `false`
Stop command execution after the first failed task
### nx-ignore-cycles
### nxIgnoreCycles
Type: `boolean`
@ -151,7 +151,7 @@ Type: `string`
This is the name of the tasks runner configured in nx.json
### skip-nx-cache
### skipNxCache
Type: `boolean`

View File

@ -35,7 +35,7 @@ Type: `string`
Show the task graph of the command. Pass a file path to save the graph data instead of viewing it in the browser.
### nx-bail
### nxBail
Type: `boolean`
@ -43,7 +43,7 @@ Default: `false`
Stop command execution after the first failed task
### nx-ignore-cycles
### nxIgnoreCycles
Type: `boolean`
@ -77,7 +77,7 @@ Type: `string`
This is the name of the tasks runner configured in nx.json
### skip-nx-cache
### skipNxCache
Type: `boolean`

View File

@ -0,0 +1,250 @@
---
title: 'release - CLI command'
description: '**ALPHA**: Orchestrate versioning and publishing of applications and libraries'
---
# release
**ALPHA**: Orchestrate versioning and publishing of applications and libraries
## Usage
```shell
nx release
```
Install `nx` globally to invoke the command directly using `nx`, or use `npx nx`, `yarn nx`, or `pnpm nx`.
## Options
### dryRun
Type: `boolean`
Default: `false`
Preview the changes without updating files/creating releases
### groups
Type: `string`
One or more release groups to target with the current command.
### help
Type: `boolean`
Show help
### projects
Type: `string`
Projects to run. (comma/space delimited project names and/or patterns)
### verbose
Type: `boolean`
Prints additional information about the commands (e.g., stack traces)
### version
Type: `boolean`
Show version number
## Subcommands
### version
Create a version and release for one or more applications and libraries
```shell
nx release version [specifier]
```
#### Options
##### help
Type: `boolean`
Show help
##### preid
Type: `string`
The optional prerelease identifier to apply to the version, in the case that specifier has been set to prerelease.
##### specifier
Type: `string`
Exact version or semver keyword to apply to the selected release group.
##### version
Type: `boolean`
Show version number
### changelog
Generate a changelog for one or more projects, and optionally push to Github
```shell
nx release changelog [version]
```
#### Options
##### from
Type: `string`
The git reference to use as the start of the changelog. If not set it will attempt to resolve the latest tag and use that
##### gitRemote
Type: `string`
Default: `origin`
Alternate git remote in the form {user}/{repo} on which to create the Github release (useful for testing)
##### help
Type: `boolean`
Show help
##### interactive
Type: `boolean`
##### tagVersionPrefix
Type: `string`
Default: `v`
Prefix to apply to the version when creating the Github release tag
##### to
Type: `string`
Default: `HEAD`
The git reference to use as the end of the changelog
##### version
Type: `string`
The version to create a Github release and changelog for
### publish
Publish a versioned project to a registry
```shell
nx release publish
```
#### Options
##### all
Type: `boolean`
Default: `true`
[deprecated] `run-many` runs all targets on all projects in the workspace if no projects are provided. This option is no longer required.
##### exclude
Type: `string`
Exclude certain projects from being processed
##### graph
Type: `string`
Show the task graph of the command. Pass a file path to save the graph data instead of viewing it in the browser.
##### help
Type: `boolean`
Show help
##### nxBail
Type: `boolean`
Default: `false`
Stop command execution after the first failed task
##### nxIgnoreCycles
Type: `boolean`
Default: `false`
Ignore cycles in the task graph
##### parallel
Type: `string`
Max number of parallel processes [default is 3]
##### projects
Type: `string`
Projects to run. (comma/space delimited project names and/or patterns)
##### registry
Type: `string`
The registry to publish to
##### runner
Type: `string`
This is the name of the tasks runner configured in nx.json
##### skipNxCache
Type: `boolean`
Default: `false`
Rerun the tasks even when the results are available in the cache
##### tag
Type: `string`
The distribution tag to apply to the published package
##### verbose
Type: `boolean`
Prints additional information about the commands (e.g., stack traces)
##### version
Type: `boolean`
Show version number

View File

@ -105,7 +105,7 @@ Type: `boolean`
Show help
### nx-bail
### nxBail
Type: `boolean`
@ -113,7 +113,7 @@ Default: `false`
Stop command execution after the first failed task
### nx-ignore-cycles
### nxIgnoreCycles
Type: `boolean`
@ -147,7 +147,7 @@ Type: `string`
This is the name of the tasks runner configured in nx.json
### skip-nx-cache
### skipNxCache
Type: `boolean`

View File

@ -29,6 +29,7 @@ Nx.json configuration
- [npmScope](../../devkit/documents/NxJsonConfiguration#npmscope): string
- [plugins](../../devkit/documents/NxJsonConfiguration#plugins): string[]
- [pluginsConfig](../../devkit/documents/NxJsonConfiguration#pluginsconfig): Record&lt;string, unknown&gt;
- [release](../../devkit/documents/NxJsonConfiguration#release): NxReleaseConfiguration
- [targetDefaults](../../devkit/documents/NxJsonConfiguration#targetdefaults): TargetDefaults
- [tasksRunnerOptions](../../devkit/documents/NxJsonConfiguration#tasksrunneroptions): Object
- [workspaceLayout](../../devkit/documents/NxJsonConfiguration#workspacelayout): Object
@ -163,6 +164,14 @@ Configuration for Nx Plugins
---
### release
`Optional` **release**: `NxReleaseConfiguration`
**ALPHA**: Configuration for `nx release` (versioning and publishing of applications and libraries)
---
### targetDefaults
`Optional` **targetDefaults**: `TargetDefaults`

View File

@ -28,6 +28,7 @@ use ProjectsConfigurations or NxJsonConfiguration
- [plugins](../../devkit/documents/Workspace#plugins): string[]
- [pluginsConfig](../../devkit/documents/Workspace#pluginsconfig): Record&lt;string, unknown&gt;
- [projects](../../devkit/documents/Workspace#projects): Record&lt;string, ProjectConfiguration&gt;
- [release](../../devkit/documents/Workspace#release): NxReleaseConfiguration
- [targetDefaults](../../devkit/documents/Workspace#targetdefaults): TargetDefaults
- [tasksRunnerOptions](../../devkit/documents/Workspace#tasksrunneroptions): Object
- [version](../../devkit/documents/Workspace#version): number
@ -219,6 +220,18 @@ Projects' projects
---
### release
`Optional` **release**: `NxReleaseConfiguration`
**ALPHA**: Configuration for `nx release` (versioning and publishing of applications and libraries)
#### Inherited from
[NxJsonConfiguration](../../devkit/documents/NxJsonConfiguration).[release](../../devkit/documents/NxJsonConfiguration#release)
---
### targetDefaults
`Optional` **targetDefaults**: `TargetDefaults`

View File

@ -7104,6 +7104,14 @@
"isExternal": false,
"disableCollapsible": false
},
{
"id": "release-publish",
"path": "/packages/js/executors/release-publish",
"name": "release-publish",
"children": [],
"isExternal": false,
"disableCollapsible": false
},
{
"id": "verdaccio",
"path": "/packages/js/executors/verdaccio",
@ -7145,6 +7153,14 @@
"isExternal": false,
"disableCollapsible": false
},
{
"id": "release-version",
"path": "/packages/js/generators/release-version",
"name": "release-version",
"children": [],
"isExternal": false,
"disableCollapsible": false
},
{
"id": "setup-verdaccio",
"path": "/packages/js/generators/setup-verdaccio",
@ -7824,6 +7840,14 @@
"isExternal": false,
"children": [],
"disableCollapsible": false
},
{
"name": "release",
"path": "/packages/nx/documents/release",
"id": "release",
"isExternal": false,
"children": [],
"disableCollapsible": false
}
],
"isExternal": false,

View File

@ -1010,6 +1010,15 @@
"path": "/packages/js/executors/node",
"type": "executor"
},
"/packages/js/executors/release-publish": {
"description": "DO NOT INVOKE DIRECTLY WITH `nx run`. Use `nx release publish` instead.",
"file": "generated/packages/js/executors/release-publish.json",
"hidden": true,
"name": "release-publish",
"originalFilePath": "/packages/js/src/executors/release-publish/schema.json",
"path": "/packages/js/executors/release-publish",
"type": "executor"
},
"/packages/js/executors/verdaccio": {
"description": "Start local registry with verdaccio",
"file": "generated/packages/js/executors/verdaccio.json",
@ -1048,6 +1057,15 @@
"path": "/packages/js/generators/convert-to-swc",
"type": "generator"
},
"/packages/js/generators/release-version": {
"description": "DO NOT INVOKE DIRECTLY WITH `nx generate`. Use `nx release version` instead.",
"file": "generated/packages/js/generators/release-version.json",
"hidden": true,
"name": "release-version",
"originalFilePath": "/packages/js/src/generators/release-version/schema.json",
"path": "/packages/js/generators/release-version",
"type": "generator"
},
"/packages/js/generators/setup-verdaccio": {
"description": "Setup Verdaccio for local package management.",
"file": "generated/packages/js/generators/setup-verdaccio.json",
@ -1773,6 +1791,17 @@
"path": "/packages/nx/documents/view-logs",
"tags": [],
"originalFilePath": "generated/cli/view-logs"
},
"/packages/nx/documents/release": {
"id": "release",
"name": "release",
"description": "The core Nx plugin contains the core functionality of Nx like the project graph, nx commands and task orchestration.",
"file": "generated/packages/nx/documents/release",
"itemList": [],
"isExternal": false,
"path": "/packages/nx/documents/release",
"tags": [],
"originalFilePath": "generated/cli/release"
}
},
"root": "/packages/nx",

View File

@ -994,6 +994,15 @@
"path": "js/executors/node",
"type": "executor"
},
{
"description": "DO NOT INVOKE DIRECTLY WITH `nx run`. Use `nx release publish` instead.",
"file": "generated/packages/js/executors/release-publish.json",
"hidden": true,
"name": "release-publish",
"originalFilePath": "/packages/js/src/executors/release-publish/schema.json",
"path": "js/executors/release-publish",
"type": "executor"
},
{
"description": "Start local registry with verdaccio",
"file": "generated/packages/js/executors/verdaccio.json",
@ -1032,6 +1041,15 @@
"path": "js/generators/convert-to-swc",
"type": "generator"
},
{
"description": "DO NOT INVOKE DIRECTLY WITH `nx generate`. Use `nx release version` instead.",
"file": "generated/packages/js/generators/release-version.json",
"hidden": true,
"name": "release-version",
"originalFilePath": "/packages/js/src/generators/release-version/schema.json",
"path": "js/generators/release-version",
"type": "generator"
},
{
"description": "Setup Verdaccio for local package management.",
"file": "generated/packages/js/generators/setup-verdaccio.json",
@ -1754,6 +1772,17 @@
"path": "nx/documents/view-logs",
"tags": [],
"originalFilePath": "generated/cli/view-logs"
},
{
"id": "release",
"name": "release",
"description": "The core Nx plugin contains the core functionality of Nx like the project graph, nx commands and task orchestration.",
"file": "generated/packages/nx/documents/release",
"itemList": [],
"isExternal": false,
"path": "nx/documents/release",
"tags": [],
"originalFilePath": "generated/cli/release"
}
],
"executors": [

View File

@ -0,0 +1,32 @@
{
"name": "release-publish",
"implementation": "/packages/js/src/executors/release-publish/release-publish.impl.ts",
"schema": {
"$schema": "http://json-schema.org/schema",
"version": 2,
"title": "Implementation details of `nx release publish`",
"description": "DO NOT INVOKE DIRECTLY WITH `nx run`. Use `nx release publish` instead.",
"type": "object",
"properties": {
"packageRoot": {
"type": "string",
"description": "The root directory of the directory (containing a manifest file at its root) to publish. Defaults to the project root."
},
"registry": {
"type": "string",
"description": "The registry to publish the package to."
},
"tag": {
"type": "string",
"description": "The distribution tag to apply to the published package."
}
},
"required": [],
"presets": []
},
"description": "DO NOT INVOKE DIRECTLY WITH `nx run`. Use `nx release publish` instead.",
"hidden": true,
"aliases": [],
"path": "/packages/js/src/executors/release-publish/schema.json",
"type": "executor"
}

View File

@ -0,0 +1,54 @@
{
"name": "release-version",
"factory": "./src/generators/release-version/release-version#releaseVersionGenerator",
"schema": {
"$schema": "http://json-schema.org/schema",
"$id": "NxJSReleaseVersionGenerator",
"cli": "nx",
"title": "Implementation details of `nx release version`",
"description": "DO NOT INVOKE DIRECTLY WITH `nx generate`. Use `nx release version` instead.",
"type": "object",
"properties": {
"projects": {
"type": "array",
"description": "The ProjectGraphProjectNodes being versioned in the current execution.",
"items": { "type": "object" }
},
"projectGraph": {
"type": "object",
"description": "ProjectGraph instance"
},
"specifier": {
"type": "string",
"description": "Exact version or semver keyword to apply to the selected release group. NOTE: This should be set on the release group level, not the project level."
},
"preid": {
"type": "string",
"description": "The optional prerelease identifier to apply to the version, in the case that specifier has been set to prerelease."
},
"packageRoot": {
"type": "string",
"description": "The root directory of the directory (containing a manifest file at its root) to publish. Defaults to the project root"
},
"currentVersionResolver": {
"type": "string",
"default": "disk",
"description": "Which approach to use to determine the current version of the project.",
"enum": ["registry", "disk"]
},
"currentVersionResolverMetadata": {
"type": "object",
"description": "Additional metadata to pass to the current version resolver.",
"default": {}
}
},
"required": ["projects", "projectGraph", "specifier"],
"presets": []
},
"description": "DO NOT INVOKE DIRECTLY WITH `nx generate`. Use `nx release version` instead.",
"hidden": true,
"implementation": "/packages/js/src/generators/release-version/release-version#releaseVersionGenerator.ts",
"aliases": [],
"path": "/packages/js/src/generators/release-version/schema.json",
"type": "generator"
}

View File

@ -115,7 +115,7 @@ Type: `boolean`
Show help
### nx-bail
### nxBail
Type: `boolean`
@ -123,7 +123,7 @@ Default: `false`
Stop command execution after the first failed task
### nx-ignore-cycles
### nxIgnoreCycles
Type: `boolean`
@ -151,7 +151,7 @@ Type: `string`
This is the name of the tasks runner configured in nx.json
### skip-nx-cache
### skipNxCache
Type: `boolean`

View File

@ -35,7 +35,7 @@ Type: `string`
Show the task graph of the command. Pass a file path to save the graph data instead of viewing it in the browser.
### nx-bail
### nxBail
Type: `boolean`
@ -43,7 +43,7 @@ Default: `false`
Stop command execution after the first failed task
### nx-ignore-cycles
### nxIgnoreCycles
Type: `boolean`
@ -77,7 +77,7 @@ Type: `string`
This is the name of the tasks runner configured in nx.json
### skip-nx-cache
### skipNxCache
Type: `boolean`

View File

@ -0,0 +1,250 @@
---
title: 'release - CLI command'
description: '**ALPHA**: Orchestrate versioning and publishing of applications and libraries'
---
# release
**ALPHA**: Orchestrate versioning and publishing of applications and libraries
## Usage
```shell
nx release
```
Install `nx` globally to invoke the command directly using `nx`, or use `npx nx`, `yarn nx`, or `pnpm nx`.
## Options
### dryRun
Type: `boolean`
Default: `false`
Preview the changes without updating files/creating releases
### groups
Type: `string`
One or more release groups to target with the current command.
### help
Type: `boolean`
Show help
### projects
Type: `string`
Projects to run. (comma/space delimited project names and/or patterns)
### verbose
Type: `boolean`
Prints additional information about the commands (e.g., stack traces)
### version
Type: `boolean`
Show version number
## Subcommands
### version
Create a version and release for one or more applications and libraries
```shell
nx release version [specifier]
```
#### Options
##### help
Type: `boolean`
Show help
##### preid
Type: `string`
The optional prerelease identifier to apply to the version, in the case that specifier has been set to prerelease.
##### specifier
Type: `string`
Exact version or semver keyword to apply to the selected release group.
##### version
Type: `boolean`
Show version number
### changelog
Generate a changelog for one or more projects, and optionally push to Github
```shell
nx release changelog [version]
```
#### Options
##### from
Type: `string`
The git reference to use as the start of the changelog. If not set it will attempt to resolve the latest tag and use that
##### gitRemote
Type: `string`
Default: `origin`
Alternate git remote in the form {user}/{repo} on which to create the Github release (useful for testing)
##### help
Type: `boolean`
Show help
##### interactive
Type: `boolean`
##### tagVersionPrefix
Type: `string`
Default: `v`
Prefix to apply to the version when creating the Github release tag
##### to
Type: `string`
Default: `HEAD`
The git reference to use as the end of the changelog
##### version
Type: `string`
The version to create a Github release and changelog for
### publish
Publish a versioned project to a registry
```shell
nx release publish
```
#### Options
##### all
Type: `boolean`
Default: `true`
[deprecated] `run-many` runs all targets on all projects in the workspace if no projects are provided. This option is no longer required.
##### exclude
Type: `string`
Exclude certain projects from being processed
##### graph
Type: `string`
Show the task graph of the command. Pass a file path to save the graph data instead of viewing it in the browser.
##### help
Type: `boolean`
Show help
##### nxBail
Type: `boolean`
Default: `false`
Stop command execution after the first failed task
##### nxIgnoreCycles
Type: `boolean`
Default: `false`
Ignore cycles in the task graph
##### parallel
Type: `string`
Max number of parallel processes [default is 3]
##### projects
Type: `string`
Projects to run. (comma/space delimited project names and/or patterns)
##### registry
Type: `string`
The registry to publish to
##### runner
Type: `string`
This is the name of the tasks runner configured in nx.json
##### skipNxCache
Type: `boolean`
Default: `false`
Rerun the tasks even when the results are available in the cache
##### tag
Type: `string`
The distribution tag to apply to the published package
##### verbose
Type: `boolean`
Prints additional information about the commands (e.g., stack traces)
##### version
Type: `boolean`
Show version number

View File

@ -105,7 +105,7 @@ Type: `boolean`
Show help
### nx-bail
### nxBail
Type: `boolean`
@ -113,7 +113,7 @@ Default: `false`
Stop command execution after the first failed task
### nx-ignore-cycles
### nxIgnoreCycles
Type: `boolean`
@ -147,7 +147,7 @@ Type: `string`
This is the name of the tasks runner configured in nx.json
### skip-nx-cache
### skipNxCache
Type: `boolean`

View File

@ -1985,6 +1985,11 @@
"name": "view-logs",
"id": "view-logs",
"file": "generated/cli/view-logs"
},
{
"name": "release",
"id": "release",
"file": "generated/cli/release"
}
]
},

View File

@ -434,11 +434,13 @@
- [tsc](/packages/js/executors/tsc)
- [swc](/packages/js/executors/swc)
- [node](/packages/js/executors/node)
- [release-publish](/packages/js/executors/release-publish)
- [verdaccio](/packages/js/executors/verdaccio)
- [generators](/packages/js/generators)
- [library](/packages/js/generators/library)
- [init](/packages/js/generators/init)
- [convert-to-swc](/packages/js/generators/convert-to-swc)
- [release-version](/packages/js/generators/release-version)
- [setup-verdaccio](/packages/js/generators/setup-verdaccio)
- [setup-build](/packages/js/generators/setup-build)
- [linter](/packages/linter)
@ -522,6 +524,7 @@
- [watch](/packages/nx/documents/watch)
- [show](/packages/nx/documents/show)
- [view-logs](/packages/nx/documents/view-logs)
- [release](/packages/nx/documents/release)
- [executors](/packages/nx/executors)
- [noop](/packages/nx/executors/noop)
- [run-commands](/packages/nx/executors/run-commands)

View File

@ -0,0 +1,13 @@
/* eslint-disable */
export default {
transform: {
'^.+\\.[tj]sx?$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'html'],
maxWorkers: 1,
globals: {},
globalSetup: '../utils/global-setup.ts',
globalTeardown: '../utils/global-teardown.ts',
displayName: 'e2e-release',
preset: '../../jest.preset.js',
};

10
e2e/release/project.json Normal file
View File

@ -0,0 +1,10 @@
{
"name": "e2e-release",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "e2e/release",
"projectType": "application",
"targets": {
"e2e": {}
},
"implicitDependencies": ["nx", "js"]
}

View File

@ -0,0 +1,393 @@
import { NxJsonConfiguration } from '@nx/devkit';
import {
cleanupProject,
killProcessAndPorts,
newProject,
runCLI,
runCommandUntil,
uniq,
updateJson,
} from '@nx/e2e/utils';
import { execSync } from 'child_process';
expect.addSnapshotSerializer({
serialize(str: string) {
return (
str
// Remove all output unique to specific projects to ensure deterministic snapshots
.replaceAll(/my-pkg-\d+/g, '{project-name}')
.replaceAll(
/integrity:\s*.*/g,
'integrity: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
)
.replaceAll(/\b[0-9a-f]{40}\b/g, '{SHASUM}')
.replaceAll(/\d*B index\.js/g, 'XXB index.js')
.replaceAll(/\d*B project\.json/g, 'XXB project.json')
.replaceAll(/\d*B package\.json/g, 'XXXB package.json')
.replaceAll(/size:\s*\d*\s?B/g, 'size: XXXB')
.replaceAll(/\d*\.\d*\s?kB/g, 'XXX.XXX kb')
// We trim each line to reduce the chances of snapshot flakiness
.split('\n')
.map((r) => r.trim())
.join('\n')
);
},
test(val: string) {
return val != null && typeof val === 'string';
},
});
describe('nx release', () => {
let pkg1: string;
let pkg2: string;
let pkg3: string;
beforeAll(() => {
newProject({
unsetProjectNameAndRootFormat: false,
});
pkg1 = uniq('my-pkg-1');
runCLI(`generate @nx/workspace:npm-package ${pkg1}`);
pkg2 = uniq('my-pkg-2');
runCLI(`generate @nx/workspace:npm-package ${pkg2}`);
pkg3 = uniq('my-pkg-3');
runCLI(`generate @nx/workspace:npm-package ${pkg3}`);
// Update pkg2 to depend on pkg1
updateJson(`${pkg2}/package.json`, (json) => {
json.dependencies ??= {};
json.dependencies[`@proj/${pkg1}`] = '0.0.0';
return json;
});
});
afterAll(() => cleanupProject());
it('should version and publish multiple related npm packages with zero config', async () => {
const versionOutput = runCLI(`release version 999.9.9`);
/**
* We can't just assert on the whole version output as a snapshot because the order of the projects
* is non-deterministic, and not every project has the same number of log lines (because of the
* dependency relationship)
*/
expect(
versionOutput.match(/Running release version for project: my-pkg-\d*/g)
.length
).toEqual(3);
expect(
versionOutput.match(
/Reading data for package "@proj\/my-pkg-\d*" from my-pkg-\d*\/package.json/g
).length
).toEqual(3);
expect(
versionOutput.match(
/Resolved the current version as 0.0.0 from my-pkg-\d*\/package.json/g
).length
).toEqual(3);
expect(
versionOutput.match(
/New version 999.9.9 written to my-pkg-\d*\/package.json/g
).length
).toEqual(3);
// Only one dependency relationship exists, so this log should only match once
expect(
versionOutput.match(
/Applying new version 999.9.9 to 1 package which depends on my-pkg-\d*/g
).length
).toEqual(1);
// This is the verdaccio instance that the e2e tests themselves are working from
const e2eRegistryUrl = execSync('npm config get registry')
.toString()
.trim();
// Thanks to the custom serializer above, the publish output should be deterministic
const publishOutput = runCLI(`release publish`);
expect(publishOutput).toMatchInlineSnapshot(`
> NX Running target nx-release-publish for 3 projects:
- {project-name}
- {project-name}
- {project-name}
> nx run {project-name}:nx-release-publish
📦 @proj/{project-name}@999.9.9
=== Tarball Contents ===
XXB index.js
XXXB package.json
XXB project.json
=== Tarball Details ===
name: @proj/{project-name}
version: 999.9.9
filename: proj-{project-name}-999.9.9.tgz
package size: XXXB
unpacked size: XXXB
shasum: {SHASUM}
integrity: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
total files: 3
Published to ${e2eRegistryUrl} with tag "latest"
> nx run {project-name}:nx-release-publish
📦 @proj/{project-name}@999.9.9
=== Tarball Contents ===
XXB index.js
XXXB package.json
XXB project.json
=== Tarball Details ===
name: @proj/{project-name}
version: 999.9.9
filename: proj-{project-name}-999.9.9.tgz
package size: XXXB
unpacked size: XXXB
shasum: {SHASUM}
integrity: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
total files: 3
Published to ${e2eRegistryUrl} with tag "latest"
> nx run {project-name}:nx-release-publish
📦 @proj/{project-name}@999.9.9
=== Tarball Contents ===
XXB index.js
XXXB package.json
XXB project.json
=== Tarball Details ===
name: @proj/{project-name}
version: 999.9.9
filename: proj-{project-name}-999.9.9.tgz
package size: XXXB
unpacked size: XXXB
shasum: {SHASUM}
integrity: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
total files: 3
Published to ${e2eRegistryUrl} with tag "latest"
> NX Successfully ran target nx-release-publish for 3 projects
`);
expect(
execSync(`npm view @proj/${pkg1} version`).toString().trim()
).toEqual('999.9.9');
expect(
execSync(`npm view @proj/${pkg2} version`).toString().trim()
).toEqual('999.9.9');
expect(
execSync(`npm view @proj/${pkg3} version`).toString().trim()
).toEqual('999.9.9');
// Add custom nx release config to control version resolution
updateJson<NxJsonConfiguration>('nx.json', (nxJson) => {
nxJson.release = {
groups: {
default: {
// @proj/source will be added as a project by the verdaccio setup, but we aren't versioning or publishing it, so we exclude it here
projects: ['*', '!@proj/source'],
version: {
generator: '@nx/js:release-version',
generatorOptions: {
// Resolve the latest version from the custom registry instance, therefore finding the previously published versions
currentVersionResolver: 'registry',
currentVersionResolverMetadata: {
registry: e2eRegistryUrl,
tag: 'latest',
},
},
},
},
},
};
return nxJson;
});
// Run additional custom verdaccio instance to publish the packages to
runCLI(`generate setup-verdaccio`);
const verdaccioPort = 7190;
const customRegistryUrl = `http://localhost:${verdaccioPort}`;
const process = await runCommandUntil(
`local-registry @proj/source --port=${verdaccioPort}`,
(output) => output.includes(`warn --- http address`)
);
const versionOutput2 = runCLI(`release version premajor --preid next`); // version using semver keyword this time (and custom preid)
expect(
versionOutput2.match(/Running release version for project: my-pkg-\d*/g)
.length
).toEqual(3);
expect(
versionOutput2.match(
/Reading data for package "@proj\/my-pkg-\d*" from my-pkg-\d*\/package.json/g
).length
).toEqual(3);
// It should resolve the current version from the registry once...
expect(
versionOutput2.match(
new RegExp(
`Resolved the current version as 999.9.9 for tag "latest" from registry ${e2eRegistryUrl}`,
'g'
)
).length
).toEqual(1);
// ...and then reuse it twice
expect(
versionOutput2.match(
new RegExp(
`Using the current version 999.9.9 already resolved from the registry ${e2eRegistryUrl}`,
'g'
)
).length
).toEqual(2);
expect(
versionOutput2.match(
/New version 1000.0.0-next.0 written to my-pkg-\d*\/package.json/g
).length
).toEqual(3);
// Only one dependency relationship exists, so this log should only match once
expect(
versionOutput2.match(
/Applying new version 1000.0.0-next.0 to 1 package which depends on my-pkg-\d*/g
).length
).toEqual(1);
// publish to custom registry (not e2e registry), and a custom dist tag of "next"
const publishOutput2 = runCLI(
`release publish --registry=${customRegistryUrl} --tag=next`
);
expect(publishOutput2).toMatchInlineSnapshot(`
> NX Running target nx-release-publish for 3 projects:
- {project-name}
- {project-name}
- {project-name}
With additional flags:
--registry=${customRegistryUrl}
--tag=next
> nx run {project-name}:nx-release-publish
📦 @proj/{project-name}@1000.0.0-next.0
=== Tarball Contents ===
XXB index.js
XXXB package.json
XXB project.json
=== Tarball Details ===
name: @proj/{project-name}
version: 1000.0.0-next.0
filename: proj-{project-name}-1000.0.0-next.0.tgz
package size: XXXB
unpacked size: XXXB
shasum: {SHASUM}
integrity: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
total files: 3
Published to ${customRegistryUrl} with tag "next"
> nx run {project-name}:nx-release-publish
📦 @proj/{project-name}@1000.0.0-next.0
=== Tarball Contents ===
XXB index.js
XXXB package.json
XXB project.json
=== Tarball Details ===
name: @proj/{project-name}
version: 1000.0.0-next.0
filename: proj-{project-name}-1000.0.0-next.0.tgz
package size: XXXB
unpacked size: XXXB
shasum: {SHASUM}
integrity: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
total files: 3
Published to ${customRegistryUrl} with tag "next"
> nx run {project-name}:nx-release-publish
📦 @proj/{project-name}@1000.0.0-next.0
=== Tarball Contents ===
XXB index.js
XXXB package.json
XXB project.json
=== Tarball Details ===
name: @proj/{project-name}
version: 1000.0.0-next.0
filename: proj-{project-name}-1000.0.0-next.0.tgz
package size: XXXB
unpacked size: XXXB
shasum: {SHASUM}
integrity: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
total files: 3
Published to ${customRegistryUrl} with tag "next"
> NX Successfully ran target nx-release-publish for 3 projects
`);
expect(
execSync(
`npm view @proj/${pkg1}@next version --registry=${customRegistryUrl}`
)
.toString()
.trim()
).toEqual('1000.0.0-next.0');
expect(
execSync(
`npm view @proj/${pkg2}@next version --registry=${customRegistryUrl}`
)
.toString()
.trim()
).toEqual('1000.0.0-next.0');
expect(
execSync(
`npm view @proj/${pkg3}@next version --registry=${customRegistryUrl}`
)
.toString()
.trim()
).toEqual('1000.0.0-next.0');
// port and process cleanup
await killProcessAndPorts(process.pid, verdaccioPort);
}, 500000);
});

13
e2e/release/tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["node", "jest"]
},
"include": [],
"files": [],
"references": [
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@ -0,0 +1,20 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"]
},
"include": [
"**/*.test.ts",
"**/*.spec.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx",
"**/*.d.ts",
"jest.config.ts"
]
}

View File

@ -119,10 +119,11 @@
"@types/js-yaml": "^4.0.5",
"@types/marked": "^2.0.0",
"@types/node": "18.16.9",
"@types/npm-package-arg": "6.1.1",
"@types/prettier": "^2.6.2",
"@types/react": "18.2.14",
"@types/react-dom": "18.2.6",
"@types/semver": "^7.5.0",
"@types/semver": "^7.5.2",
"@types/tar-stream": "^2.2.2",
"@types/tmp": "^0.2.0",
"@types/yargs": "^17.0.10",
@ -144,6 +145,7 @@
"chalk": "^4.1.0",
"cli-cursor": "3.1.0",
"cli-spinners": "2.6.1",
"columnify": "^1.6.0",
"confusing-browser-globals": "^1.0.9",
"conventional-changelog-cli": "^2.0.23",
"convert-source-map": "^2.0.0",
@ -197,6 +199,7 @@
"jasmine-spec-reporter": "~4.2.1",
"jest": "29.4.3",
"jest-config": "^29.4.1",
"jest-diff": "^29.4.1",
"jest-environment-jsdom": "29.4.3",
"jest-environment-node": "^29.4.1",
"jest-resolve": "^29.4.1",
@ -225,6 +228,7 @@
"next-sitemap": "^3.1.10",
"ng-packagr": "~16.2.0",
"node-fetch": "^2.6.7",
"npm-package-arg": "11.0.1",
"nx": "16.9.0-beta.1",
"nx-cloud": "16.4.0",
"octokit": "^2.0.14",

View File

@ -17,6 +17,12 @@
"schema": "./src/executors/node/schema.json",
"description": "Execute a Node application."
},
"release-publish": {
"implementation": "./src/executors/release-publish/release-publish.impl",
"schema": "./src/executors/release-publish/schema.json",
"description": "DO NOT INVOKE DIRECTLY WITH `nx run`. Use `nx release publish` instead.",
"hidden": true
},
"verdaccio": {
"implementation": "./src/executors/verdaccio/verdaccio.impl",
"schema": "./src/executors/verdaccio/schema.json",

View File

@ -59,6 +59,12 @@
"x-type": "library",
"description": "Convert a TypeScript library to compile with SWC."
},
"release-version": {
"factory": "./src/generators/release-version/release-version#releaseVersionGenerator",
"schema": "./src/generators/release-version/schema.json",
"description": "DO NOT INVOKE DIRECTLY WITH `nx generate`. Use `nx release version` instead.",
"hidden": true
},
"setup-verdaccio": {
"factory": "./src/generators/setup-verdaccio/generator#setupVerdaccio",
"schema": "./src/generators/setup-verdaccio/schema.json",

View File

@ -44,14 +44,18 @@
"babel-plugin-macros": "^2.8.0",
"babel-plugin-transform-typescript-metadata": "^0.3.1",
"chalk": "^4.1.0",
"columnify": "^1.6.0",
"detect-port": "^1.5.1",
"fast-glob": "3.2.7",
"fs-extra": "^11.1.0",
"npm-package-arg": "11.0.1",
"npm-run-path": "^4.0.1",
"ts-node": "10.9.1",
"tsconfig-paths": "^4.1.2",
"ignore": "^5.0.4",
"js-tokens": "^4.0.0",
"minimatch": "3.0.5",
"ora": "5.3.0",
"semver": "7.5.3",
"source-map-support": "0.5.19",
"tslib": "^2.3.0",

View File

@ -0,0 +1,30 @@
// Taken from https://github.com/npm/cli/blob/c736b622b8504b07f5a19f631ade42dd40063269/lib/utils/format-bytes.js
// Convert bytes to printable output, for file reporting in tarballs
// Only supports up to GB because that's way larger than anything the registry
// supports anyways.
export const formatBytes = (bytes, space = true) => {
let spacer = '';
if (space) {
spacer = ' ';
}
if (bytes < 1000) {
// B
return `${bytes}${spacer}B`;
}
if (bytes < 1000000) {
// kB
return `${(bytes / 1000).toFixed(1)}${spacer}kB`;
}
if (bytes < 1000000000) {
// MB
return `${(bytes / 1000000).toFixed(1)}${spacer}MB`;
}
// GB
return `${(bytes / 1000000000).toFixed(1)}${spacer}GB`;
};

View File

@ -0,0 +1,76 @@
// Adapted from https://github.com/npm/cli/blob/c736b622b8504b07f5a19f631ade42dd40063269/lib/utils/tar.js
import * as chalk from 'chalk';
import * as columnify from 'columnify';
import { formatBytes } from './format-bytes';
export const logTar = (tarball, opts = {}) => {
// @ts-ignore
const { unicode = true } = opts;
console.log('');
console.log(
`${unicode ? '📦 ' : 'package:'} ${tarball.name}@${tarball.version}`
);
console.log(chalk.magenta('=== Tarball Contents ==='));
if (tarball.files.length) {
console.log('');
const columnData = columnify(
tarball.files
.map((f) => {
const bytes = formatBytes(f.size, false);
return /^node_modules\//.test(f.path)
? null
: { path: f.path, size: `${bytes}` };
})
.filter((f) => f),
{
include: ['size', 'path'],
showHeaders: false,
}
);
columnData.split('\n').forEach((line) => {
console.log(line);
});
}
if (tarball.bundled.length) {
console.log(chalk.magenta('=== Bundled Dependencies ==='));
tarball.bundled.forEach((name) => console.log('', name));
}
console.log(chalk.magenta('=== Tarball Details ==='));
console.log(
columnify(
[
{ name: 'name:', value: tarball.name },
{ name: 'version:', value: tarball.version },
tarball.filename && { name: 'filename:', value: tarball.filename },
{ name: 'package size:', value: formatBytes(tarball.size) },
{ name: 'unpacked size:', value: formatBytes(tarball.unpackedSize) },
{ name: 'shasum:', value: tarball.shasum },
{
name: 'integrity:',
value:
tarball.integrity.toString().slice(0, 20) +
'[...]' +
tarball.integrity.toString().slice(80),
},
tarball.bundled.length && {
name: 'bundled deps:',
value: tarball.bundled.length,
},
tarball.bundled.length && {
name: 'bundled files:',
value: tarball.entryCount - tarball.files.length,
},
tarball.bundled.length && {
name: 'own files:',
value: tarball.files.length,
},
{ name: 'total files:', value: tarball.entryCount },
].filter((x) => x),
{
include: ['name', 'value'],
showHeaders: false,
}
)
);
console.log('', '');
};

View File

@ -0,0 +1,119 @@
import { ExecutorContext, joinPathFragments, readJsonFile } from '@nx/devkit';
import { execSync } from 'child_process';
import { env as appendLocalEnv } from 'npm-run-path';
import { logTar } from './log-tar';
import { PublishExecutorSchema } from './schema';
const LARGE_BUFFER = 1024 * 1000000;
function processEnv(color: boolean) {
const env = {
...process.env,
...appendLocalEnv(),
};
if (color) {
env.FORCE_COLOR = `${color}`;
}
return env;
}
export default async function runExecutor(
options: PublishExecutorSchema,
context: ExecutorContext
) {
const projectConfig =
context.projectsConfigurations!.projects[context.projectName!]!;
const packageRoot = joinPathFragments(
context.root,
options.packageRoot ?? projectConfig.root
);
const npmPublishCommandSegments = [`npm publish --json`];
if (options.registry) {
npmPublishCommandSegments.push(`--registry=${options.registry}`);
}
if (options.tag) {
npmPublishCommandSegments.push(`--tag=${options.tag}`);
}
// Resolve values using the `npm config` command so that things like environment variables and `publishConfig`s are accounted for
const registry =
options.registry ?? execSync(`npm config get registry`).toString().trim();
const tag = options.tag ?? execSync(`npm config get tag`).toString().trim();
try {
const output = execSync(npmPublishCommandSegments.join(' '), {
maxBuffer: LARGE_BUFFER,
env: processEnv(true),
cwd: packageRoot,
stdio: ['ignore', 'pipe', 'pipe'],
});
const stdoutData = JSON.parse(output.toString());
// If npm workspaces are in use, the publish output will nest the data under the package name, so we normalize it first
const normalizedStdoutData = stdoutData[context.projectName!] ?? stdoutData;
logTar(normalizedStdoutData);
console.log(`Published to ${registry} with tag "${tag}"`);
return {
success: true,
};
} catch (err) {
try {
const projectPackageJson = readJsonFile(
joinPathFragments(packageRoot, 'package.json')
);
const name = projectPackageJson.name;
const currentVersion = projectPackageJson.version;
const stdoutData = JSON.parse(err.stdout?.toString() || '{}');
if (
stdoutData.error?.code === 'EPUBLISHCONFLICT' ||
(stdoutData.error?.code === 'E403' &&
stdoutData.error?.body?.error?.includes(
'You cannot publish over the previously published versions'
))
) {
// If package and project name match, make it terser
let packageTxt =
name === context.projectName
? `package "${name}"`
: `package "${name}" from project "${context.projectName}"`;
console.warn(
`Skipping ${packageTxt}, as v${currentVersion} has already been published to ${registry} with tag "${tag}"`
);
return {
success: true,
};
}
console.error('npm publish error:');
if (stdoutData.error.summary) {
console.error(stdoutData.error.summary);
}
if (stdoutData.error.detail) {
console.error(stdoutData.error.detail);
}
return {
success: false,
};
} catch (err) {
// npm v9 onwards seems to guarantee stdout will be well formed JSON when --json is used, so maybe we need to
// specify that as minimum supported version? (comes with node 18 and 20 by default)
console.error(
'Something unexpected went wrong when processing the npm publish output\n',
err
);
return {
success: false,
};
}
}
}

View File

@ -0,0 +1,5 @@
export interface PublishExecutorSchema {
packageRoot?: string;
registry?: string;
tag?: string;
}

View File

@ -0,0 +1,22 @@
{
"$schema": "http://json-schema.org/schema",
"version": 2,
"title": "Implementation details of `nx release publish`",
"description": "DO NOT INVOKE DIRECTLY WITH `nx run`. Use `nx release publish` instead.",
"type": "object",
"properties": {
"packageRoot": {
"type": "string",
"description": "The root directory of the directory (containing a manifest file at its root) to publish. Defaults to the project root."
},
"registry": {
"type": "string",
"description": "The registry to publish the package to."
},
"tag": {
"type": "string",
"description": "The distribution tag to apply to the published package."
}
},
"required": []
}

View File

@ -0,0 +1,148 @@
import { ProjectGraph, Tree, readJson } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { releaseVersionGenerator } from './release-version';
import { createWorkspaceWithPackageDependencies } from './test-utils/create-workspace-with-package-dependencies';
// Using the daemon in unit tests would cause jest to never exit
process.env.NX_DAEMON = 'false';
describe('release-version', () => {
let tree: Tree;
let projectGraph: ProjectGraph;
beforeEach(() => {
tree = createTreeWithEmptyWorkspace();
projectGraph = createWorkspaceWithPackageDependencies(tree, {
'my-lib': {
projectRoot: 'libs/my-lib',
packageName: 'my-lib',
version: '0.0.1',
packageJsonPath: 'libs/my-lib/package.json',
localDependencies: [],
},
'project-with-dependency-on-my-pkg': {
projectRoot: 'libs/project-with-dependency-on-my-pkg',
packageName: 'project-with-dependency-on-my-pkg',
version: '0.0.1',
packageJsonPath: 'libs/project-with-dependency-on-my-pkg/package.json',
localDependencies: [
{
projectName: 'my-lib',
dependencyCollection: 'dependencies',
version: '0.0.1',
},
],
},
'project-with-devDependency-on-my-pkg': {
projectRoot: 'libs/project-with-devDependency-on-my-pkg',
packageName: 'project-with-devDependency-on-my-pkg',
version: '0.0.1',
packageJsonPath:
'libs/project-with-devDependency-on-my-pkg/package.json',
localDependencies: [
{
projectName: 'my-lib',
dependencyCollection: 'devDependencies',
version: '0.0.1',
},
],
},
});
});
it(`should work with semver keywords and exact semver versions`, async () => {
expect(readJson(tree, 'libs/my-lib/package.json').version).toEqual('0.0.1');
await releaseVersionGenerator(tree, {
projects: Object.values(projectGraph.nodes), // version all projects
projectGraph,
specifier: 'major',
currentVersionResolver: 'disk',
});
expect(readJson(tree, 'libs/my-lib/package.json').version).toEqual('1.0.0');
await releaseVersionGenerator(tree, {
projects: Object.values(projectGraph.nodes), // version all projects
projectGraph,
specifier: 'minor',
currentVersionResolver: 'disk',
});
expect(readJson(tree, 'libs/my-lib/package.json').version).toEqual('1.1.0');
await releaseVersionGenerator(tree, {
projects: Object.values(projectGraph.nodes), // version all projects
projectGraph,
specifier: 'patch',
currentVersionResolver: 'disk',
});
expect(readJson(tree, 'libs/my-lib/package.json').version).toEqual('1.1.1');
await releaseVersionGenerator(tree, {
projects: Object.values(projectGraph.nodes), // version all projects
projectGraph,
specifier: '1.2.3', // exact version
currentVersionResolver: 'disk',
});
expect(readJson(tree, 'libs/my-lib/package.json').version).toEqual('1.2.3');
});
it(`should apply the updated version to the projects, including updating dependents`, async () => {
await releaseVersionGenerator(tree, {
projects: Object.values(projectGraph.nodes), // version all projects
projectGraph,
specifier: 'major',
currentVersionResolver: 'disk',
});
expect(readJson(tree, 'libs/my-lib/package.json')).toMatchInlineSnapshot(`
{
"name": "my-lib",
"version": "1.0.0",
}
`);
expect(
readJson(tree, 'libs/project-with-dependency-on-my-pkg/package.json')
).toMatchInlineSnapshot(`
{
"dependencies": {
"my-lib": "1.0.0",
},
"name": "project-with-dependency-on-my-pkg",
"version": "1.0.0",
}
`);
expect(
readJson(tree, 'libs/project-with-devDependency-on-my-pkg/package.json')
).toMatchInlineSnapshot(`
{
"devDependencies": {
"my-lib": "1.0.0",
},
"name": "project-with-devDependency-on-my-pkg",
"version": "1.0.0",
}
`);
});
describe('not all given projects have package.json files', () => {
beforeEach(() => {
tree.delete('libs/my-lib/package.json');
});
it(`should error with guidance when not all of the given projects are appropriate for JS versioning`, async () => {
await expect(
releaseVersionGenerator(tree, {
projects: Object.values(projectGraph.nodes), // version all projects
projectGraph,
specifier: 'major',
currentVersionResolver: 'disk',
})
).rejects.toThrowErrorMatchingInlineSnapshot(`
"The project "my-lib" does not have a package.json available at libs/my-lib/package.json.
To fix this you will either need to add a package.json file at that location, or configure "release" within your nx.json to exclude "my-lib" from the current release group, or amend the packageRoot configuration to point to where the package.json should be."
`);
});
});
});

View File

@ -0,0 +1,219 @@
import {
Tree,
joinPathFragments,
output,
readJson,
updateJson,
workspaceRoot,
writeJson,
} from '@nx/devkit';
import * as chalk from 'chalk';
import { exec } from 'child_process';
import { deriveNewSemverVersion } from 'nx/src/command-line/release/version';
import { interpolate } from 'nx/src/tasks-runner/utils';
import * as ora from 'ora';
import { relative } from 'path';
import { ReleaseVersionGeneratorSchema } from './schema';
import { resolveLocalPackageDependencies } from './utils/resolve-local-package-dependencies';
export async function releaseVersionGenerator(
tree: Tree,
options: ReleaseVersionGeneratorSchema
) {
const projects = options.projects;
// Resolve any custom package roots for each project upfront as they will need to be reused during dependency resolution
const projectNameToPackageRootMap = new Map<string, string>();
for (const project of projects) {
projectNameToPackageRootMap.set(
project.name,
// Default to the project root if no custom packageRoot
!options.packageRoot
? project.data.root
: interpolate(options.packageRoot, {
workspaceRoot: '',
projectRoot: project.data.root,
projectName: project.name,
})
);
}
let currentVersion: string;
for (const project of projects) {
const projectName = project.name;
const packageRoot = projectNameToPackageRootMap.get(projectName);
const packageJsonPath = joinPathFragments(packageRoot, 'package.json');
const workspaceRelativePackageJsonPath = relative(
workspaceRoot,
packageJsonPath
);
const color = getColor(projectName);
const log = (msg: string) => {
console.log(color.instance.bold(projectName) + ' ' + msg);
};
if (!tree.exists(packageJsonPath)) {
throw new Error(
`The project "${projectName}" does not have a package.json available at ${workspaceRelativePackageJsonPath}.
To fix this you will either need to add a package.json file at that location, or configure "release" within your nx.json to exclude "${projectName}" from the current release group, or amend the packageRoot configuration to point to where the package.json should be.`
);
}
output.logSingleLine(
`Running release version for project: ${color.instance.bold(
project.name
)}`
);
const projectPackageJson = readJson(tree, packageJsonPath);
log(
`🔍 Reading data for package "${projectPackageJson.name}" from ${workspaceRelativePackageJsonPath}`
);
const { name: packageName, version: currentVersionFromDisk } =
projectPackageJson;
switch (options.currentVersionResolver) {
case 'registry': {
const metadata = options.currentVersionResolverMetadata;
const registry = metadata?.registry ?? 'https://registry.npmjs.org';
const tag = metadata?.tag ?? 'latest';
// If the currentVersionResolver is set to registry, we only want to make the request once for the whole batch of projects
if (!currentVersion) {
const spinner = ora(
`${Array.from(new Array(projectName.length + 3)).join(
' '
)}Resolving the current version for tag "${tag}" on ${registry}`
);
spinner.color =
color.spinnerColor as typeof colors[number]['spinnerColor'];
spinner.start();
// Must be non-blocking async to allow spinner to render
currentVersion = await new Promise<string>((resolve, reject) => {
exec(
`npm view ${packageName} version --registry=${registry} --tag=${tag}`,
(error, stdout, stderr) => {
if (error) {
return reject(error);
}
if (stderr) {
return reject(stderr);
}
return resolve(stdout.trim());
}
);
});
spinner.stop();
log(
`📄 Resolved the current version as ${currentVersion} for tag "${tag}" from registry ${registry}`
);
} else {
log(
`📄 Using the current version ${currentVersion} already resolved from the registry ${registry}`
);
}
break;
}
case 'disk':
currentVersion = currentVersionFromDisk;
log(
`📄 Resolved the current version as ${currentVersion} from ${packageJsonPath}`
);
break;
default:
throw new Error(
`Invalid value for options.currentVersionResolver: ${options.currentVersionResolver}`
);
}
// Resolve any local package dependencies for this project (before applying the new version)
const localPackageDependencies = resolveLocalPackageDependencies(
tree,
options.projectGraph,
projects,
projectNameToPackageRootMap
);
const newVersion = deriveNewSemverVersion(
currentVersion,
options.specifier,
options.preid
);
writeJson(tree, packageJsonPath, {
...projectPackageJson,
version: newVersion,
});
log(
`✍️ New version ${newVersion} written to ${workspaceRelativePackageJsonPath}`
);
const dependentProjects = Object.values(localPackageDependencies)
.filter((localPackageDependencies) => {
return localPackageDependencies.some(
(localPackageDependency) =>
localPackageDependency.target === project.name
);
})
.flat();
if (dependentProjects.length > 0) {
log(
`✍️ Applying new version ${newVersion} to ${
dependentProjects.length
} ${
dependentProjects.length > 1
? 'packages which depend'
: 'package which depends'
} on ${project.name}`
);
}
for (const dependentProject of dependentProjects) {
updateJson(
tree,
joinPathFragments(
projectNameToPackageRootMap.get(dependentProject.source),
'package.json'
),
(json) => {
json[dependentProject.dependencyCollection][packageName] = newVersion;
return json;
}
);
}
}
}
export default releaseVersionGenerator;
const colors = [
{ instance: chalk.green, spinnerColor: 'green' },
{ instance: chalk.greenBright, spinnerColor: 'green' },
{ instance: chalk.red, spinnerColor: 'red' },
{ instance: chalk.redBright, spinnerColor: 'red' },
{ instance: chalk.cyan, spinnerColor: 'cyan' },
{ instance: chalk.cyanBright, spinnerColor: 'cyan' },
{ instance: chalk.yellow, spinnerColor: 'yellow' },
{ instance: chalk.yellowBright, spinnerColor: 'yellow' },
{ instance: chalk.magenta, spinnerColor: 'magenta' },
{ instance: chalk.magentaBright, spinnerColor: 'magenta' },
] as const;
function getColor(projectName: string) {
let code = 0;
for (let i = 0; i < projectName.length; ++i) {
code += projectName.charCodeAt(i);
}
const colorIndex = code % colors.length;
return colors[colorIndex];
}

View File

@ -0,0 +1 @@
export { ReleaseVersionGeneratorSchema } from 'nx/src/command-line/release/version';

View File

@ -0,0 +1,45 @@
{
"$schema": "http://json-schema.org/schema",
"$id": "NxJSReleaseVersionGenerator",
"cli": "nx",
"title": "Implementation details of `nx release version`",
"description": "DO NOT INVOKE DIRECTLY WITH `nx generate`. Use `nx release version` instead.",
"type": "object",
"properties": {
"projects": {
"type": "array",
"description": "The ProjectGraphProjectNodes being versioned in the current execution.",
"items": {
"type": "object"
}
},
"projectGraph": {
"type": "object",
"description": "ProjectGraph instance"
},
"specifier": {
"type": "string",
"description": "Exact version or semver keyword to apply to the selected release group. NOTE: This should be set on the release group level, not the project level."
},
"preid": {
"type": "string",
"description": "The optional prerelease identifier to apply to the version, in the case that specifier has been set to prerelease."
},
"packageRoot": {
"type": "string",
"description": "The root directory of the directory (containing a manifest file at its root) to publish. Defaults to the project root"
},
"currentVersionResolver": {
"type": "string",
"default": "disk",
"description": "Which approach to use to determine the current version of the project.",
"enum": ["registry", "disk"]
},
"currentVersionResolverMetadata": {
"type": "object",
"description": "Additional metadata to pass to the current version resolver.",
"default": {}
}
},
"required": ["projects", "projectGraph", "specifier"]
}

View File

@ -0,0 +1,62 @@
import { ProjectGraph, Tree, writeJson } from '@nx/devkit';
interface ProjectAndPackageData {
[projectName: string]: {
projectRoot: string;
packageName: string;
version: string;
packageJsonPath: string;
localDependencies: {
projectName: string;
dependencyCollection:
| 'dependencies'
| 'devDependencies'
| 'optionalDependencies';
version: string;
}[];
};
}
export function createWorkspaceWithPackageDependencies(
tree: Tree,
projectAndPackageData: ProjectAndPackageData
): ProjectGraph {
const projectGraph: ProjectGraph = {
nodes: {},
dependencies: {},
};
for (const [projectName, data] of Object.entries(projectAndPackageData)) {
const packageJsonContents = {
name: data.packageName,
version: data.version,
};
for (const dependency of data.localDependencies) {
const dependencyPackageName =
projectAndPackageData[dependency.projectName].packageName;
packageJsonContents[dependency.dependencyCollection] = {
...packageJsonContents[dependency.dependencyCollection],
[dependencyPackageName]: dependency.version,
};
}
// add the project and its nx project level dependencies to the projectGraph
projectGraph.nodes[projectName] = {
name: projectName,
type: 'lib',
data: {
root: data.projectRoot,
},
};
projectGraph.dependencies[projectName] = data.localDependencies.map(
(dependency) => ({
source: projectName,
target: dependency.projectName,
type: 'static',
})
);
// create the package.json in the tree
writeJson(tree, data.packageJsonPath, packageJsonContents);
}
return projectGraph;
}

View File

@ -0,0 +1,43 @@
import { joinPathFragments } from '@nx/devkit';
import { PackageJson } from 'nx/src/utils/package-json';
export class Package {
name: string;
version: string;
location: string;
constructor(
private packageJson: PackageJson,
workspaceRoot: string,
workspaceRelativeLocation: string
) {
this.name = packageJson.name;
this.version = packageJson.version;
this.location = joinPathFragments(workspaceRoot, workspaceRelativeLocation);
}
getLocalDependency(depName: string): {
collection: 'dependencies' | 'devDependencies' | 'optionalDependencies';
spec: string;
} | null {
if (this.packageJson.dependencies?.[depName]) {
return {
collection: 'dependencies',
spec: this.packageJson.dependencies[depName],
};
}
if (this.packageJson.devDependencies?.[depName]) {
return {
collection: 'devDependencies',
spec: this.packageJson.devDependencies[depName],
};
}
if (this.packageJson.optionalDependencies?.[depName]) {
return {
collection: 'optionalDependencies',
spec: this.packageJson.optionalDependencies[depName],
};
}
return null;
}
}

View File

@ -0,0 +1,410 @@
import { ProjectGraph, Tree, workspaceRoot } from '@nx/devkit';
import { createTree } from '@nx/devkit/testing';
import { createWorkspaceWithPackageDependencies } from '../test-utils/create-workspace-with-package-dependencies';
import { resolveLocalPackageDependencies } from './resolve-local-package-dependencies';
expect.addSnapshotSerializer({
serialize: (str: string) => {
// replace all instances of the workspace root with a placeholder to ensure consistency
return JSON.stringify(
str.replaceAll(
new RegExp(workspaceRoot.replace(/\\/g, '\\\\'), 'g'),
'<workspaceRoot>'
)
);
},
test(val: string) {
return (
val != null && typeof val === 'string' && val.includes(workspaceRoot)
);
},
});
describe('resolveLocalPackageDependencies()', () => {
let tree: Tree;
let projectGraph: ProjectGraph;
describe('fixed versions', () => {
beforeEach(() => {
tree = createTree();
projectGraph = createWorkspaceWithPackageDependencies(tree, {
projectA: {
projectRoot: 'packages/projectA',
packageName: 'projectA',
version: '1.0.0',
packageJsonPath: 'packages/projectA/package.json',
localDependencies: [
{
projectName: 'projectB',
dependencyCollection: 'dependencies',
version: '1.0.0',
},
{
projectName: 'projectC',
dependencyCollection: 'devDependencies',
version: '1.0.0',
},
{
projectName: 'projectD',
dependencyCollection: 'optionalDependencies',
version: '1.0.0',
},
],
},
projectB: {
projectRoot: 'packages/projectB',
packageName: 'projectB',
version: '1.0.0',
packageJsonPath: 'packages/projectB/package.json',
localDependencies: [],
},
projectC: {
projectRoot: 'packages/projectC',
packageName: 'projectC',
version: '1.0.0',
packageJsonPath: 'packages/projectC/package.json',
localDependencies: [],
},
projectD: {
projectRoot: 'packages/projectD',
packageName: 'projectD',
version: '1.0.0',
packageJsonPath: 'packages/projectD/package.json',
localDependencies: [],
},
});
});
it('should resolve local dependencies based on fixed semver versions', () => {
const allProjects = Object.values(projectGraph.nodes);
const projectNameToPackageRootMap = new Map<string, string>();
for (const project of allProjects) {
projectNameToPackageRootMap.set(project.name, project.data.root);
}
const result = resolveLocalPackageDependencies(
tree,
projectGraph,
allProjects,
projectNameToPackageRootMap
);
expect(result).toMatchInlineSnapshot(`
{
"projectA": [
{
"dependencyCollection": "dependencies",
"source": "projectA",
"target": "projectB",
"type": "static",
},
{
"dependencyCollection": "devDependencies",
"source": "projectA",
"target": "projectC",
"type": "static",
},
{
"dependencyCollection": "optionalDependencies",
"source": "projectA",
"target": "projectD",
"type": "static",
},
],
}
`);
});
});
describe(`"file:", "link:" and "workspace:" protocols`, () => {
beforeEach(() => {
tree = createTree();
projectGraph = createWorkspaceWithPackageDependencies(tree, {
projectA: {
projectRoot: 'packages/projectA',
packageName: 'projectA',
version: '1.0.0',
packageJsonPath: 'packages/projectA/package.json',
localDependencies: [
{
projectName: 'projectB',
dependencyCollection: 'dependencies',
version: 'file:../projectB',
},
{
projectName: 'projectC',
dependencyCollection: 'devDependencies',
version: 'workspace:*',
},
{
projectName: 'projectD',
dependencyCollection: 'optionalDependencies',
version: 'workspace:../projectD',
},
{
projectName: 'projectE',
dependencyCollection: 'dependencies',
version: 'link:../projectE', // yarn classic equivalent of `file:`
},
],
},
projectB: {
projectRoot: 'packages/projectB',
packageName: 'projectB',
version: '1.0.0',
packageJsonPath: 'packages/projectB/package.json',
localDependencies: [
{
projectName: 'projectC',
dependencyCollection: 'dependencies',
version: 'workspace:1.0.0',
},
{
projectName: 'projectD',
dependencyCollection: 'dependencies',
/**
* Wrong version is specified, shouldn't be resolved as a local package dependency
* (pnpm will likely error on this at install time anyway, so it's unlikely
* to occur in a real-world setup)
*/
version: 'workspace:2.0.0',
},
],
},
projectC: {
projectRoot: 'packages/projectC',
packageName: 'projectC',
version: '1.0.0',
packageJsonPath: 'packages/projectC/package.json',
localDependencies: [],
},
projectD: {
projectRoot: 'packages/projectD',
packageName: 'projectD',
version: '1.0.0',
packageJsonPath: 'packages/projectD/package.json',
localDependencies: [],
},
projectE: {
projectRoot: 'packages/projectE',
packageName: 'projectE',
version: '1.0.0',
packageJsonPath: 'packages/projectE/package.json',
localDependencies: [],
},
});
});
it('should resolve local dependencies based on file, link and workspace protocols', () => {
const allProjects = Object.values(projectGraph.nodes);
const projectNameToPackageRootMap = new Map<string, string>();
for (const project of allProjects) {
projectNameToPackageRootMap.set(project.name, project.data.root);
}
const result = resolveLocalPackageDependencies(
tree,
projectGraph,
allProjects,
projectNameToPackageRootMap
);
expect(result).toMatchInlineSnapshot(`
{
"projectA": [
{
"dependencyCollection": "dependencies",
"source": "projectA",
"target": "projectB",
"type": "static",
},
{
"dependencyCollection": "devDependencies",
"source": "projectA",
"target": "projectC",
"type": "static",
},
{
"dependencyCollection": "optionalDependencies",
"source": "projectA",
"target": "projectD",
"type": "static",
},
{
"dependencyCollection": "dependencies",
"source": "projectA",
"target": "projectE",
"type": "static",
},
],
"projectB": [
{
"dependencyCollection": "dependencies",
"source": "projectB",
"target": "projectC",
"type": "static",
},
],
}
`);
});
});
describe('npm scopes', () => {
beforeEach(() => {
tree = createTree();
projectGraph = createWorkspaceWithPackageDependencies(tree, {
projectA: {
projectRoot: 'packages/projectA',
packageName: '@acme/projectA',
version: '1.0.0',
packageJsonPath: 'packages/projectA/package.json',
localDependencies: [
{
projectName: 'projectB',
dependencyCollection: 'dependencies',
version: '1.0.0',
},
],
},
projectB: {
projectRoot: 'packages/projectB',
packageName: '@acme/projectB',
version: '1.0.0',
packageJsonPath: 'packages/projectB/package.json',
localDependencies: [],
},
});
});
it('should resolve local dependencies which contain npm scopes', () => {
const allProjects = Object.values(projectGraph.nodes);
const projectNameToPackageRootMap = new Map<string, string>();
for (const project of allProjects) {
projectNameToPackageRootMap.set(project.name, project.data.root);
}
const result = resolveLocalPackageDependencies(
tree,
projectGraph,
allProjects,
projectNameToPackageRootMap
);
expect(result).toMatchInlineSnapshot(`
{
"projectA": [
{
"dependencyCollection": "dependencies",
"source": "projectA",
"target": "projectB",
"type": "static",
},
],
}
`);
});
});
describe('custom package roots', () => {
beforeEach(() => {
tree = createTree();
projectGraph = createWorkspaceWithPackageDependencies(tree, {
projectA: {
projectRoot: 'packages/projectA',
packageName: '@acme/projectA',
version: '1.0.0',
// Custom package.json path coming from a build/dist location, not the project root
packageJsonPath: 'build/packages/projectA/package.json',
localDependencies: [
{
projectName: 'projectB',
dependencyCollection: 'dependencies',
version: '1.0.0',
},
{
projectName: 'projectC',
dependencyCollection: 'dependencies',
version: '1.0.0',
},
{
projectName: 'projectD',
dependencyCollection: 'dependencies',
// relative from projectA's package.json path to projectD's package.json path
version: 'file:../../../packages/projectD',
},
],
},
projectB: {
projectRoot: 'packages/projectB',
packageName: '@acme/projectB',
version: '1.0.0',
// Custom package.json path coming from a build/dist location, not the project root
packageJsonPath: 'build/packages/projectB/package.json',
localDependencies: [],
},
projectC: {
projectRoot: 'packages/projectC',
packageName: '@acme/projectC',
version: '1.0.0',
// Standard package.json path coming from the project root
packageJsonPath: 'packages/projectC/package.json',
localDependencies: [],
},
projectD: {
projectRoot: 'packages/projectD',
packageName: 'projectD',
version: '1.0.0',
// Standard package.json path coming from the project root
packageJsonPath: 'packages/projectD/package.json',
localDependencies: [],
},
});
});
it('should resolve local dependencies using custom package roots', () => {
const allProjects = Object.values(projectGraph.nodes);
const projectNameToPackageRootMap = new Map<string, string>();
projectNameToPackageRootMap.set('projectA', 'build/packages/projectA');
projectNameToPackageRootMap.set('projectB', 'build/packages/projectB');
projectNameToPackageRootMap.set('projectC', 'packages/projectC');
projectNameToPackageRootMap.set('projectD', 'packages/projectD');
const result = resolveLocalPackageDependencies(
tree,
projectGraph,
allProjects,
projectNameToPackageRootMap
);
expect(result).toMatchInlineSnapshot(`
{
"projectA": [
{
"dependencyCollection": "dependencies",
"source": "projectA",
"target": "projectB",
"type": "static",
},
{
"dependencyCollection": "dependencies",
"source": "projectA",
"target": "projectC",
"type": "static",
},
{
"dependencyCollection": "dependencies",
"source": "projectA",
"target": "projectD",
"type": "static",
},
],
}
`);
});
});
});

View File

@ -0,0 +1,106 @@
import {
ProjectGraph,
ProjectGraphDependency,
ProjectGraphProjectNode,
Tree,
joinPathFragments,
readJson,
workspaceRoot,
} from '@nx/devkit';
import { PackageJson } from 'nx/src/utils/package-json';
import { satisfies } from 'semver';
import { Package } from './package';
import { resolveVersionSpec } from './resolve-version-spec';
interface LocalPackageDependency extends ProjectGraphDependency {
dependencyCollection:
| 'dependencies'
| 'devDependencies'
| 'optionalDependencies';
// we don't currently manage peer dependencies
}
export function resolveLocalPackageDependencies(
tree: Tree,
projectGraph: ProjectGraph,
projects: ProjectGraphProjectNode[],
projectNameToPackageRootMap: Map<string, string>
): Record<string, LocalPackageDependency[]> {
const localPackageDependencies: Record<string, LocalPackageDependency[]> = {};
const projectNodeToPackageMap = new Map<ProjectGraphProjectNode, Package>();
// Iterate through the projects being released and resolve any relevant package.json data
for (const projectNode of projects) {
// Resolve the package.json path for the project, taking into account any custom packageRoot settings
const packageRoot = projectNameToPackageRootMap.get(projectNode.name);
if (!packageRoot) {
continue;
}
const packageJsonPath = joinPathFragments(packageRoot, 'package.json');
if (!tree.exists(packageJsonPath)) {
continue;
}
const packageJson = readJson(tree, packageJsonPath) as PackageJson;
const pkg = new Package(packageJson, workspaceRoot, packageRoot);
projectNodeToPackageMap.set(projectNode, pkg);
}
// populate local npm package dependencies
for (const projectDeps of Object.values(projectGraph.dependencies)) {
const workspaceDeps = projectDeps.filter(
(dep) =>
!isExternalNpmDependency(dep.target) &&
!isExternalNpmDependency(dep.source)
);
for (const dep of workspaceDeps) {
const source = projectGraph.nodes[dep.source];
const target = projectGraph.nodes[dep.target];
if (
!source ||
!projectNodeToPackageMap.has(source) ||
!target ||
!projectNodeToPackageMap.has(target)
) {
// only relevant for dependencies between two workspace projects with Package objects
continue;
}
const sourcePackage = projectNodeToPackageMap.get(source);
const targetPackage = projectNodeToPackageMap.get(target);
const sourceNpmDependency = sourcePackage.getLocalDependency(
targetPackage.name
);
if (!sourceNpmDependency) {
continue;
}
const targetVersionSpec = resolveVersionSpec(
targetPackage.name,
targetPackage.version,
sourceNpmDependency.spec,
sourcePackage.location
);
const targetMatchesRequirement =
// For file: and workspace: protocols the targetVersionSpec could be a path, so we check if it matches the target's location
targetVersionSpec === targetPackage.location ||
satisfies(targetPackage.version, targetVersionSpec);
if (targetMatchesRequirement) {
// track only local package dependencies that are satisfied by the target's version
localPackageDependencies[dep.source] = [
...(localPackageDependencies[dep.source] || []),
{
...dep,
dependencyCollection: sourceNpmDependency.collection,
},
];
}
}
}
return localPackageDependencies;
}
function isExternalNpmDependency(dep: string): boolean {
return dep.startsWith('npm:');
}

View File

@ -0,0 +1,83 @@
import { join } from 'path';
import { resolveVersionSpec } from './resolve-version-spec';
describe('resolveVersionSpec()', () => {
it('should work for specific name and spec', () => {
expect(
resolveVersionSpec(
'projectA',
'1.0.4',
'^1.0.0',
'/test/packages/packageB'
)
).toEqual('^1.0.0');
});
it('should work for a workspace spec', () => {
expect(
resolveVersionSpec(
'projectA',
'1.0.4',
'workspace:^1.0.0',
'/test/packages/packageB'
)
).toEqual('^1.0.0');
});
describe('with a workspace alias', () => {
it('should work for a * workspace alias', () => {
expect(
resolveVersionSpec(
'projectA',
'1.0.4',
'workspace:*',
'/test/packages/packageB'
)
).toEqual('1.0.4');
});
it('should work for a ^ workspace alias', () => {
expect(
resolveVersionSpec(
'projectA',
'1.0.4',
'workspace:^',
'/test/packages/packageB'
)
).toEqual('^1.0.4');
});
it('should work for a ~ workspace alias', () => {
expect(
resolveVersionSpec(
'projectA',
'1.0.4',
'workspace:~',
'/test/packages/packageB'
)
).toEqual('~1.0.4');
});
});
it('should for a file reference', async () => {
expect(
resolveVersionSpec(
'projectA',
'1.0.0',
'file:../projectB',
'/packages/projectB'
)
).toEqual(expect.stringContaining(join('/packages/projectB')));
});
it('should work for a yarn classic style link reference', async () => {
expect(
resolveVersionSpec(
'projectA',
'1.0.0',
'link:../projectB',
'/packages/fuck'
)
).toEqual(expect.stringContaining(join('/packages/projectB')));
});
});

View File

@ -0,0 +1,30 @@
import * as npa from 'npm-package-arg';
export function resolveVersionSpec(
name: string,
version: string,
spec: string,
location?: string
): string {
// yarn classic uses link instead of file, normalize to match what npm expects
spec = spec.replace(/^link:/, 'file:');
// Support workspace: protocol for pnpm and yarn 2+ (https://pnpm.io/workspaces#workspace-protocol-workspace)
const isWorkspaceSpec = /^workspace:/.test(spec);
if (isWorkspaceSpec) {
spec = spec.replace(/^workspace:/, '');
// replace aliases (https://pnpm.io/workspaces#referencing-workspace-packages-through-aliases)
if (spec === '*' || spec === '^' || spec === '~') {
if (version) {
const prefix = spec === '*' ? '' : spec;
spec = `${prefix}${version}`;
} else {
spec = '*';
}
}
}
const npaResult = npa.resolve(name, spec, location);
return npaResult.fetchSpec;
}

View File

@ -49,6 +49,7 @@
"fs-extra": "^11.1.0",
"glob": "7.1.4",
"ignore": "^5.0.4",
"jest-diff": "^29.4.1",
"js-yaml": "4.1.0",
"jsonc-parser": "3.2.0",
"lines-and-columns": "~2.0.3",

View File

@ -52,6 +52,13 @@ describe('nx package.json workspaces plugin', () => {
"script": "echo",
},
},
"nx-release-publish": {
"dependsOn": [
"^nx-release-publish",
],
"executor": "@nx/js:release-publish",
"options": {},
},
},
},
},
@ -67,6 +74,13 @@ describe('nx package.json workspaces plugin', () => {
"root": "packages/lib-a",
"sourceRoot": "packages/lib-a",
"targets": {
"nx-release-publish": {
"dependsOn": [
"^nx-release-publish",
],
"executor": "@nx/js:release-publish",
"options": {},
},
"test": {
"executor": "nx:run-script",
"options": {
@ -104,6 +118,13 @@ describe('nx package.json workspaces plugin', () => {
"{projectRoot}/dist",
],
},
"nx-release-publish": {
"dependsOn": [
"^nx-release-publish",
],
"executor": "@nx/js:release-publish",
"options": {},
},
"test": {
"executor": "nx:run-script",
"options": {

View File

@ -104,7 +104,7 @@ export async function affected(
projectNames
);
} else {
await runCommand(
const status = await runCommand(
projectsWithTarget,
projectGraph,
{ nxJson },
@ -114,6 +114,9 @@ export async function affected(
extraTargetDependencies,
{ excludeTaskDependencies: false, loadDotEnvFiles: true }
);
// fix for https://github.com/nrwl/nx/issues/1666
if (process.stdin['unref']) (process.stdin as any).unref();
process.exit(status);
}
break;
}

View File

@ -10,7 +10,7 @@ export const yargsExecCommand: CommandModule = {
builder: (yargs) => withRunOneOptions(yargs),
handler: async (args) => {
try {
await (await import('./exec')).nxExecCommand(withOverrides(args));
await (await import('./exec')).nxExecCommand(withOverrides(args) as any);
process.exit(0);
} catch (e) {
process.exit(1);

View File

@ -184,7 +184,7 @@ async function promptForCollection(
}
}
function parseGeneratorString(value: string): {
export function parseGeneratorString(value: string): {
collection?: string;
generator: string;
} {

View File

@ -40,6 +40,7 @@ import { yargsShowCommand } from './show/command-object';
import { yargsWatchCommand } from './watch/command-object';
import { yargsWorkspaceLintCommand } from './workspace-lint/command-object';
import { yargsResetCommand } from './reset/command-object';
import { yargsReleaseCommand } from './release/command-object';
// Ensure that the output takes up the available width of the terminal.
yargs.wrap(yargs.terminalWidth());
@ -78,6 +79,7 @@ export const commandsObject = yargs
.command(yargsMigrateCommand)
.command(yargsNewCommand)
.command(yargsPrintAffectedCommand)
.command(yargsReleaseCommand)
.command(yargsRepairCommand)
.command(yargsReportCommand)
.command(yargsResetCommand)

View File

@ -0,0 +1,163 @@
import * as chalk from 'chalk';
import { readFileSync, writeFileSync } from 'node:fs';
import { dirSync } from 'tmp';
import { joinPathFragments, logger, output } from '../../devkit-exports';
import { ChangelogOptions } from './command-object';
import { getGitDiff, getLastGitTag, parseCommits } from './utils/git';
import {
GithubRelease,
GithubRequestConfig,
createOrUpdateGithubRelease,
generateMarkdown,
getGitHubRemote,
getGithubReleaseByTag,
resolveGithubToken,
} from './utils/github';
import { launchEditor } from './utils/launch-editor';
import { printDiff } from './utils/print-diff';
export async function changelogHandler(args: ChangelogOptions): Promise<void> {
/**
* TODO: allow the prefix and version to be controllable via config as well once we flesh out
* changelog customization, and how it will interact with independently released projects.
*/
const tagVersionPrefix = args.tagVersionPrefix ?? 'v';
const releaseVersion = `${tagVersionPrefix}${args.version}`;
const githubRemote = getGitHubRemote(args.gitRemote);
const token = await resolveGithubToken();
const githubRequestConfig: GithubRequestConfig = {
repo: githubRemote,
token,
};
const from = args.from || (await getLastGitTag());
if (!from) {
throw new Error(
`Could not determine the previous git tag, please provide and explicit reference using --from`
);
}
const to = args.to;
const rawCommits = await getGitDiff(from, args.to);
// Parse as conventional commits
const commits = parseCommits(rawCommits).filter((c) => {
const type = c.type;
// Always ignore non user-facing commits for now
// TODO: allow this filter to be configurable via config in a future release
if (type === 'feat' || type === 'fix' || type === 'perf') {
return true;
}
return false;
});
const initialMarkdown = await generateMarkdown(
commits,
releaseVersion,
githubRequestConfig
);
let finalMarkdown = initialMarkdown;
/**
* If interactive mode, make the markdown available for the user to modify in their editor of choice,
* in a similar style to git interactive rebases/merges.
*/
if (args.interactive) {
const tmpDir = dirSync().name;
const changelogPath = joinPathFragments(tmpDir, 'c.md');
writeFileSync(changelogPath, initialMarkdown);
await launchEditor(changelogPath);
finalMarkdown = readFileSync(changelogPath, 'utf-8');
}
let existingGithubReleaseForVersion: GithubRelease;
try {
existingGithubReleaseForVersion = await getGithubReleaseByTag(
githubRequestConfig,
releaseVersion
);
} 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 changesRangeText =
to === 'HEAD' ? `since ${from}` : `between ${from} and ${to}`;
if (existingGithubReleaseForVersion) {
output.log({
title: `Found existing Github release for ${chalk.white(
releaseVersion
)}, regenerating with changes ${chalk.cyan(changesRangeText)}`,
});
} else {
output.log({
title: `Creating a new Github release for ${chalk.white(
releaseVersion
)}, including changes ${chalk.cyan(changesRangeText)}`,
});
}
printReleaseLog(
releaseVersion,
githubRemote,
args.dryRun,
finalMarkdown,
existingGithubReleaseForVersion
);
if (args.dryRun) {
logger.warn(`\nNOTE: The "dryRun" flag means no changes were made.`);
} else {
await createOrUpdateGithubRelease(
githubRequestConfig,
{
version: releaseVersion,
body: finalMarkdown,
},
existingGithubReleaseForVersion
);
}
process.exit(0);
}
function printReleaseLog(
releaseVersion: string,
githubRemote: string,
isDryRun: boolean,
finalMarkdown: string,
existingGithubReleaseForVersion?: GithubRelease
) {
const logTitle = `https://github.com/${githubRemote}/releases/tag/${releaseVersion}`;
if (existingGithubReleaseForVersion) {
console.error(
`${chalk.white('UPDATE')} ${logTitle}${
isDryRun ? chalk.keyword('orange')(' [dry-run]') : ''
}`
);
} else {
console.error(
`${chalk.green('CREATE')} ${logTitle}${
isDryRun ? chalk.keyword('orange')(' [dry-run]') : ''
}`
);
}
console.log('');
printDiff('', finalMarkdown);
}

View File

@ -0,0 +1,186 @@
import { CommandModule, showHelp } from 'yargs';
import { readNxJson } from '../../project-graph/file-utils';
import {
parseCSV,
RunManyOptions,
withOverrides,
withRunManyOptions,
} from '../yargs-utils/shared-options';
export interface NxReleaseArgs {
groups?: string[];
projects?: string[];
dryRun?: boolean;
verbose?: boolean;
}
export type VersionOptions = NxReleaseArgs & {
specifier?: string;
preid?: string;
};
export type ChangelogOptions = NxReleaseArgs & {
version: string;
to: string;
from?: string;
interactive?: boolean;
gitRemote?: string;
tagVersionPrefix?: string;
};
export type PublishOptions = NxReleaseArgs &
RunManyOptions & {
registry?: string;
tag?: string;
};
export const yargsReleaseCommand: CommandModule<
Record<string, unknown>,
NxReleaseArgs
> = {
command: 'release',
describe:
'**ALPHA**: Orchestrate versioning and publishing of applications and libraries',
builder: (yargs) =>
yargs
.command(versionCommand)
.command(changelogCommand)
.command(publishCommand)
.demandCommand()
.option('groups', {
description:
'One or more release groups to target with the current command.',
type: 'string',
coerce: parseCSV,
alias: ['group', 'g'],
})
.option('projects', {
type: 'string',
alias: 'p',
coerce: parseCSV,
describe:
'Projects to run. (comma/space delimited project names and/or patterns)',
})
.option('dryRun', {
describe:
'Preview the changes without updating files/creating releases',
alias: 'd',
type: 'boolean',
default: false,
})
.option('verbose', {
type: 'boolean',
describe:
'Prints additional information about the commands (e.g., stack traces)',
})
.check((argv) => {
if (argv.groups && argv.projects) {
throw new Error(
'The --projects and --groups options are mutually exclusive, please use one or the other.'
);
}
const nxJson = readNxJson();
if (argv.groups?.length) {
for (const group of argv.groups) {
if (!nxJson.release?.groups?.[group]) {
throw new Error(
`The specified release group "${group}" was not found in nx.json`
);
}
}
}
return true;
}) as any, // the type: 'string' and coerce: parseCSV combo isn't enough to produce the string[] type for projects and groups
handler: async () => {
showHelp();
process.exit(1);
},
};
const versionCommand: CommandModule<NxReleaseArgs, VersionOptions> = {
command: 'version [specifier]',
aliases: ['v'],
describe:
'Create a version and release for one or more applications and libraries',
builder: (yargs) =>
yargs
.positional('specifier', {
type: 'string',
describe:
'Exact version or semver keyword to apply to the selected release group.',
})
.option('preid', {
type: 'string',
describe:
'The optional prerelease identifier to apply to the version, in the case that specifier has been set to prerelease.',
default: '',
}),
handler: (args) => import('./version').then((m) => m.versionHandler(args)),
};
const changelogCommand: CommandModule<NxReleaseArgs, ChangelogOptions> = {
command: 'changelog [version]',
aliases: ['c'],
describe:
'Generate a changelog for one or more projects, and optionally push to Github',
builder: (yargs) =>
yargs
// Disable default meaning of yargs version for this command
.version(false)
.positional('version', {
type: 'string',
description: 'The version to create a Github release and changelog for',
})
.option('from', {
type: 'string',
description:
'The git reference to use as the start of the changelog. If not set it will attempt to resolve the latest tag and use that',
})
.option('to', {
type: 'string',
description: 'The git reference to use as the end of the changelog',
default: 'HEAD',
})
.option('interactive', {
alias: 'i',
type: 'boolean',
})
.option('gitRemote', {
type: 'string',
description:
'Alternate git remote in the form {user}/{repo} on which to create the Github release (useful for testing)',
default: 'origin',
})
.option('tagVersionPrefix', {
type: 'string',
description:
'Prefix to apply to the version when creating the Github release tag',
default: 'v',
})
.check((argv) => {
if (!argv.version) {
throw new Error('A target version must be specified');
}
return true;
}),
handler: (args) =>
import('./changelog').then((m) => m.changelogHandler(args)),
};
const publishCommand: CommandModule<NxReleaseArgs, PublishOptions> = {
command: 'publish',
aliases: ['p'],
describe: 'Publish a versioned project to a registry',
builder: (yargs) =>
withRunManyOptions(yargs)
.option('registry', {
type: 'string',
description: 'The registry to publish to',
})
.option('tag', {
type: 'string',
description: 'The distribution tag to apply to the published package',
}),
handler: (args) =>
import('./publish').then((m) => m.publishHandler(withOverrides(args, 2))),
};

View File

@ -0,0 +1,48 @@
import { createNxReleaseConfig } from './config';
describe('createNxReleaseConfig()', () => {
const testCases = [
{
input: undefined,
output: {
groups: {},
},
},
{
input: {},
output: {
groups: {},
},
},
{
input: {
groups: {},
},
output: {
groups: {},
},
},
{
input: {
groups: {
foo: {
projects: '*',
},
},
},
output: {
groups: {
foo: {
projects: '*',
},
},
},
},
];
testCases.forEach((c, i) => {
it(`should create appropriate NxReleaseConfig, CASE: ${i}`, () => {
expect(createNxReleaseConfig(c.input)).toEqual(c.output);
});
});
});

View File

@ -0,0 +1,12 @@
import { NxJsonConfiguration } from '../../../config/nx-json';
// Apply default configuration to any optional user configuration
export function createNxReleaseConfig(
userConfig: NxJsonConfiguration['release'] = {}
): Required<NxJsonConfiguration['release']> {
const nxReleaseConfig: Required<NxJsonConfiguration['release']> = {
...userConfig,
groups: userConfig.groups || {},
};
return nxReleaseConfig;
}

View File

@ -0,0 +1,249 @@
import { ProjectGraph } from '../../../config/project-graph';
import { createReleaseGroups } from './create-release-groups';
describe('create-release-groups', () => {
let projectGraph: ProjectGraph;
beforeEach(() => {
projectGraph = {
nodes: {
'lib-a': {
name: 'lib-a',
type: 'lib',
data: {
root: 'libs/lib-a',
targets: {
'nx-release-publish': {},
},
} as any,
},
'lib-b': {
name: 'lib-b',
type: 'lib',
data: {
root: 'libs/lib-b',
targets: {
'nx-release-publish': {},
},
} as any,
},
},
dependencies: {},
};
});
describe('no user specified groups', () => {
it('should return a catch all release group containing all projects when no groups are specified', async () => {
const res = await createReleaseGroups(projectGraph, {});
expect(res).toMatchInlineSnapshot(`
{
"error": null,
"releaseGroups": [
{
"name": "__default__",
"projects": [
"lib-a",
"lib-b",
],
"version": {
"generator": "@nx/js:release-version",
"generatorOptions": {},
},
},
],
}
`);
});
});
describe('user specified groups', () => {
it('should ignore any projects not matched to user specified groups', async () => {
const res = await createReleaseGroups(projectGraph, {
'group-1': {
projects: ['lib-a'], // intentionally no lib-b, so it should be ignored
},
});
expect(res).toMatchInlineSnapshot(`
{
"error": null,
"releaseGroups": [
{
"name": "group-1",
"projects": [
"lib-a",
],
"version": {
"generator": "@nx/js:release-version",
"generatorOptions": {},
},
},
],
}
`);
});
it('should respect user overrides for "version" config', async () => {
const res = await createReleaseGroups(projectGraph, {
'group-1': {
projects: ['lib-a'],
version: {
generator: '@custom/generator',
generatorOptions: {
optionsOverride: 'something',
},
},
},
'group-2': {
projects: ['lib-b'],
version: {
generator: '@custom/generator-alternative',
},
},
});
expect(res).toMatchInlineSnapshot(`
{
"error": null,
"releaseGroups": [
{
"name": "group-1",
"projects": [
"lib-a",
],
"version": {
"generator": "@custom/generator",
"generatorOptions": {
"optionsOverride": "something",
},
},
},
{
"name": "group-2",
"projects": [
"lib-b",
],
"version": {
"generator": "@custom/generator-alternative",
"generatorOptions": {},
},
},
],
}
`);
});
});
describe('release group config errors', () => {
it('should return an error if a project matches multiple groups', async () => {
const res = await createReleaseGroups(projectGraph, {
'group-1': {
projects: ['lib-a'],
},
'group-2': {
projects: ['lib-a'],
},
});
expect(res).toMatchInlineSnapshot(`
{
"error": {
"code": "PROJECT_MATCHES_MULTIPLE_GROUPS",
"data": {
"project": "lib-a",
},
},
"releaseGroups": [],
}
`);
});
it('should return an error if no projects can be resolved for a group', async () => {
const res = await createReleaseGroups(projectGraph, {
'group-1': {
projects: ['lib-does-not-exist'],
},
});
expect(res).toMatchInlineSnapshot(`
{
"error": {
"code": "RELEASE_GROUP_MATCHES_NO_PROJECTS",
"data": {
"releaseGroupName": "group-1",
},
},
"releaseGroups": [],
}
`);
});
it('should return an error if any matched projects do not have the required target specified', async () => {
const res = await createReleaseGroups(
{
...projectGraph,
nodes: {
...projectGraph.nodes,
'project-without-target': {
name: 'project-without-target',
type: 'lib',
data: {
root: 'libs/project-without-target',
targets: {},
} as any,
},
},
},
{
'group-1': {
projects: '*', // using string form to ensure that is supported in addition to array form
},
},
'nx-release-publish'
);
expect(res).toMatchInlineSnapshot(`
{
"error": {
"code": "PROJECTS_MISSING_TARGET",
"data": {
"projects": [
"project-without-target",
],
"targetName": "nx-release-publish",
},
},
"releaseGroups": [],
}
`);
const res2 = await createReleaseGroups(
{
...projectGraph,
nodes: {
...projectGraph.nodes,
'another-project-without-target': {
name: 'another-project-without-target',
type: 'lib',
data: {
root: 'libs/another-project-without-target',
targets: {},
} as any,
},
},
},
{},
'nx-release-publish'
);
expect(res2).toMatchInlineSnapshot(`
{
"error": {
"code": "PROJECTS_MISSING_TARGET",
"data": {
"projects": [
"another-project-without-target",
],
"targetName": "nx-release-publish",
},
},
"releaseGroups": [],
}
`);
});
});
});

View File

@ -0,0 +1,225 @@
import type { NxJsonConfiguration } from '../../../config/nx-json';
import { output, type ProjectGraph } from '../../../devkit-exports';
import { findMatchingProjects } from '../../../utils/find-matching-projects';
import { projectHasTarget } from '../../../utils/project-graph-utils';
import { resolveNxJsonConfigErrorMessage } from '../utils/resolve-nx-json-error-message';
export interface ReleaseGroup {
name: string;
projects: string[];
version: {
generator: string;
generatorOptions: Record<string, unknown>;
};
}
// We explicitly handle some expected errors in order to provide the best possible DX
interface CreateReleaseGroupsError {
code:
| 'RELEASE_GROUP_MATCHES_NO_PROJECTS'
| 'PROJECT_MATCHES_MULTIPLE_GROUPS'
| 'PROJECTS_MISSING_TARGET';
data: Record<string, string | string[]>;
}
export const CATCH_ALL_RELEASE_GROUP = '__default__';
/**
* Create a set of release groups based on the relevant user specified config ready
* to be consumed by the release commands.
*/
export async function createReleaseGroups(
projectGraph: ProjectGraph,
userSpecifiedGroups: NxJsonConfiguration['release']['groups'] = {},
requiredTargetName?: 'nx-release-publish'
): Promise<{
error: null | CreateReleaseGroupsError;
releaseGroups: ReleaseGroup[];
}> {
const DEFAULT_VERSION_GENERATOR = '@nx/js:release-version';
const DEFAULT_VERSION_GENERATOR_OPTIONS = {};
const allProjects = findMatchingProjects(['*'], projectGraph.nodes);
/**
* No user specified release groups, so we treat all projects as being in one release group
* together in which all projects are released in lock step.
*/
if (Object.keys(userSpecifiedGroups).length === 0) {
// Ensure all projects have the relevant target available, if applicable
if (requiredTargetName) {
const error = ensureProjectsHaveTarget(
allProjects,
projectGraph,
requiredTargetName
);
if (error) {
return {
error,
releaseGroups: [],
};
}
}
return {
error: null,
releaseGroups: [
{
name: CATCH_ALL_RELEASE_GROUP,
projects: allProjects,
version: {
generator: DEFAULT_VERSION_GENERATOR,
generatorOptions: DEFAULT_VERSION_GENERATOR_OPTIONS,
},
},
],
};
}
/**
* The user has specified at least one release group.
*
* Resolve all the project names into their release groups, and check
* that individual projects are not found in multiple groups.
*/
const releaseGroups: ReleaseGroup[] = [];
const alreadyMatchedProjects = new Set<string>();
for (const [releaseGroupName, userSpecifiedGroup] of Object.entries(
userSpecifiedGroups
)) {
// Ensure that the user config for the release group can resolve at least one project
const matchingProjects = findMatchingProjects(
Array.isArray(userSpecifiedGroup.projects)
? userSpecifiedGroup.projects
: [userSpecifiedGroup.projects],
projectGraph.nodes
);
if (!matchingProjects.length) {
return {
error: {
code: 'RELEASE_GROUP_MATCHES_NO_PROJECTS',
data: {
releaseGroupName: releaseGroupName,
},
},
releaseGroups: [],
};
}
// Ensure all matching projects have the relevant target available, if applicable
if (requiredTargetName) {
const error = ensureProjectsHaveTarget(
matchingProjects,
projectGraph,
requiredTargetName
);
if (error) {
return {
error,
releaseGroups: [],
};
}
}
for (const project of matchingProjects) {
if (alreadyMatchedProjects.has(project)) {
return {
error: {
code: 'PROJECT_MATCHES_MULTIPLE_GROUPS',
data: {
project,
},
},
releaseGroups: [],
};
}
alreadyMatchedProjects.add(project);
}
releaseGroups.push({
name: releaseGroupName,
projects: matchingProjects,
version: userSpecifiedGroup.version
? {
generator:
userSpecifiedGroup.version.generator || DEFAULT_VERSION_GENERATOR,
generatorOptions:
userSpecifiedGroup.version.generatorOptions ||
DEFAULT_VERSION_GENERATOR_OPTIONS,
}
: {
generator: DEFAULT_VERSION_GENERATOR,
generatorOptions: DEFAULT_VERSION_GENERATOR_OPTIONS,
},
});
}
return {
error: null,
releaseGroups,
};
}
export async function handleCreateReleaseGroupsError(
error: CreateReleaseGroupsError
) {
switch (error.code) {
case 'RELEASE_GROUP_MATCHES_NO_PROJECTS':
{
const nxJsonMessage = await resolveNxJsonConfigErrorMessage([
'release',
'groups',
]);
output.error({
title: `Release group "${error.data.releaseGroupName}" matches no projects. Please ensure all release groups match at least one project:`,
bodyLines: [nxJsonMessage],
});
}
break;
case 'PROJECT_MATCHES_MULTIPLE_GROUPS':
{
const nxJsonMessage = await resolveNxJsonConfigErrorMessage([
'release',
'groups',
]);
output.error({
title: `Project "${error.data.project}" matches multiple release groups. Please ensure all projects are part of only one release group:`,
bodyLines: [nxJsonMessage],
});
}
break;
case 'PROJECTS_MISSING_TARGET':
{
output.error({
title: `Based on your config, the following projects were matched for release but do not have a "${error.data.targetName}" target specified. Please ensure you have an appropriate plugin such as @nx/js installed, or have configured the target manually, or exclude the projects using release groups config in nx.json:`,
bodyLines: Array.from(error.data.projects).map((name) => `- ${name}`),
});
}
break;
default:
throw new Error(`Unhandled error code: ${error.code}`);
}
process.exit(1);
}
function ensureProjectsHaveTarget(
projects: string[],
projectGraph: ProjectGraph,
requiredTargetName: string
): null | CreateReleaseGroupsError {
const missingTargetProjects = projects.filter(
(project) =>
!projectHasTarget(projectGraph.nodes[project], requiredTargetName)
);
if (missingTargetProjects.length) {
return {
code: 'PROJECTS_MISSING_TARGET',
data: {
targetName: requiredTargetName,
projects: missingTargetProjects,
},
};
}
return null;
}

View File

@ -0,0 +1,222 @@
import { readNxJson } from '../../config/nx-json';
import {
ProjectGraph,
ProjectGraphProjectNode,
} from '../../config/project-graph';
import { NxJsonConfiguration, output } from '../../devkit-exports';
import { createProjectGraphAsync } from '../../project-graph/project-graph';
import { runCommand } from '../../tasks-runner/run-command';
import {
createOverrides,
readGraphFileFromGraphArg,
} from '../../utils/command-line-utils';
import { findMatchingProjects } from '../../utils/find-matching-projects';
import { PublishOptions } from './command-object';
import { createNxReleaseConfig } from './config/config';
import {
CATCH_ALL_RELEASE_GROUP,
ReleaseGroup,
createReleaseGroups,
handleCreateReleaseGroupsError,
} from './config/create-release-groups';
import { generateGraph } from '../graph/graph';
export async function publishHandler(
args: PublishOptions & { __overrides_unparsed__: string[] }
): Promise<void> {
const projectGraph = await createProjectGraphAsync({ exitOnError: true });
const nxJson = readNxJson();
// Apply default configuration to any optional user configuration
const nxReleaseConfig = createNxReleaseConfig(nxJson.release);
const releaseGroupsData = await createReleaseGroups(
projectGraph,
nxReleaseConfig.groups
);
if (releaseGroupsData.error) {
return await handleCreateReleaseGroupsError(releaseGroupsData.error);
}
let { releaseGroups } = releaseGroupsData;
/**
* User is filtering to a subset of projects. We need to make sure that what they have provided can be reconciled
* against their configuration in terms of release groups and the ungroupedProjectsHandling option.
*/
if (args.projects?.length) {
const matchingProjectsForFilter = findMatchingProjects(
args.projects,
projectGraph.nodes
);
if (!matchingProjectsForFilter.length) {
output.error({
title: `Your --projects filter "${args.projects}" did not match any projects in the workspace`,
});
process.exit(1);
}
const filteredProjectToReleaseGroup = new Map<string, ReleaseGroup>();
const releaseGroupToFilteredProjects = new Map<ReleaseGroup, Set<string>>();
// Figure out which release groups, if any, that the filtered projects belong to so that we can resolve other config
for (const releaseGroup of releaseGroups) {
const matchingProjectsForReleaseGroup = findMatchingProjects(
releaseGroup.projects,
projectGraph.nodes
);
for (const matchingProject of matchingProjectsForFilter) {
if (matchingProjectsForReleaseGroup.includes(matchingProject)) {
filteredProjectToReleaseGroup.set(matchingProject, releaseGroup);
if (!releaseGroupToFilteredProjects.has(releaseGroup)) {
releaseGroupToFilteredProjects.set(releaseGroup, new Set());
}
releaseGroupToFilteredProjects.get(releaseGroup).add(matchingProject);
}
}
}
/**
* If there are release groups specified, each filtered project must match at least one release
* group, otherwise the command + config combination is invalid.
*/
if (Object.keys(nxReleaseConfig.groups).length) {
const unmatchedProjects = matchingProjectsForFilter.filter(
(p) => !filteredProjectToReleaseGroup.has(p)
);
if (unmatchedProjects.length) {
output.error({
title: `The following projects which match your projects filter "${args.projects}" did not match any configured release groups:`,
bodyLines: unmatchedProjects.map((p) => `- ${p}`),
});
process.exit(1);
}
}
output.note({
title: `Your filter "${args.projects}" matched the following projects:`,
bodyLines: matchingProjectsForFilter.map((p) => {
const releaseGroupForProject = filteredProjectToReleaseGroup.get(p);
if (releaseGroupForProject.name === CATCH_ALL_RELEASE_GROUP) {
return `- ${p}`;
}
return `- ${p} (release group "${releaseGroupForProject.name}")`;
}),
});
// Filter the releaseGroups collection appropriately
releaseGroups = releaseGroups.filter((rg) =>
releaseGroupToFilteredProjects.has(rg)
);
/**
* Run publishing for all remaining release groups and filtered projects within them
*/
for (const releaseGroup of releaseGroups) {
await runPublishOnProjects(
args,
projectGraph,
nxJson,
Array.from(releaseGroupToFilteredProjects.get(releaseGroup))
);
}
return process.exit(0);
}
/**
* The user is filtering by release group
*/
if (args.groups?.length) {
releaseGroups = releaseGroups.filter((g) => args.groups?.includes(g.name));
}
// Should be an impossible state, as we should have explicitly handled any errors/invalid config by now
if (!releaseGroups.length) {
output.error({
title: `No projects could be matched for versioning, please report this case and include your nx.json config`,
});
process.exit(1);
}
/**
* Run publishing for all remaining release groups
*/
for (const releaseGroup of releaseGroups) {
await runPublishOnProjects(
args,
projectGraph,
nxJson,
releaseGroup.projects
);
}
process.exit(0);
}
async function runPublishOnProjects(
args: PublishOptions & { __overrides_unparsed__: string[] },
projectGraph: ProjectGraph,
nxJson: NxJsonConfiguration,
projectNames: string[]
) {
const projectsToRun: ProjectGraphProjectNode[] = projectNames.map(
(projectName) => projectGraph.nodes[projectName]
);
const overrides = createOverrides(args.__overrides_unparsed__);
if (args.registry) {
overrides.registry = args.registry;
}
if (args.tag) {
overrides.tag = args.tag;
}
if (args.verbose) {
process.env.NX_VERBOSE_LOGGING = 'true';
}
const targets = ['nx-release-publish'];
if (args.graph) {
const file = readGraphFileFromGraphArg(args);
const projectNames = projectsToRun.map((t) => t.name);
return await generateGraph(
{
watch: false,
all: false,
open: true,
view: 'tasks',
targets,
projects: projectNames,
file,
},
projectNames
);
} else {
/**
* Run the relevant nx-release-publish executor on each of the selected projects.
*/
const status = await runCommand(
projectsToRun,
projectGraph,
{ nxJson },
{
targets,
outputStyle: 'static',
...(args as any),
},
overrides,
null,
{},
{ excludeTaskDependencies: false, loadDotEnvFiles: true }
);
if (status !== 0) {
// fix for https://github.com/nrwl/nx/issues/1666
if (process.stdin['unref']) (process.stdin as any).unref();
process.exit(status);
}
}
}

View File

@ -0,0 +1,158 @@
/**
* Special thanks to changelogen for the original inspiration for many of these utilities:
* https://github.com/unjs/changelogen
*/
import { spawn } from 'node:child_process';
export interface GitCommitAuthor {
name: string;
email: string;
}
export interface RawGitCommit {
message: string;
body: string;
shortHash: string;
author: GitCommitAuthor;
}
export interface Reference {
type: 'hash' | 'issue' | 'pull-request';
value: string;
}
export interface GitCommit extends RawGitCommit {
description: string;
type: string;
scope: string;
references: Reference[];
authors: GitCommitAuthor[];
isBreaking: boolean;
}
export async function getLastGitTag() {
const r = await execCommand('git', ['describe', '--tags', '--abbrev=0'])
.then((r) => r.split('\n').filter(Boolean))
.catch(() => []);
return r.at(-1);
}
export async function getGitDiff(
from: string | undefined,
to = 'HEAD'
): Promise<RawGitCommit[]> {
// https://git-scm.com/docs/pretty-formats
const r = await execCommand('git', [
'--no-pager',
'log',
`${from ? `${from}...` : ''}${to}`,
'--pretty="----%n%s|%h|%an|%ae%n%b"',
'--name-status',
]);
return r
.split('----\n')
.splice(1)
.map((line) => {
const [firstLine, ..._body] = line.split('\n');
const [message, shortHash, authorName, authorEmail] =
firstLine.split('|');
const r: RawGitCommit = {
message,
shortHash,
author: { name: authorName, email: authorEmail },
body: _body.join('\n'),
};
return r;
});
}
export function parseCommits(commits: RawGitCommit[]): GitCommit[] {
return commits.map((commit) => parseGitCommit(commit)).filter(Boolean);
}
// https://www.conventionalcommits.org/en/v1.0.0/
// https://regex101.com/r/FSfNvA/1
const ConventionalCommitRegex =
/(?<type>[a-z]+)(\((?<scope>.+)\))?(?<breaking>!)?: (?<description>.+)/i;
const CoAuthoredByRegex = /co-authored-by:\s*(?<name>.+)(<(?<email>.+)>)/gim;
const PullRequestRE = /\([ a-z]*(#\d+)\s*\)/gm;
const IssueRE = /(#\d+)/gm;
export function parseGitCommit(commit: RawGitCommit): GitCommit | null {
const match = commit.message.match(ConventionalCommitRegex);
if (!match) {
return null;
}
const type = match.groups.type;
const scope = match.groups.scope || '';
const isBreaking = Boolean(match.groups.breaking);
let description = match.groups.description;
// Extract references from message
const references: Reference[] = [];
for (const m of description.matchAll(PullRequestRE)) {
references.push({ type: 'pull-request', value: m[1] });
}
for (const m of description.matchAll(IssueRE)) {
if (!references.some((i) => i.value === m[1])) {
references.push({ type: 'issue', value: m[1] });
}
}
references.push({ value: commit.shortHash, type: 'hash' });
// Remove references and normalize
description = description.replace(PullRequestRE, '').trim();
// Find all authors
const authors: GitCommitAuthor[] = [commit.author];
for (const match of commit.body.matchAll(CoAuthoredByRegex)) {
authors.push({
name: (match.groups.name || '').trim(),
email: (match.groups.email || '').trim(),
});
}
return {
...commit,
authors,
description,
type,
scope,
references,
isBreaking,
};
}
async function execCommand(
cmd: string,
args: string[],
options?: any
): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
...options,
stdio: ['pipe', 'pipe', 'pipe'], // stdin, stdout, stderr
encoding: 'utf-8',
});
let stdout = '';
child.stdout.on('data', (chunk) => {
stdout += chunk;
});
child.on('error', (error) => {
reject(error);
});
child.on('close', (code) => {
if (code !== 0) {
reject(new Error(`Command failed with exit code ${code}`));
} else {
resolve(stdout);
}
});
});
}

View File

@ -0,0 +1,391 @@
/**
* 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 { execSync } from 'node:child_process';
import { existsSync, promises as fsp } from 'node:fs';
import { homedir } from 'node:os';
import { joinPathFragments, output } from '../../../devkit-exports';
import { GitCommit, Reference } from './git';
// axios types and values don't seem to match
import _axios = require('axios');
const axios = _axios as any as typeof _axios['default'];
export interface GithubRequestConfig {
repo: string;
token: string | null;
}
export interface GithubRelease {
id?: string;
tag_name: string;
name?: string;
body?: string;
draft?: boolean;
prerelease?: boolean;
}
export function getGitHubRemote(remoteName = 'origin') {
try {
const remoteUrl = execSync(`git remote get-url ${remoteName}`, {
encoding: 'utf8',
}).trim();
// Extract the 'user/repo' part from the URL
const regex = /github\.com[/:]([\w-]+\/[\w-]+)\.git/;
const match = remoteUrl.match(regex);
if (match && match[1]) {
return match[1];
} else {
throw new Error(
`Could not extract "user/repo" data from the resolved remote URL: ${remoteUrl}`
);
}
} catch (error) {
console.error('Error getting GitHub remote:', error.message);
return null;
}
}
export async function createOrUpdateGithubRelease(
githubRequestConfig: GithubRequestConfig,
release: { version: string; body: string },
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 (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'
);
});
} else {
output.success({
title: `Successfully ${
existingGithubReleaseForVersion ? 'updated' : 'created'
} release ${chalk.bold(release.version)} on Github:`,
bodyLines: [result.url],
});
}
}
// TODO: allow this to be configurable via config in a future release
export async function generateMarkdown(
commits: GitCommit[],
releaseVersion: string,
githubRequestConfig: GithubRequestConfig
) {
const typeGroups = groupBy(commits, 'type');
const markdown: string[] = [];
const breakingChanges = [];
const commitTypes = {
feat: { title: '🚀 Features' },
perf: { title: '🔥 Performance' },
fix: { title: '🩹 Fixes' },
refactor: { title: '💅 Refactors' },
docs: { title: '📖 Documentation' },
build: { title: '📦 Build' },
types: { title: '🌊 Types' },
chore: { title: '🏡 Chore' },
examples: { title: '🏀 Examples' },
test: { title: '✅ Tests' },
style: { title: '🎨 Styles' },
ci: { title: '🤖 CI' },
};
// Version Title
markdown.push('', `## ${releaseVersion}`, '');
for (const type of Object.keys(commitTypes)) {
const group = typeGroups[type];
if (!group || group.length === 0) {
continue;
}
markdown.push('', '### ' + commitTypes[type].title, '');
for (const commit of group.reverse()) {
const line = formatCommit(commit, githubRequestConfig);
markdown.push(line);
if (commit.isBreaking) {
breakingChanges.push(line);
}
}
}
if (breakingChanges.length > 0) {
markdown.push('', '#### ⚠️ Breaking Changes', '', ...breakingChanges);
}
const _authors = new Map<string, { email: Set<string>; github?: string }>();
for (const commit of commits) {
if (!commit.author) {
continue;
}
const name = formatName(commit.author.name);
if (!name || name.includes('[bot]')) {
continue;
}
if (_authors.has(name)) {
const entry = _authors.get(name);
entry.email.add(commit.author.email);
} else {
_authors.set(name, { email: new Set([commit.author.email]) });
}
}
// Try to map authors to github usernames
await Promise.all(
[..._authors.keys()].map(async (authorName) => {
const meta = _authors.get(authorName);
for (const email of meta.email) {
// For these pseudo-anonymized emails we can just extract the Github username from before the @
if (email.endsWith('@users.noreply.github.com')) {
meta.github = email.split('@')[0];
break;
}
// Look up any other emails against the ungh.cc API
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) => ({ name: e[0], ...e[1] }));
if (authors.length > 0) {
markdown.push(
'',
'### ' + '❤️ Thank You',
'',
...authors.map((i) => {
const _email = [...i.email].find(
(e) => !e.includes('noreply.github.com')
);
const email = _email ? `<${_email}>` : '';
const github = i.github ? `@${i.github}` : '';
return `- ${i.name} ${github || email}`;
})
);
}
return markdown.join('\n').trim();
}
async function syncGithubRelease(
githubRequestConfig: GithubRequestConfig,
release: { version: string; body: string },
existingGithubReleaseForVersion?: GithubRelease
) {
const ghRelease: GithubRelease = {
tag_name: release.version,
name: release.version,
body: release.body,
};
try {
const newGhRelease = await (existingGithubReleaseForVersion
? updateGithubRelease(
githubRequestConfig,
existingGithubReleaseForVersion.id,
ghRelease
)
: createGithubRelease(githubRequestConfig, ghRelease));
return {
status: existingGithubReleaseForVersion ? 'updated' : 'created',
id: newGhRelease.id,
url: newGhRelease.html_url,
};
} catch (error) {
return {
status: 'manual',
error,
url: githubNewReleaseURL(githubRequestConfig, release),
};
}
}
export async function resolveGithubToken(): 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);
return ghCLIConfig['github.com'].oauth_token;
}
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: 'https://api.github.com',
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: { version: string; body: string }
) {
return `https://github.com/${config.repo}/releases/new?tag=v${
release.version
}&title=v${release.version}&body=${encodeURIComponent(release.body)}`;
}
type RepoProvider = 'github';
const providerToRefSpec: Record<
RepoProvider,
Record<Reference['type'], string>
> = {
github: { 'pull-request': 'pull', hash: 'commit', issue: 'issues' },
};
function formatReference(
ref: Reference,
githubRequestConfig: GithubRequestConfig
) {
const refSpec = providerToRefSpec['github'];
return `[${ref.value}](https://github.com/${githubRequestConfig.repo}/${
refSpec[ref.type]
}/${ref.value.replace(/^#/, '')})`;
}
export function formatCommit(
commit: GitCommit,
githubRequestConfig: GithubRequestConfig
) {
return (
'- ' +
(commit.scope ? `**${commit.scope.trim()}:** ` : '') +
(commit.isBreaking ? '⚠️ ' : '') +
commit.description +
formatReferences(commit.references, githubRequestConfig)
);
}
function formatReferences(
references: Reference[],
githubRequestConfig: GithubRequestConfig
) {
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, githubRequestConfig))
.join(', ') +
')'
);
}
if (references.length > 0) {
return ' (' + formatReference(references[0], githubRequestConfig) + ')';
}
return '';
}
function formatName(name = '') {
return name
.split(' ')
.map((p) => p.trim())
.join(' ');
}
function groupBy(items: any[], key: string) {
const groups = {};
for (const item of items) {
groups[item[key]] = groups[item[key]] || [];
groups[item[key]].push(item);
}
return groups;
}

View File

@ -0,0 +1,43 @@
import { execSync, spawn } from 'node:child_process';
export async function launchEditor(filePath: string) {
// Inspired by what git does
const editorCommand =
process.env.GIT_EDITOR ||
getGitConfig('core.editor') ||
process.env.VISUAL ||
process.env.EDITOR ||
'vi';
const { cmd, args } = parseCommand(editorCommand);
return new Promise((resolve, reject) => {
const editorProcess = spawn(cmd, [...args, filePath], {
stdio: 'inherit', // This will ensure the editor uses the current terminal
});
editorProcess.on('exit', (code) => {
if (code === 0) {
resolve(undefined);
} else {
reject(new Error(`Editor process exited with code ${code}`));
}
});
});
}
function getGitConfig(key): string | null {
try {
return execSync(`git config --get ${key}`).toString().trim();
} catch {
return null;
}
}
function parseCommand(commandString) {
const parts = commandString.split(/\s+/);
return {
cmd: parts[0],
args: parts.slice(1),
};
}

View File

@ -0,0 +1,16 @@
import * as chalk from 'chalk';
import { diff } from 'jest-diff';
export function printDiff(before: string, after: string) {
console.error(
diff(before, after, {
omitAnnotationLines: true,
contextLines: 1,
expand: false,
aColor: chalk.red,
bColor: chalk.green,
patchColor: (s) => '',
})
);
console.log('');
}

View File

@ -0,0 +1,68 @@
import { readFileSync } from 'node:fs';
import { relative } from 'node:path';
import { joinPathFragments, workspaceRoot } from '../../../devkit-exports';
export async function resolveNxJsonConfigErrorMessage(
propPath: string[]
): Promise<string> {
const errorLines = await getJsonConfigLinesForErrorMessage(
readFileSync(joinPathFragments(workspaceRoot, 'nx.json'), 'utf-8'),
propPath
);
let nxJsonMessage = `The relevant config is defined here: ${relative(
process.cwd(),
joinPathFragments(workspaceRoot, 'nx.json')
)}`;
if (errorLines) {
nxJsonMessage += `, lines ${errorLines.startLine}-${errorLines.endLine}`;
}
return nxJsonMessage;
}
async function getJsonConfigLinesForErrorMessage(
rawConfig: string,
jsonPath: string[]
): Promise<{ startLine: number; endLine: number } | null> {
try {
const jsonParser = await import('jsonc-parser');
const rootNode = jsonParser.parseTree(rawConfig);
const node = jsonParser.findNodeAtLocation(rootNode, jsonPath);
return computeJsonLineNumbers(rawConfig, node?.offset, node?.length);
} catch {
return null;
}
}
function computeJsonLineNumbers(
inputText: string,
startOffset: number,
characterCount: number
) {
let lines = inputText.split('\n');
let totalChars = 0;
let startLine = 0;
let endLine = 0;
for (let i = 0; i < lines.length; i++) {
totalChars += lines[i].length + 1; // +1 for '\n' character
if (!startLine && totalChars >= startOffset) {
startLine = i + 1; // +1 because arrays are 0-based
}
if (totalChars >= startOffset + characterCount) {
endLine = i + 1; // +1 because arrays are 0-based
break;
}
}
if (!startLine) {
throw new Error('Start offset exceeds the text length');
}
if (!endLine) {
throw new Error(
'Character count exceeds the text length after start offset'
);
}
return { startLine, endLine };
}

View File

@ -0,0 +1,70 @@
import { deriveNewSemverVersion } from './semver';
describe('deriveNewSemverVersion()', () => {
const testCases = [
{
input: {
currentVersion: '1.0.0',
specifier: 'major',
},
expected: '2.0.0',
},
{
input: {
currentVersion: '1.0.0',
specifier: 'minor',
},
expected: '1.1.0',
},
{
input: {
currentVersion: '1.0.0',
specifier: 'patch',
},
expected: '1.0.1',
},
{
input: {
currentVersion: '1.0.0',
specifier: '99.9.9', // exact version
},
expected: '99.9.9',
},
{
input: {
currentVersion: '1.0.0',
specifier: '99.9.9', // exact version
},
expected: '99.9.9',
},
];
testCases.forEach((c, i) => {
it(`should derive an appropriate semver version, CASE: ${i}`, () => {
expect(
deriveNewSemverVersion(c.input.currentVersion, c.input.specifier)
).toEqual(c.expected);
});
});
it('should throw if the current version is not a valid semver version', () => {
expect(() =>
deriveNewSemverVersion('not-a-valid-semver-version', 'minor')
).toThrowErrorMatchingInlineSnapshot(
`"Invalid semver version "not-a-valid-semver-version" provided."`
);
expect(() =>
deriveNewSemverVersion('major', 'minor')
).toThrowErrorMatchingInlineSnapshot(
`"Invalid semver version "major" provided."`
);
});
it('should throw if the new version specifier is not a valid semver version or semver keyword', () => {
expect(() =>
deriveNewSemverVersion('1.0.0', 'foo')
).toThrowErrorMatchingInlineSnapshot(
`"Invalid semver version specifier "foo" provided. Please provide either a valid semver version or a valid semver version keyword."`
);
});
});

View File

@ -0,0 +1,37 @@
import { RELEASE_TYPES, ReleaseType, inc, valid } from 'semver';
export function isRelativeVersionKeyword(val: string): val is ReleaseType {
return RELEASE_TYPES.includes(val as ReleaseType);
}
export function deriveNewSemverVersion(
currentSemverVersion: string,
semverSpecifier: string,
preid?: string
) {
if (!valid(currentSemverVersion)) {
throw new Error(
`Invalid semver version "${currentSemverVersion}" provided.`
);
}
let newVersion = semverSpecifier;
if (isRelativeVersionKeyword(semverSpecifier)) {
// Derive the new version from the current version combined with the new version specifier.
const derivedVersion = inc(currentSemverVersion, semverSpecifier, preid);
if (!derivedVersion) {
throw new Error(
`Unable to derive new version from current version "${currentSemverVersion}" and version specifier "${semverSpecifier}"`
);
}
newVersion = derivedVersion;
} else {
// Ensure the new version specifier is a valid semver version, given it is not a valid semver keyword
if (!valid(semverSpecifier)) {
throw new Error(
`Invalid semver version specifier "${semverSpecifier}" provided. Please provide either a valid semver version or a valid semver version keyword.`
);
}
}
return newVersion;
}

View File

@ -0,0 +1,428 @@
import * as chalk from 'chalk';
import * as enquirer from 'enquirer';
import { readFileSync } from 'node:fs';
import { relative } from 'node:path';
import { RELEASE_TYPES, valid } from 'semver';
import { Generator } from '../../config/misc-interfaces';
import { readNxJson } from '../../config/nx-json';
import {
ProjectGraph,
ProjectGraphProjectNode,
} from '../../config/project-graph';
import {
NxJsonConfiguration,
joinPathFragments,
logger,
output,
workspaceRoot,
} from '../../devkit-exports';
import { FsTree, Tree, flushChanges } from '../../generators/tree';
import {
createProjectGraphAsync,
readProjectsConfigurationFromProjectGraph,
} from '../../project-graph/project-graph';
import { findMatchingProjects } from '../../utils/find-matching-projects';
import { combineOptionsForGenerator } from '../../utils/params';
import { parseGeneratorString } from '../generate/generate';
import { getGeneratorInformation } from '../generate/generator-utils';
import { VersionOptions } from './command-object';
import { createNxReleaseConfig } from './config/config';
import {
CATCH_ALL_RELEASE_GROUP,
ReleaseGroup,
createReleaseGroups,
handleCreateReleaseGroupsError,
} from './config/create-release-groups';
import { printDiff } from './utils/print-diff';
import { isRelativeVersionKeyword } from './utils/semver';
// Reexport for use in plugin release-version generator implementations
export { deriveNewSemverVersion } from './utils/semver';
export interface ReleaseVersionGeneratorSchema {
// The projects being versioned in the current execution
projects: ProjectGraphProjectNode[];
projectGraph: ProjectGraph;
specifier: string;
preid?: string;
packageRoot?: string;
currentVersionResolver?: 'registry' | 'disk';
currentVersionResolverMetadata?: Record<string, unknown>;
}
export async function versionHandler(args: VersionOptions): Promise<void> {
const projectGraph = await createProjectGraphAsync({ exitOnError: true });
const nxJson = readNxJson();
if (args.verbose) {
process.env.NX_VERBOSE_LOGGING = 'true';
}
// Apply default configuration to any optional user configuration
const nxReleaseConfig = createNxReleaseConfig(nxJson.release);
const releaseGroupsData = await createReleaseGroups(
projectGraph,
nxReleaseConfig.groups
);
if (releaseGroupsData.error) {
return await handleCreateReleaseGroupsError(releaseGroupsData.error);
}
const tree = new FsTree(workspaceRoot, args.verbose);
let { releaseGroups } = releaseGroupsData;
/**
* User is filtering to a subset of projects. We need to make sure that what they have provided can be reconciled
* against their configuration in terms of release groups and the ungroupedProjectsHandling option.
*/
if (args.projects?.length) {
const matchingProjectsForFilter = findMatchingProjects(
args.projects,
projectGraph.nodes
);
if (!matchingProjectsForFilter.length) {
output.error({
title: `Your --projects filter "${args.projects}" did not match any projects in the workspace`,
});
process.exit(1);
}
const filteredProjectToReleaseGroup = new Map<string, ReleaseGroup>();
const releaseGroupToFilteredProjects = new Map<ReleaseGroup, Set<string>>();
// Figure out which release groups, if any, that the filtered projects belong to so that we can resolve other config
for (const releaseGroup of releaseGroups) {
const matchingProjectsForReleaseGroup = findMatchingProjects(
releaseGroup.projects,
projectGraph.nodes
);
for (const matchingProject of matchingProjectsForFilter) {
if (matchingProjectsForReleaseGroup.includes(matchingProject)) {
filteredProjectToReleaseGroup.set(matchingProject, releaseGroup);
if (!releaseGroupToFilteredProjects.has(releaseGroup)) {
releaseGroupToFilteredProjects.set(releaseGroup, new Set());
}
releaseGroupToFilteredProjects.get(releaseGroup).add(matchingProject);
}
}
}
/**
* If there are release groups specified, each filtered project must match at least one release
* group, otherwise the command + config combination is invalid.
*/
if (Object.keys(nxReleaseConfig.groups).length) {
const unmatchedProjects = matchingProjectsForFilter.filter(
(p) => !filteredProjectToReleaseGroup.has(p)
);
if (unmatchedProjects.length) {
output.error({
title: `The following projects which match your projects filter "${args.projects}" did not match any configured release groups:`,
bodyLines: unmatchedProjects.map((p) => `- ${p}`),
});
process.exit(1);
}
}
output.note({
title: `Your filter "${args.projects}" matched the following projects:`,
bodyLines: matchingProjectsForFilter.map((p) => {
const releaseGroupForProject = filteredProjectToReleaseGroup.get(p);
if (releaseGroupForProject.name === CATCH_ALL_RELEASE_GROUP) {
return `- ${p}`;
}
return `- ${p} (release group "${releaseGroupForProject.name}")`;
}),
});
// Filter the releaseGroups collection appropriately
releaseGroups = releaseGroups.filter((rg) =>
releaseGroupToFilteredProjects.has(rg)
);
/**
* Run semver versioning for all remaining release groups and filtered projects within them
*/
for (const releaseGroup of releaseGroups) {
const releaseGroupName = releaseGroup.name;
// Resolve the generator data for the current release group
const generatorData = resolveGeneratorData({
...extractGeneratorCollectionAndName(
`release-group "${releaseGroupName}"`,
releaseGroup.version.generator
),
configGeneratorOptions: releaseGroup.version.generatorOptions,
});
const semverSpecifier = await resolveSemverSpecifier(
args.specifier,
`What kind of change is this for the ${
releaseGroupToFilteredProjects.get(releaseGroup).size
} matched project(s) within release group "${releaseGroupName}"?`,
`What is the exact version for the ${
releaseGroupToFilteredProjects.get(releaseGroup).size
} matched project(s) within release group "${releaseGroupName}"?`
);
await runVersionOnProjects(
projectGraph,
nxJson,
args,
tree,
generatorData,
Array.from(releaseGroupToFilteredProjects.get(releaseGroup)),
semverSpecifier
);
}
printChanges(tree, !!args.dryRun);
return process.exit(0);
}
/**
* The user is filtering by release group
*/
if (args.groups?.length) {
releaseGroups = releaseGroups.filter((g) => args.groups?.includes(g.name));
}
// Should be an impossible state, as we should have explicitly handled any errors/invalid config by now
if (!releaseGroups.length) {
output.error({
title: `No projects could be matched for versioning, please report this case and include your nx.json config`,
});
process.exit(1);
}
/**
* Run semver versioning for all remaining release groups
*/
for (const releaseGroup of releaseGroups) {
const releaseGroupName = releaseGroup.name;
// Resolve the generator data for the current release group
const generatorData = resolveGeneratorData({
...extractGeneratorCollectionAndName(
`release-group "${releaseGroupName}"`,
releaseGroup.version.generator
),
configGeneratorOptions: releaseGroup.version.generatorOptions,
});
const semverSpecifier = await resolveSemverSpecifier(
args.specifier,
releaseGroupName === CATCH_ALL_RELEASE_GROUP
? `What kind of change is this for all packages?`
: `What kind of change is this for release group "${releaseGroupName}"?`,
releaseGroupName === CATCH_ALL_RELEASE_GROUP
? `What is the exact version for all packages?`
: `What is the exact version for release group "${releaseGroupName}"?`
);
await runVersionOnProjects(
projectGraph,
nxJson,
args,
tree,
generatorData,
releaseGroup.projects,
semverSpecifier
);
}
printChanges(tree, !!args.dryRun);
process.exit(0);
}
async function runVersionOnProjects(
projectGraph: ProjectGraph,
nxJson: NxJsonConfiguration,
args: VersionOptions,
tree: Tree,
generatorData: GeneratorData,
projectNames: string[],
newVersionSpecifier: string
) {
// Should be impossible state
if (!newVersionSpecifier) {
output.error({
title: `No version or semver keyword could be determined`,
});
process.exit(1);
}
// Specifier could be user provided so we need to validate it
if (
!valid(newVersionSpecifier) &&
!isRelativeVersionKeyword(newVersionSpecifier)
) {
output.error({
title: `The given version specifier "${newVersionSpecifier}" is not valid. You provide an exact version or a valid semver keyword such as "major", "minor", "patch", etc.`,
});
process.exit(1);
}
const generatorOptions: ReleaseVersionGeneratorSchema = {
projects: projectNames.map((p) => projectGraph.nodes[p]),
projectGraph,
specifier: newVersionSpecifier,
preid: args.preid,
...generatorData.configGeneratorOptions,
};
// Apply generator defaults from schema.json file etc
const combinedOpts = await combineOptionsForGenerator(
generatorOptions as any,
generatorData.collectionName,
generatorData.normalizedGeneratorName,
readProjectsConfigurationFromProjectGraph(projectGraph),
nxJson,
generatorData.schema,
false,
null,
relative(process.cwd(), workspaceRoot),
args.verbose
);
const releaseVersionGenerator = generatorData.implementationFactory();
await releaseVersionGenerator(tree, combinedOpts);
}
function printChanges(tree: Tree, isDryRun: boolean) {
const changes = tree.listChanges();
console.log('');
// Print the changes
changes.forEach((f) => {
if (f.type === 'CREATE') {
console.error(
`${chalk.green('CREATE')} ${f.path}${
isDryRun ? chalk.keyword('orange')(' [dry-run]') : ''
}`
);
printDiff('', f.content?.toString() || '');
} else if (f.type === 'UPDATE') {
console.error(
`${chalk.white('UPDATE')} ${f.path}${
isDryRun ? chalk.keyword('orange')(' [dry-run]') : ''
}`
);
const currentContentsOnDisk = readFileSync(
joinPathFragments(tree.root, f.path)
).toString();
printDiff(currentContentsOnDisk, f.content?.toString() || '');
} else if (f.type === 'DELETE') {
throw new Error(
'Unexpected DELETE change, please report this as an issue'
);
}
});
if (!isDryRun) {
flushChanges(workspaceRoot, changes);
}
if (isDryRun) {
logger.warn(`\nNOTE: The "dryRun" flag means no changes were made.`);
}
}
async function resolveSemverSpecifier(
cliArgSpecifier: string,
selectionMessage: string,
customVersionMessage: string
): Promise<string> {
try {
let newVersionSpecifier = cliArgSpecifier;
// If the user didn't provide a new version specifier directly on the CLI, prompt for one
if (!newVersionSpecifier) {
const reply = await enquirer.prompt<{ specifier: string }>([
{
name: 'specifier',
message: selectionMessage,
type: 'select',
choices: [
...RELEASE_TYPES.map((t) => ({ name: t, message: t })),
{
name: 'custom',
message: 'Custom exact version',
},
],
},
]);
if (reply.specifier !== 'custom') {
newVersionSpecifier = reply.specifier;
} else {
const reply = await enquirer.prompt<{ specifier: string }>([
{
name: 'specifier',
message: customVersionMessage,
type: 'input',
validate: (input) => {
if (valid(input)) {
return true;
}
return 'Please enter a valid semver version';
},
},
]);
newVersionSpecifier = reply.specifier;
}
}
return newVersionSpecifier;
} catch {
// We need to catch the error from enquirer prompt, otherwise yargs will print its help
process.exit(1);
}
}
function extractGeneratorCollectionAndName(
description: string,
generatorString: string
) {
let collectionName: string;
let generatorName: string;
const parsedGeneratorString = parseGeneratorString(generatorString);
collectionName = parsedGeneratorString.collection;
generatorName = parsedGeneratorString.generator;
if (!collectionName || !generatorName) {
throw new Error(
`Invalid generator string: ${generatorString} used for ${description}. Must be in the format of [collectionName]:[generatorName]`
);
}
return { collectionName, generatorName };
}
interface GeneratorData {
collectionName: string;
generatorName: string;
configGeneratorOptions: NxJsonConfiguration['release']['groups'][number]['version']['generatorOptions'];
normalizedGeneratorName: string;
schema: any;
implementationFactory: () => Generator<unknown>;
}
function resolveGeneratorData({
collectionName,
generatorName,
configGeneratorOptions,
}): GeneratorData {
const { normalizedGeneratorName, schema, implementationFactory } =
getGeneratorInformation(collectionName, generatorName, workspaceRoot);
return {
collectionName,
generatorName,
configGeneratorOptions,
normalizedGeneratorName,
schema,
implementationFactory,
};
}

View File

@ -65,7 +65,7 @@ export async function runMany(
projectNames
);
} else {
await runCommand(
const status = await runCommand(
projects,
projectGraph,
{ nxJson },
@ -75,6 +75,9 @@ export async function runMany(
extraTargetDependencies,
extraOptions
);
// fix for https://github.com/nrwl/nx/issues/1666
if (process.stdin['unref']) (process.stdin as any).unref();
process.exit(status);
}
}

View File

@ -79,7 +79,7 @@ export async function runOne(
projectNames
);
} else {
await runCommand(
const status = await runCommand(
projects,
projectGraph,
{ nxJson },
@ -89,6 +89,9 @@ export async function runOne(
extraTargetDependencies,
extraOptions
);
// fix for https://github.com/nrwl/nx/issues/1666
if (process.stdin['unref']) (process.stdin as any).unref();
process.exit(status);
}
}

View File

@ -1,14 +1,33 @@
import { Argv } from 'yargs';
export function withExcludeOption(yargs: Argv) {
interface ExcludeOptions {
exclude: string[];
}
export function withExcludeOption(yargs: Argv): Argv<ExcludeOptions> {
return yargs.option('exclude', {
describe: 'Exclude certain projects from being processed',
type: 'string',
coerce: parseCSV,
});
}) as any;
}
export function withRunOptions(yargs: Argv) {
export interface RunOptions {
exclude: string;
parallel: string;
maxParallel: number;
runner: string;
prod: boolean;
graph: string;
verbose: boolean;
nxBail: boolean;
nxIgnoreCycles: boolean;
skipNxCache: boolean;
cloud: boolean;
dte: boolean;
}
export function withRunOptions<T>(yargs: Argv<T>): Argv<T & RunOptions> {
return withExcludeOption(yargs)
.option('parallel', {
describe: 'Max number of parallel processes [default is 3]',
@ -47,17 +66,17 @@ export function withRunOptions(yargs: Argv) {
describe:
'Prints additional information about the commands (e.g., stack traces)',
})
.option('nx-bail', {
.option('nxBail', {
describe: 'Stop command execution after the first failed task',
type: 'boolean',
default: false,
})
.option('nx-ignore-cycles', {
.option('nxIgnoreCycles', {
describe: 'Ignore cycles in the task graph',
type: 'boolean',
default: false,
})
.options('skip-nx-cache', {
.options('skipNxCache', {
describe:
'Rerun the tasks even when the results are available in the cache',
type: 'boolean',
@ -70,7 +89,7 @@ export function withRunOptions(yargs: Argv) {
.options('dte', {
type: 'boolean',
hidden: true,
});
}) as Argv<Omit<RunOptions, 'projects' | 'exclude'>> as any;
}
export function withTargetAndConfigurationOption(
@ -146,7 +165,17 @@ export function withAffectedOptions(yargs: Argv) {
});
}
export function withRunManyOptions(yargs: Argv) {
export interface RunManyOptions extends RunOptions {
projects: string[];
/**
* @deprecated This is deprecated
*/
all: boolean;
}
export function withRunManyOptions<T>(
yargs: Argv<T>
): Argv<T & RunManyOptions> {
return withRunOptions(yargs)
.parserConfiguration({
'strip-dashed': true,
@ -165,16 +194,22 @@ export function withRunManyOptions(yargs: Argv) {
'[deprecated] `run-many` runs all targets on all projects in the workspace if no projects are provided. This option is no longer required.',
type: 'boolean',
default: true,
});
}) as Argv<T & RunManyOptions>;
}
export function withOverrides(args: any): any {
args.__overrides_unparsed__ = (args['--'] ?? args._.slice(1)).map((v) =>
v.toString()
export function withOverrides<T extends { _: Array<string | number> }>(
args: T,
commandLevel: number = 1
): T & { __overrides_unparsed__: string[] } {
const unparsedArgs: string[] = (args['--'] ?? args._.slice(commandLevel)).map(
(v) => v.toString()
);
delete args['--'];
delete args._;
return args;
return {
...args,
__overrides_unparsed__: unparsedArgs,
};
}
export function withOutputStyleOption(
@ -279,9 +314,9 @@ export function withRunOneOptions(yargs: Argv) {
}
}
export function parseCSV(args: string[] | string) {
export function parseCSV(args: string[] | string): string[] {
if (!args) {
return args;
return [];
}
if (Array.isArray(args)) {
return args;

View File

@ -50,6 +50,35 @@ interface NxInstallationConfiguration {
plugins?: Record<string, string>;
}
/**
* **ALPHA**
*/
interface NxReleaseVersionConfiguration {
generator: string;
generatorOptions?: Record<string, unknown>;
}
/**
* **ALPHA**
*/
interface NxReleaseConfiguration {
/**
* @note: When no groups are configured at all (the default), all projects in the workspace are treated as
* if they were in a release group together.
*/
groups?: Record<
string,
{
projects: string[] | string;
/**
* If no version config is provided for the group, we will assume that @nx/js:release-version
* is the desired generator implementation, allowing for terser config for the common case.
*/
version?: NxReleaseVersionConfiguration;
}
>;
}
/**
* Nx.json configuration
*
@ -158,6 +187,11 @@ export interface NxJsonConfiguration<T = '*' | string[]> {
* useful for workspaces that don't have a root package.json + node_modules.
*/
installation?: NxInstallationConfiguration;
/**
* **ALPHA**: Configuration for `nx release` (versioning and publishing of applications and libraries)
*/
release?: NxReleaseConfiguration;
}
export function readNxJson(root: string = workspaceRoot): NxJsonConfiguration {

View File

@ -9,7 +9,13 @@ const libConfig = (root, name?: string) => ({
projectType: 'library',
root: `libs/${root}`,
sourceRoot: `libs/${root}/src`,
targets: {},
targets: {
'nx-release-publish': {
dependsOn: ['^nx-release-publish'],
executor: '@nx/js:release-publish',
options: {},
},
},
});
const packageLibConfig = (root, name?: string) => ({
@ -17,7 +23,13 @@ const packageLibConfig = (root, name?: string) => ({
root,
sourceRoot: root,
projectType: 'library',
targets: {},
targets: {
'nx-release-publish': {
dependsOn: ['^nx-release-publish'],
executor: '@nx/js:release-publish',
options: {},
},
},
});
describe('Workspaces', () => {
@ -86,7 +98,13 @@ describe('Workspaces', () => {
root: 'libs/lib1',
sourceRoot: 'libs/lib1/src',
projectType: 'library',
targets: {},
targets: {
'nx-release-publish': {
dependsOn: ['^nx-release-publish'],
executor: '@nx/js:release-publish',
options: {},
},
},
});
expect(projects.lib2).toEqual(lib2Config);
expect(projects['domain-lib3']).toEqual(domainPackageConfig);
@ -127,7 +145,13 @@ describe('Workspaces', () => {
root: 'packages/my-package',
sourceRoot: 'packages/my-package',
projectType: 'library',
targets: {},
targets: {
'nx-release-publish': {
dependsOn: ['^nx-release-publish'],
executor: '@nx/js:release-publish',
options: {},
},
},
});
}
);

View File

@ -39,6 +39,7 @@ export function updateWorkspaceConfiguration(
affected,
extends: ext,
installation,
release,
} = workspaceConfig;
const nxJson: Required<NxJsonConfiguration> = {
@ -56,6 +57,7 @@ export function updateWorkspaceConfiguration(
defaultProject,
extends: ext,
installation,
release,
};
updateNxJson(tree, nxJson);

View File

@ -11,6 +11,14 @@ import {
import { CreateNodesContext } from '../../../utils/nx-plugin';
const { createNodes } = CreateProjectJsonProjectsPlugin;
const defaultReleasePublishTarget = {
'nx-release-publish': {
dependsOn: ['^nx-release-publish'],
executor: '@nx/js:release-publish',
options: {},
},
};
describe('nx project.json plugin', () => {
let context: CreateNodesContext;
beforeEach(() => {
@ -72,6 +80,13 @@ describe('nx project.json plugin', () => {
"executor": "nx:run-commands",
"options": {},
},
"nx-release-publish": {
"dependsOn": [
"^nx-release-publish",
],
"executor": "@nx/js:release-publish",
"options": {},
},
"test": {
"executor": "nx:run-script",
"options": {
@ -115,7 +130,10 @@ describe('nx project.json plugin', () => {
packageJson,
projectJsonTargets
);
expect(result).toEqual(projectJsonTargets);
expect(result).toEqual({
...projectJsonTargets,
...defaultReleasePublishTarget,
});
});
it('should provide targets from project.json and package.json', () => {
@ -135,6 +153,7 @@ describe('nx project.json plugin', () => {
expect(result).toEqual({
...projectJsonTargets,
build: packageJsonBuildTarget,
...defaultReleasePublishTarget,
});
});
@ -158,6 +177,7 @@ describe('nx project.json plugin', () => {
);
expect(result).toEqual({
build: { ...packageJsonBuildTarget, outputs: ['custom'] },
...defaultReleasePublishTarget,
});
});
@ -171,6 +191,7 @@ describe('nx project.json plugin', () => {
script: 'build',
},
},
...defaultReleasePublishTarget,
});
});
@ -200,6 +221,7 @@ describe('nx project.json plugin', () => {
executor: 'nx:run-script',
options: { script: 'test' },
},
...defaultReleasePublishTarget,
});
});
@ -233,6 +255,7 @@ describe('nx project.json plugin', () => {
executor: 'nx:run-script',
options: { script: 'test' },
},
...defaultReleasePublishTarget,
});
});
});

View File

@ -144,7 +144,7 @@ export async function runCommand(
initiatingProject: string | null,
extraTargetDependencies: Record<string, (TargetDependencyConfig | string)[]>,
extraOptions: { excludeTaskDependencies: boolean; loadDotEnvFiles: boolean }
) {
): Promise<NodeJS.Process['exitCode']> {
const status = await handleErrors(
process.env.NX_VERBOSE_LOGGING === 'true',
async () => {
@ -189,9 +189,8 @@ export async function runCommand(
return status;
}
);
// fix for https://github.com/nrwl/nx/issues/1666
if (process.stdin['unref']) (process.stdin as any).unref();
process.exit(status);
return status;
}
function setEnvVarsBasedOnArgs(nxArgs: NxArgs, loadDotEnvFiles: boolean) {

View File

@ -37,6 +37,23 @@ export interface NxArgs {
type?: string;
}
export function createOverrides(__overrides_unparsed__: string[] = []) {
let overrides =
yargsParser(__overrides_unparsed__, {
configuration: {
'camel-case-expansion': false,
'dot-notation': true,
},
}) || {};
if (!overrides._ || overrides._.length === 0) {
delete overrides._;
}
overrides.__overrides_unparsed__ = __overrides_unparsed__;
return overrides;
}
export function splitArgsIntoNxArgsAndOverrides(
args: { [k: string]: any },
mode: 'run-one' | 'run-many' | 'affected' | 'print-affected',
@ -66,18 +83,8 @@ export function splitArgsIntoNxArgsAndOverrides(
}
const nxArgs: RawNxArgs = args;
let overrides = yargsParser(args.__overrides_unparsed__ as string[], {
configuration: {
'camel-case-expansion': false,
'dot-notation': true,
},
});
if (!overrides._ || overrides._.length === 0) {
delete overrides._;
}
overrides.__overrides_unparsed__ = args.__overrides_unparsed__;
let overrides = createOverrides(args.__overrides_unparsed__);
delete (nxArgs as any).$0;
delete (nxArgs as any).__overrides_unparsed__;
@ -322,7 +329,7 @@ export function getProjectRoots(
return projectNames.map((name) => nodes[name].data.root);
}
export function readGraphFileFromGraphArg({ graph }: NxArgs) {
export function readGraphFileFromGraphArg({ graph }: Pick<NxArgs, 'graph'>) {
return typeof graph === 'string' && graph !== 'true' && graph !== ''
? graph
: undefined;

View File

@ -70,6 +70,11 @@ describe('readTargetsFromPackageJson', () => {
script: 'build',
},
},
'nx-release-publish': {
dependsOn: ['^nx-release-publish'],
executor: '@nx/js:release-publish',
options: {},
},
});
});
@ -90,6 +95,11 @@ describe('readTargetsFromPackageJson', () => {
});
expect(result).toEqual({
build: { ...packageJsonBuildTarget, outputs: ['custom'] },
'nx-release-publish': {
dependsOn: ['^nx-release-publish'],
executor: '@nx/js:release-publish',
options: {},
},
});
});
@ -111,6 +121,11 @@ describe('readTargetsFromPackageJson', () => {
executor: 'nx:run-script',
options: { script: 'test' },
},
'nx-release-publish': {
dependsOn: ['^nx-release-publish'],
executor: '@nx/js:release-publish',
options: {},
},
});
});
});

View File

@ -141,6 +141,16 @@ export function readTargetsFromPackageJson({ scripts, nx }: PackageJson) {
res[script] = buildTargetFromScript(script, nx);
}
});
// Add implicit nx-release-publish target for all package.json files to allow for lightweight configuration for package based repos
if (!res['nx-release-publish']) {
res['nx-release-publish'] = {
dependsOn: ['^nx-release-publish'],
executor: '@nx/js:release-publish',
options: {},
};
}
return res;
}

1770
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff