From 3c600b46d9052426a8f79e114f0ed9ddd6d7b939 Mon Sep 17 00:00:00 2001 From: Cestercian Date: Thu, 22 Oct 2026 15:29:14 +0000 Subject: [PATCH] fix(release): push after committing and tagging when git.push is enabled --- .../command-line/release/command-object.ts | 6 + .../src/command-line/release/release.spec.ts | 234 ++++++++++++++++++ .../nx/src/command-line/release/release.ts | 14 +- 3 files changed, 248 insertions(+), 6 deletions(-) create mode 100644 packages/nx/src/command-line/release/release.spec.ts diff --git a/packages/nx/src/command-line/release/command-object.ts b/packages/nx/src/command-line/release/command-object.ts index 41a92a7e66b..c4c50250f19 100644 --- a/packages/nx/src/command-line/release/command-object.ts +++ b/packages/nx/src/command-line/release/command-object.ts @@ -121,6 +121,7 @@ export type ReleaseOptions = NxReleaseArgs & preid?: VersionOptions['preid']; skipPublish?: boolean; otp?: number; + gitPush?: boolean; }; export type VersionPlanArgs = { @@ -241,6 +242,11 @@ const releaseCommand: CommandModule = { 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: diff --git a/packages/nx/src/command-line/release/release.spec.ts b/packages/nx/src/command-line/release/release.spec.ts new file mode 100644 index 00000000000..06a916e907a --- /dev/null +++ b/packages/nx/src/command-line/release/release.spec.ts @@ -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 = {}) { + 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] + ); + }); +}); diff --git a/packages/nx/src/command-line/release/release.ts b/packages/nx/src/command-line/release/release.ts index 3e63e9c460f..4aebb1ad4cc 100644 --- a/packages/nx/src/command-line/release/release.ts +++ b/packages/nx/src/command-line/release/release.ts @@ -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 @@ -137,6 +138,7 @@ export function createAPI( stageChanges: shouldStage, gitCommit: false, gitTag: false, + gitPush: false, deleteVersionPlans: false, }); @@ -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({