Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/nx/src/command-line/release/command-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export type ReleaseOptions = NxReleaseArgs &
preid?: VersionOptions['preid'];
skipPublish?: boolean;
otp?: number;
gitPush?: boolean;
};

export type VersionPlanArgs = {
Expand Down Expand Up @@ -241,6 +242,11 @@ const releaseCommand: CommandModule<NxReleaseArgs, ReleaseOptions> = {
description:
'Skip publishing by automatically answering no to the confirmation prompt for publishing.',
})
.option('git-push', {
describe:
'Whether or not to automatically push the changes made by this command to the remote git repository.',
type: 'boolean',
})
.option('otp', {
type: 'number',
description:
Expand Down
234 changes: 234 additions & 0 deletions packages/nx/src/command-line/release/release.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import type { ProjectFileMap, ProjectGraph } from '../../config/project-graph';
import { TempFs } from '../../internal-testing-utils/temp-fs';
import type { ReleaseGroupWithName } from './config/filter-release-groups';
import { createAPI } from './release';
import type { ReleaseGraph } from './utils/release-graph';

const { releaseVersion } = vi.hoisted(() => ({
releaseVersion: vi.fn(),
}));

vi.mock('./version', () => ({
createAPI: () => releaseVersion,
}));

vi.mock('./publish', () => ({
createAPI: () => vi.fn(),
}));

vi.mock('../../project-graph/project-graph', () => ({
createProjectGraphAsync: vi.fn(),
}));

vi.mock('../../project-graph/file-map-utils', () => ({
createProjectFileMapUsingProjectGraph: vi.fn(),
createFileMapUsingProjectGraph: vi.fn(() =>
Promise.resolve({
fileMap: { projectFileMap: {}, nonProjectFiles: [] },
})
),
}));

vi.mock('./utils/git', async () => ({
...(await vi.importActual('./utils/git')),
getCommitHash: vi.fn(() => Promise.resolve('abc123')),
gitAdd: vi.fn(),
gitCommit: vi.fn(),
gitTag: vi.fn(),
gitPush: vi.fn(),
}));

const { createProjectGraphAsync } =
await import('../../project-graph/project-graph');
const { createProjectFileMapUsingProjectGraph } =
await import('../../project-graph/file-map-utils');
const { gitCommit, gitTag, gitPush } = await import('./utils/git');

describe('release', () => {
let tempFs: TempFs;
let projectGraph: ProjectGraph;
let projectFileMap: ProjectFileMap;
let releaseGroup: ReleaseGroupWithName;

beforeEach(async () => {
vi.clearAllMocks();

tempFs = new TempFs('nx-release-command-test');
await tempFs.createFiles({
'package.json': JSON.stringify({
name: 'root',
version: '0.0.0',
private: true,
}),
'packages/pkg-a/package.json': JSON.stringify({
name: 'pkg-a',
version: '1.0.0',
}),
});

projectGraph = {
nodes: {
'pkg-a': {
name: 'pkg-a',
type: 'lib',
data: {
root: 'packages/pkg-a',
tags: [],
targets: {
'nx-release-publish': {},
},
} as any,
},
},
dependencies: {},
};
projectFileMap = {
'pkg-a': [
{
file: 'packages/pkg-a/package.json',
hash: 'abc',
},
],
};

createProjectGraphAsync.mockResolvedValue(projectGraph);
createProjectFileMapUsingProjectGraph.mockResolvedValue(projectFileMap);

releaseGroup = {
name: '__default__',
projectsRelationship: 'fixed',
projects: ['pkg-a'],
changelog: false,
versionPlans: false,
resolvedVersionPlans: false,
releaseTag: {
pattern: 'v{version}',
preferDockerVersion: false,
},
} as unknown as ReleaseGroupWithName;

const releaseGraph = {
releaseGroups: [releaseGroup],
allReleaseGroups: [releaseGroup],
sortedReleaseGroups: ['__default__'],
releaseGroupToFilteredProjects: new Map([
[releaseGroup, new Set(['pkg-a'])],
]),
} as unknown as ReleaseGraph;

releaseVersion.mockResolvedValue({
workspaceVersion: '1.0.1',
projectsVersionData: {
'pkg-a': {
currentVersion: '1.0.0',
newVersion: '1.0.1',
dependentProjects: [],
},
},
releaseGraph,
});

vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`process.exit(${code})`);
}) as typeof process.exit);
});

afterEach(() => {
tempFs.cleanup();
vi.restoreAllMocks();
});

async function writeNxJson(gitPush: boolean) {
await tempFs.createFiles({
'nx.json': JSON.stringify({
release: {
projects: ['pkg-a'],
git: {
commit: true,
tag: true,
push: gitPush,
},
changelog: {
workspaceChangelog: false,
projectChangelogs: false,
},
},
}),
});
}

function runRelease(extraArgs: Record<string, unknown> = {}) {
const release = createAPI({}, false);
return release({
specifier: 'patch',
skipPublish: true,
dryRun: false,
...extraArgs,
});
}

it('pushes once after tagging when git.push is true and createRelease is not configured', async () => {
await writeNxJson(true);

await runRelease();

expect(releaseVersion).toHaveBeenCalledTimes(1);
expect(releaseVersion).toHaveBeenCalledWith(
expect.objectContaining({
gitCommit: false,
gitTag: false,
gitPush: false,
})
);
expect(gitCommit).toHaveBeenCalledTimes(1);
expect(gitTag).toHaveBeenCalledTimes(1);
expect(gitPush).toHaveBeenCalledTimes(1);
expect(gitPush).toHaveBeenCalledWith(
expect.objectContaining({ dryRun: false })
);
expect(vi.mocked(gitTag).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(gitPush).mock.invocationCallOrder[0]
);
});

it('does not push when git.push is false', async () => {
await writeNxJson(false);

await runRelease();

expect(releaseVersion).toHaveBeenCalledWith(
expect.objectContaining({ gitPush: false })
);
expect(gitCommit).toHaveBeenCalledTimes(1);
expect(gitTag).toHaveBeenCalledTimes(1);
expect(gitPush).not.toHaveBeenCalled();
});

it('does not push when gitPush is explicitly false', async () => {
await writeNxJson(true);

await runRelease({ gitPush: false });

expect(releaseVersion).toHaveBeenCalledWith(
expect.objectContaining({ gitPush: false })
);
expect(gitPush).not.toHaveBeenCalled();
});

it('pushes once after tagging when gitPush is explicitly true', async () => {
await writeNxJson(false);

await runRelease({ gitPush: true, dryRun: true });

expect(releaseVersion).toHaveBeenCalledWith(
expect.objectContaining({ gitPush: false })
);
expect(gitPush).toHaveBeenCalledTimes(1);
expect(gitPush).toHaveBeenCalledWith(
expect.objectContaining({ dryRun: true })
);
expect(vi.mocked(gitTag).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(gitPush).mock.invocationCallOrder[0]
);
});
});
14 changes: 8 additions & 6 deletions packages/nx/src/command-line/release/release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export function createAPI(
const shouldStage =
(shouldCommit || userProvidedReleaseConfig.git?.stageChanges) ?? false;
const shouldTag = userProvidedReleaseConfig.git?.tag ?? true;
const shouldPushFromGitConfig = args.gitPush ?? nxReleaseConfig.git.push;

const shouldCreateWorkspaceRemoteRelease = shouldCreateRemoteRelease(
nxReleaseConfig.changelog.workspaceChangelog
Expand All @@ -137,6 +138,7 @@ export function createAPI(
stageChanges: shouldStage,
gitCommit: false,
gitTag: false,
gitPush: false,
deleteVersionPlans: false,
});

Expand Down Expand Up @@ -289,13 +291,13 @@ export function createAPI(
}

let hasPushedChanges = false;
// If the workspace or any of the release groups specify that a remote release should be created, we need to push the changes to the remote
// Push the release commit and tags when git push is enabled, or when a remote release needs them on the remote.
const shouldPush =
(shouldCreateWorkspaceRemoteRelease ||
releaseGraph.releaseGroups.some((group) =>
shouldCreateRemoteRelease(group.changelog)
)) ??
false;
shouldPushFromGitConfig ||
shouldCreateWorkspaceRemoteRelease ||
releaseGraph.releaseGroups.some((group) =>
shouldCreateRemoteRelease(group.changelog)
);
if (shouldPush) {
output.logSingleLine(`Pushing to git remote "origin"`);
await gitPush({
Expand Down