From 3c21f7bee95b0fcb3b9ecb5dcea0c4e4e6d92d9c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 20 May 2026 01:32:57 +0000 Subject: [PATCH 01/12] Update release commit message Co-authored-by: Elliot Winkler --- CHANGELOG.md | 4 ++++ src/functional.test.ts | 6 ++---- src/monorepo-workflow-operations.test.ts | 24 ++++++++++++------------ src/monorepo-workflow-operations.ts | 2 +- src/ui.ts | 2 +- 5 files changed, 20 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 405924d..a390b83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Use `Release X.Y.Z` instead of `Update Release X.Y.Z` for release preparation commit messages. + ## [4.2.1] ### Changed diff --git a/src/functional.test.ts b/src/functional.test.ts index 56ebd54..3af55b2 100644 --- a/src/functional.test.ts +++ b/src/functional.test.ts @@ -590,7 +590,7 @@ describe('create-release-branch (functional)', () => { }); // Tests five things: - // * The latest commit should be called "Update Release 2.0.0" + // * The latest commit should be called "Release 2.0.0" // * The before latest commit should be called "Initialize Release 2.0.0" // * The latest commit should be the current commit (HEAD) // * The latest branch should be called "release/2.0.0" @@ -617,9 +617,7 @@ describe('create-release-branch (functional)', () => { '--max-count=1', ]) ).stdout; - expect(latestCommitsInReverse[0].subject).toBe( - 'Update Release 2.0.0', - ); + expect(latestCommitsInReverse[0].subject).toBe('Release 2.0.0'); expect(latestCommitsInReverse[1].subject).toBe( 'Initialize Release 2.0.0', ); diff --git a/src/monorepo-workflow-operations.test.ts b/src/monorepo-workflow-operations.test.ts index f92951b..ad50a71 100644 --- a/src/monorepo-workflow-operations.test.ts +++ b/src/monorepo-workflow-operations.test.ts @@ -473,7 +473,7 @@ describe('monorepo-workflow-operations', () => { expect(commitAllChangesSpy).toHaveBeenNthCalledWith( 2, projectDirectoryPath, - `Update Release ${releaseVersion}`, + `Release ${releaseVersion}`, ); expect(fixConstraintsSpy).toHaveBeenCalledTimes(1); @@ -517,7 +517,7 @@ describe('monorepo-workflow-operations', () => { expect(commitAllChangesSpy).toHaveBeenNthCalledWith( 3, projectDirectoryPath, - `Update Release ${releaseVersion}`, + `Release ${releaseVersion}`, ); }); }); @@ -592,7 +592,7 @@ describe('monorepo-workflow-operations', () => { expect(commitAllChangesSpy).toHaveBeenCalledWith( projectDirectoryPath, - 'Update Release 2.0.0', + 'Release 2.0.0', ); }); }); @@ -688,7 +688,7 @@ describe('monorepo-workflow-operations', () => { ); expect(commitAllChangesSpy).not.toHaveBeenCalledWith( projectDirectoryPath, - 'Update Release 2.0.0', + 'Release 2.0.0', ); }); }); @@ -905,7 +905,7 @@ describe('monorepo-workflow-operations', () => { expect(commitAllChangesSpy).not.toHaveBeenCalledWith( projectDirectoryPath, - 'Update Release 2.0.0', + 'Release 2.0.0', ); }); }); @@ -1131,7 +1131,7 @@ describe('monorepo-workflow-operations', () => { expect(commitAllChangesSpy).toHaveBeenCalledWith( projectDirectoryPath, - 'Update Release 2.0.0', + 'Release 2.0.0', ); }); }); @@ -1402,7 +1402,7 @@ describe('monorepo-workflow-operations', () => { expect(commitAllChangesSpy).toHaveBeenCalledWith( projectDirectoryPath, - 'Update Release 2.0.0', + 'Release 2.0.0', ); }); }); @@ -1494,7 +1494,7 @@ describe('monorepo-workflow-operations', () => { expect(commitAllChangesSpy).not.toHaveBeenCalledWith( projectDirectoryPath, - 'Update Release 2.0.0', + 'Release 2.0.0', ); }); }); @@ -1711,7 +1711,7 @@ describe('monorepo-workflow-operations', () => { expect(commitAllChangesSpy).not.toHaveBeenCalledWith( projectDirectoryPath, - 'Update Release 2.0.0', + 'Release 2.0.0', ); }); }); @@ -1945,7 +1945,7 @@ describe('monorepo-workflow-operations', () => { expect(commitAllChangesSpy).toHaveBeenCalledWith( projectDirectoryPath, - 'Update Release 2.0.0', + 'Release 2.0.0', ); }); }); @@ -2037,7 +2037,7 @@ describe('monorepo-workflow-operations', () => { expect(commitAllChangesSpy).not.toHaveBeenCalledWith( projectDirectoryPath, - 'Update Release 2.0.0', + 'Release 2.0.0', ); }); }); @@ -2288,7 +2288,7 @@ describe('monorepo-workflow-operations', () => { expect(commitAllChangesSpy).not.toHaveBeenCalledWith( projectDirectoryPath, - 'Update Release 2.0.0', + 'Release 2.0.0', ); }); }); diff --git a/src/monorepo-workflow-operations.ts b/src/monorepo-workflow-operations.ts index c3aa40c..351a7fe 100644 --- a/src/monorepo-workflow-operations.ts +++ b/src/monorepo-workflow-operations.ts @@ -160,6 +160,6 @@ export async function followMonorepoWorkflow({ await deduplicateDependencies(project.directoryPath); await commitAllChanges( project.directoryPath, - `Update Release ${newReleaseVersion}`, + `Release ${newReleaseVersion}`, ); } diff --git a/src/ui.ts b/src/ui.ts index 6c59779..873a6cd 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -342,7 +342,7 @@ function createApp({ await deduplicateDependencies(project.directoryPath); await commitAllChanges( project.directoryPath, - `Update Release ${version}`, + `Release ${version}`, ); res.json({ status: 'success' }); From a97b5fcb04b60730f348b56a1389a04df4ff646d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 20 May 2026 01:35:35 +0000 Subject: [PATCH 02/12] Format release commit calls Co-authored-by: Elliot Winkler --- src/monorepo-workflow-operations.ts | 5 +---- src/ui.ts | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/monorepo-workflow-operations.ts b/src/monorepo-workflow-operations.ts index 351a7fe..8094fb4 100644 --- a/src/monorepo-workflow-operations.ts +++ b/src/monorepo-workflow-operations.ts @@ -158,8 +158,5 @@ export async function followMonorepoWorkflow({ await fixConstraints(project.directoryPath); await updateYarnLockfile(project.directoryPath); await deduplicateDependencies(project.directoryPath); - await commitAllChanges( - project.directoryPath, - `Release ${newReleaseVersion}`, - ); + await commitAllChanges(project.directoryPath, `Release ${newReleaseVersion}`); } diff --git a/src/ui.ts b/src/ui.ts index 873a6cd..b9fc423 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -340,10 +340,7 @@ function createApp({ await fixConstraints(project.directoryPath); await updateYarnLockfile(project.directoryPath); await deduplicateDependencies(project.directoryPath); - await commitAllChanges( - project.directoryPath, - `Release ${version}`, - ); + await commitAllChanges(project.directoryPath, `Release ${version}`); res.json({ status: 'success' }); From b514fc988ea49ca11cc12dcd712632293674980a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 20 May 2026 01:38:45 +0000 Subject: [PATCH 03/12] Update release workflow commit tests Co-authored-by: Elliot Winkler --- src/functional.test.ts | 6 +- src/monorepo-workflow-operations.test.ts | 24 ++-- src/repo.test.ts | 14 ++ src/ui.test.ts | 163 +++++++++++++++++++++++ 4 files changed, 193 insertions(+), 14 deletions(-) create mode 100644 src/ui.test.ts diff --git a/src/functional.test.ts b/src/functional.test.ts index 3af55b2..a8f3d92 100644 --- a/src/functional.test.ts +++ b/src/functional.test.ts @@ -590,7 +590,7 @@ describe('create-release-branch (functional)', () => { }); // Tests five things: - // * The latest commit should be called "Release 2.0.0" + // * The latest commit should be called "Filter release to only selected packages" // * The before latest commit should be called "Initialize Release 2.0.0" // * The latest commit should be the current commit (HEAD) // * The latest branch should be called "release/2.0.0" @@ -617,7 +617,9 @@ describe('create-release-branch (functional)', () => { '--max-count=1', ]) ).stdout; - expect(latestCommitsInReverse[0].subject).toBe('Release 2.0.0'); + expect(latestCommitsInReverse[0].subject).toBe( + 'Filter release to only selected packages', + ); expect(latestCommitsInReverse[1].subject).toBe( 'Initialize Release 2.0.0', ); diff --git a/src/monorepo-workflow-operations.test.ts b/src/monorepo-workflow-operations.test.ts index ad50a71..d47e2b4 100644 --- a/src/monorepo-workflow-operations.test.ts +++ b/src/monorepo-workflow-operations.test.ts @@ -473,7 +473,7 @@ describe('monorepo-workflow-operations', () => { expect(commitAllChangesSpy).toHaveBeenNthCalledWith( 2, projectDirectoryPath, - `Release ${releaseVersion}`, + 'Filter release to only selected packages', ); expect(fixConstraintsSpy).toHaveBeenCalledTimes(1); @@ -517,7 +517,7 @@ describe('monorepo-workflow-operations', () => { expect(commitAllChangesSpy).toHaveBeenNthCalledWith( 3, projectDirectoryPath, - `Release ${releaseVersion}`, + 'Filter release to only selected packages', ); }); }); @@ -592,7 +592,7 @@ describe('monorepo-workflow-operations', () => { expect(commitAllChangesSpy).toHaveBeenCalledWith( projectDirectoryPath, - 'Release 2.0.0', + 'Filter release to only selected packages', ); }); }); @@ -688,7 +688,7 @@ describe('monorepo-workflow-operations', () => { ); expect(commitAllChangesSpy).not.toHaveBeenCalledWith( projectDirectoryPath, - 'Release 2.0.0', + 'Filter release to only selected packages', ); }); }); @@ -905,7 +905,7 @@ describe('monorepo-workflow-operations', () => { expect(commitAllChangesSpy).not.toHaveBeenCalledWith( projectDirectoryPath, - 'Release 2.0.0', + 'Filter release to only selected packages', ); }); }); @@ -1131,7 +1131,7 @@ describe('monorepo-workflow-operations', () => { expect(commitAllChangesSpy).toHaveBeenCalledWith( projectDirectoryPath, - 'Release 2.0.0', + 'Filter release to only selected packages', ); }); }); @@ -1402,7 +1402,7 @@ describe('monorepo-workflow-operations', () => { expect(commitAllChangesSpy).toHaveBeenCalledWith( projectDirectoryPath, - 'Release 2.0.0', + 'Filter release to only selected packages', ); }); }); @@ -1494,7 +1494,7 @@ describe('monorepo-workflow-operations', () => { expect(commitAllChangesSpy).not.toHaveBeenCalledWith( projectDirectoryPath, - 'Release 2.0.0', + 'Filter release to only selected packages', ); }); }); @@ -1711,7 +1711,7 @@ describe('monorepo-workflow-operations', () => { expect(commitAllChangesSpy).not.toHaveBeenCalledWith( projectDirectoryPath, - 'Release 2.0.0', + 'Filter release to only selected packages', ); }); }); @@ -1945,7 +1945,7 @@ describe('monorepo-workflow-operations', () => { expect(commitAllChangesSpy).toHaveBeenCalledWith( projectDirectoryPath, - 'Release 2.0.0', + 'Filter release to only selected packages', ); }); }); @@ -2037,7 +2037,7 @@ describe('monorepo-workflow-operations', () => { expect(commitAllChangesSpy).not.toHaveBeenCalledWith( projectDirectoryPath, - 'Release 2.0.0', + 'Filter release to only selected packages', ); }); }); @@ -2288,7 +2288,7 @@ describe('monorepo-workflow-operations', () => { expect(commitAllChangesSpy).not.toHaveBeenCalledWith( projectDirectoryPath, - 'Release 2.0.0', + 'Filter release to only selected packages', ); }); }); diff --git a/src/repo.test.ts b/src/repo.test.ts index c9c81a2..2ab4c6e 100644 --- a/src/repo.test.ts +++ b/src/repo.test.ts @@ -6,6 +6,7 @@ import { getCurrentBranchName, branchExists, restoreFiles, + resetLastCommit, } from './repo.js'; import * as miscUtils from './misc-utils.js'; @@ -34,6 +35,19 @@ describe('repo', () => { }); }); + describe('resetLastCommit', () => { + it('soft-resets HEAD to the previous commit', async () => { + const runCommandSpy = jest.spyOn(miscUtils, 'runCommand'); + await resetLastCommit('/path/to/project'); + + expect(runCommandSpy).toHaveBeenCalledWith( + 'git', + ['reset', '--soft', 'HEAD~1'], + { cwd: '/path/to/project' }, + ); + }); + }); + describe('getTagNames', () => { it('returns all of the tag names that match a known format, sorted by ascending semantic version order', async () => { when(jest.spyOn(miscUtils, 'getLinesFromCommand')) diff --git a/src/ui.test.ts b/src/ui.test.ts new file mode 100644 index 0000000..4c4f5d3 --- /dev/null +++ b/src/ui.test.ts @@ -0,0 +1,163 @@ +import type { Server } from 'http'; +import type express from 'express'; +import { MockWritable } from 'stdio-mock'; +import { buildMockPackage, buildMockProject } from '../tests/unit/helpers.js'; +import { createApp } from './ui.js'; +import * as projectModule from './project.js'; +import * as releasePlanModule from './release-plan.js'; +import * as repoModule from './repo.js'; +import * as yarnCommands from './yarn-commands.js'; + +jest.mock('./project'); +jest.mock('./release-plan'); +jest.mock('./repo'); +jest.mock('./yarn-commands'); + +async function withServer( + app: express.Application, + callback: (url: string) => Promise, +) { + let server: Server; + const url = await new Promise((resolve, reject) => { + server = app.listen(0, () => { + const address = server.address(); + if (address === null || typeof address === 'string') { + reject(new Error('Unable to determine server port')); + return; + } + + resolve(`http://127.0.0.1:${address.port}`); + }); + }); + + try { + await callback(url); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); + } +}); + +describe('ui', () => { + describe('createApp', () => { + it('squashes the initial release commit before committing an interactive first run', async () => { + const project = buildMockProject({ + directoryPath: '/path/to/project', + workspacePackages: { + '@scope/a': buildMockPackage('@scope/a', { + hasChangesSinceLatestRelease: true, + }), + }, + }); + const releasePlan = { newVersion: '2.0.0', packages: [] }; + const stderr = new MockWritable(); + const closeServer = jest.fn(); + const resetLastCommitSpy = jest.spyOn(repoModule, 'resetLastCommit'); + const commitAllChangesSpy = jest.spyOn(repoModule, 'commitAllChanges'); + jest.spyOn(releasePlanModule, 'planRelease').mockResolvedValue(releasePlan); + jest.spyOn(releasePlanModule, 'executeReleasePlan').mockResolvedValue(); + + const app = createApp({ + project, + defaultBranch: 'main', + formatter: 'prettier', + stderr, + version: '2.0.0', + firstRun: true, + closeServer, + }); + + await withServer(app, async (url) => { + const response = await fetch(`${url}/api/release`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ '@scope/a': 'major' }), + }); + + expect(response.ok).toBe(true); + await expect(response.json()).resolves.toStrictEqual({ + status: 'success', + }); + }); + + expect( + projectModule.restoreChangelogsForSkippedPackages, + ).toHaveBeenCalledWith({ + project, + releaseSpecificationPackages: { '@scope/a': 'major' }, + defaultBranch: 'main', + }); + expect(yarnCommands.fixConstraints).toHaveBeenCalledWith( + project.directoryPath, + ); + expect(yarnCommands.updateYarnLockfile).toHaveBeenCalledWith( + project.directoryPath, + ); + expect(yarnCommands.deduplicateDependencies).toHaveBeenCalledWith( + project.directoryPath, + ); + expect(resetLastCommitSpy).toHaveBeenCalledWith(project.directoryPath); + expect(commitAllChangesSpy).toHaveBeenCalledTimes(1); + expect(commitAllChangesSpy).toHaveBeenCalledWith( + project.directoryPath, + 'Release 2.0.0', + ); + expect(resetLastCommitSpy.mock.invocationCallOrder[0]).toBeLessThan( + commitAllChangesSpy.mock.invocationCallOrder[0], + ); + expect(closeServer).toHaveBeenCalledTimes(1); + }); + + it('does not reset HEAD before committing an existing interactive release branch', async () => { + const project = buildMockProject({ + directoryPath: '/path/to/project', + workspacePackages: { + '@scope/a': buildMockPackage('@scope/a', { + hasChangesSinceLatestRelease: true, + }), + }, + }); + const releasePlan = { newVersion: '2.0.0', packages: [] }; + const stderr = new MockWritable(); + const closeServer = jest.fn(); + const resetLastCommitSpy = jest.spyOn(repoModule, 'resetLastCommit'); + const commitAllChangesSpy = jest.spyOn(repoModule, 'commitAllChanges'); + jest.spyOn(releasePlanModule, 'planRelease').mockResolvedValue(releasePlan); + jest.spyOn(releasePlanModule, 'executeReleasePlan').mockResolvedValue(); + + const app = createApp({ + project, + defaultBranch: 'main', + formatter: 'prettier', + stderr, + version: '2.0.0', + firstRun: false, + closeServer, + }); + + await withServer(app, async (url) => { + const response = await fetch(`${url}/api/release`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ '@scope/a': 'major' }), + }); + + expect(response.ok).toBe(true); + }); + + expect(resetLastCommitSpy).not.toHaveBeenCalled(); + expect(commitAllChangesSpy).toHaveBeenCalledWith( + project.directoryPath, + 'Release 2.0.0', + ); + }); + }); +} From ea2eff0f960d3a5a7c2e35fb5c87202232c6b96f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 20 May 2026 01:39:38 +0000 Subject: [PATCH 04/12] Fix UI workflow test syntax Co-authored-by: Elliot Winkler --- src/ui.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui.test.ts b/src/ui.test.ts index 4c4f5d3..b23d60f 100644 --- a/src/ui.test.ts +++ b/src/ui.test.ts @@ -44,7 +44,7 @@ async function withServer( }); }); } -}); +} describe('ui', () => { describe('createApp', () => { @@ -160,4 +160,4 @@ describe('ui', () => { ); }); }); -} +}); From 76a55165f4136cdeec3a14724a1eaa9a33e599d6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 20 May 2026 01:40:57 +0000 Subject: [PATCH 05/12] Implement split release commit behavior Co-authored-by: Elliot Winkler --- CHANGELOG.md | 2 +- src/monorepo-workflow-operations.ts | 5 ++++- src/repo.ts | 13 +++++++++++++ src/ui.test.ts | 4 ++++ src/ui.ts | 11 +++++++++-- 5 files changed, 31 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a390b83..129e4db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Use `Release X.Y.Z` instead of `Update Release X.Y.Z` for release preparation commit messages. +- Use `Release X.Y.Z` as the single `--interactive` release preparation commit message; non-interactive release preparation now keeps `Initialize Release X.Y.Z` followed by `Filter release to only selected packages`. ## [4.2.1] diff --git a/src/monorepo-workflow-operations.ts b/src/monorepo-workflow-operations.ts index 8094fb4..868827f 100644 --- a/src/monorepo-workflow-operations.ts +++ b/src/monorepo-workflow-operations.ts @@ -158,5 +158,8 @@ export async function followMonorepoWorkflow({ await fixConstraints(project.directoryPath); await updateYarnLockfile(project.directoryPath); await deduplicateDependencies(project.directoryPath); - await commitAllChanges(project.directoryPath, `Release ${newReleaseVersion}`); + await commitAllChanges( + project.directoryPath, + 'Filter release to only selected packages', + ); } diff --git a/src/repo.ts b/src/repo.ts index ec4074f..06590da 100644 --- a/src/repo.ts +++ b/src/repo.ts @@ -143,6 +143,19 @@ export async function commitAllChanges( ]); } +/** + * Soft-resets the repository to the previous commit, leaving the reverted + * commit's changes staged so they can be recommitted with other changes. + * + * @param repositoryDirectoryPath - The file system path to the git repository. + */ +export async function resetLastCommit(repositoryDirectoryPath: string) { + await runGitCommandWithin(repositoryDirectoryPath, 'reset', [ + '--soft', + 'HEAD~1', + ]); +} + /** * Retrieves the current branch name of a git repository. * diff --git a/src/ui.test.ts b/src/ui.test.ts index b23d60f..fb76451 100644 --- a/src/ui.test.ts +++ b/src/ui.test.ts @@ -12,6 +12,10 @@ jest.mock('./project'); jest.mock('./release-plan'); jest.mock('./repo'); jest.mock('./yarn-commands'); +jest.mock('open', () => ({ + __esModule: true, + default: jest.fn(), +})); async function withServer( app: express.Application, diff --git a/src/ui.ts b/src/ui.ts index b9fc423..f6d6e65 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -18,7 +18,7 @@ import { validateAllPackageEntries, } from './release-specification.js'; import { createReleaseBranch } from './workflow-operations.js'; -import { commitAllChanges } from './repo.js'; +import { commitAllChanges, resetLastCommit } from './repo.js'; import { SemVer, semver } from './semver.js'; import { executeReleasePlan, planRelease } from './release-plan.js'; import { @@ -82,6 +82,7 @@ export async function startUI({ formatter, stderr, version: newReleaseVersion, + firstRun, closeServer: () => { // eslint-disable-next-line @typescript-eslint/no-use-before-define server.close(); @@ -126,15 +127,17 @@ export async function startUI({ * @param options.formatter - The formatter to use for formatting the changelog. * @param options.stderr - The stderr stream. * @param options.version - The release version. + * @param options.firstRun - Whether this invocation created the release branch. * @param options.closeServer - The function to close the server. * @returns The Express application. */ -function createApp({ +export function createApp({ project, defaultBranch, formatter, stderr, version, + firstRun, closeServer, }: { project: Project; @@ -142,6 +145,7 @@ function createApp({ formatter: Formatter; stderr: Pick; version: string; + firstRun: boolean; closeServer: () => void; }): express.Application { const app = express(); @@ -340,6 +344,9 @@ function createApp({ await fixConstraints(project.directoryPath); await updateYarnLockfile(project.directoryPath); await deduplicateDependencies(project.directoryPath); + if (firstRun) { + await resetLastCommit(project.directoryPath); + } await commitAllChanges(project.directoryPath, `Release ${version}`); res.json({ status: 'success' }); From 67f61a96cc370ab230c80c240186dc9bba86842a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 20 May 2026 01:41:43 +0000 Subject: [PATCH 06/12] Mock dirname in UI workflow tests Co-authored-by: Elliot Winkler --- src/ui.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ui.test.ts b/src/ui.test.ts index fb76451..6f5bc06 100644 --- a/src/ui.test.ts +++ b/src/ui.test.ts @@ -12,6 +12,9 @@ jest.mock('./project'); jest.mock('./release-plan'); jest.mock('./repo'); jest.mock('./yarn-commands'); +jest.mock('./dirname', () => ({ + getCurrentDirectoryPath: jest.fn().mockReturnValue('/path/to/somewhere'), +})); jest.mock('open', () => ({ __esModule: true, default: jest.fn(), From f656209cbf5e0853dd82b6472a144dee206f6f00 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 20 May 2026 01:42:16 +0000 Subject: [PATCH 07/12] Adjust UI response assertion Co-authored-by: Elliot Winkler --- src/ui.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui.test.ts b/src/ui.test.ts index 6f5bc06..1de2621 100644 --- a/src/ui.test.ts +++ b/src/ui.test.ts @@ -90,7 +90,7 @@ describe('ui', () => { }); expect(response.ok).toBe(true); - await expect(response.json()).resolves.toStrictEqual({ + await expect(response.json()).resolves.toEqual({ status: 'success', }); }); From 6c1a539c54ffe9730a64c2fad4430f8c5e11d12e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 20 May 2026 01:43:23 +0000 Subject: [PATCH 08/12] Fix UI workflow lint issues Co-authored-by: Elliot Winkler --- src/ui.test.ts | 29 ++++++++++++++++++----------- src/ui.ts | 2 ++ 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/ui.test.ts b/src/ui.test.ts index 1de2621..a6ee622 100644 --- a/src/ui.test.ts +++ b/src/ui.test.ts @@ -20,14 +20,18 @@ jest.mock('open', () => ({ default: jest.fn(), })); -async function withServer( - app: express.Application, - callback: (url: string) => Promise, -) { +/** + * Starts an Express app on an ephemeral port for the duration of a test. + * + * @param app - The Express app to start. + * @param run - The test logic to run while the server is listening. + */ +async function withServer(app: express.Application, run: (url: string) => Promise) { let server: Server; const url = await new Promise((resolve, reject) => { server = app.listen(0, () => { const address = server.address(); + if (address === null || typeof address === 'string') { reject(new Error('Unable to determine server port')); return; @@ -38,16 +42,15 @@ async function withServer( }); try { - await callback(url); + await run(url); } finally { await new Promise((resolve, reject) => { server.close((error) => { if (error) { - reject(error); - return; + return reject(error); } - resolve(); + return resolve(); }); }); } @@ -69,7 +72,9 @@ describe('ui', () => { const closeServer = jest.fn(); const resetLastCommitSpy = jest.spyOn(repoModule, 'resetLastCommit'); const commitAllChangesSpy = jest.spyOn(repoModule, 'commitAllChanges'); - jest.spyOn(releasePlanModule, 'planRelease').mockResolvedValue(releasePlan); + jest + .spyOn(releasePlanModule, 'planRelease') + .mockResolvedValue(releasePlan); jest.spyOn(releasePlanModule, 'executeReleasePlan').mockResolvedValue(); const app = createApp({ @@ -90,7 +95,7 @@ describe('ui', () => { }); expect(response.ok).toBe(true); - await expect(response.json()).resolves.toEqual({ + expect(JSON.parse(await response.text())).toStrictEqual({ status: 'success', }); }); @@ -137,7 +142,9 @@ describe('ui', () => { const closeServer = jest.fn(); const resetLastCommitSpy = jest.spyOn(repoModule, 'resetLastCommit'); const commitAllChangesSpy = jest.spyOn(repoModule, 'commitAllChanges'); - jest.spyOn(releasePlanModule, 'planRelease').mockResolvedValue(releasePlan); + jest + .spyOn(releasePlanModule, 'planRelease') + .mockResolvedValue(releasePlan); jest.spyOn(releasePlanModule, 'executeReleasePlan').mockResolvedValue(); const app = createApp({ diff --git a/src/ui.ts b/src/ui.ts index f6d6e65..16e9925 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -344,9 +344,11 @@ export function createApp({ await fixConstraints(project.directoryPath); await updateYarnLockfile(project.directoryPath); await deduplicateDependencies(project.directoryPath); + if (firstRun) { await resetLastCommit(project.directoryPath); } + await commitAllChanges(project.directoryPath, `Release ${version}`); res.json({ status: 'success' }); From db459d72032be11c04cda9e7812f59fed18336ff Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 20 May 2026 01:43:37 +0000 Subject: [PATCH 09/12] Format UI test helper signature Co-authored-by: Elliot Winkler --- src/ui.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ui.test.ts b/src/ui.test.ts index a6ee622..d55acc1 100644 --- a/src/ui.test.ts +++ b/src/ui.test.ts @@ -26,7 +26,10 @@ jest.mock('open', () => ({ * @param app - The Express app to start. * @param run - The test logic to run while the server is listening. */ -async function withServer(app: express.Application, run: (url: string) => Promise) { +async function withServer( + app: express.Application, + run: (url: string) => Promise, +) { let server: Server; const url = await new Promise((resolve, reject) => { server = app.listen(0, () => { From 9e297ad3c09b46627dc6f2d8c720ea36357d420a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 20 May 2026 01:49:37 +0000 Subject: [PATCH 10/12] Stabilize interactive release tests Co-authored-by: Elliot Winkler --- src/ui.test.ts | 81 +++-------------------- src/ui.ts | 170 +++++++++++++++++++++++++++++++------------------ 2 files changed, 116 insertions(+), 135 deletions(-) diff --git a/src/ui.test.ts b/src/ui.test.ts index d55acc1..7b383e6 100644 --- a/src/ui.test.ts +++ b/src/ui.test.ts @@ -1,8 +1,6 @@ -import type { Server } from 'http'; -import type express from 'express'; import { MockWritable } from 'stdio-mock'; import { buildMockPackage, buildMockProject } from '../tests/unit/helpers.js'; -import { createApp } from './ui.js'; +import { finalizeInteractiveRelease } from './ui.js'; import * as projectModule from './project.js'; import * as releasePlanModule from './release-plan.js'; import * as repoModule from './repo.js'; @@ -20,47 +18,8 @@ jest.mock('open', () => ({ default: jest.fn(), })); -/** - * Starts an Express app on an ephemeral port for the duration of a test. - * - * @param app - The Express app to start. - * @param run - The test logic to run while the server is listening. - */ -async function withServer( - app: express.Application, - run: (url: string) => Promise, -) { - let server: Server; - const url = await new Promise((resolve, reject) => { - server = app.listen(0, () => { - const address = server.address(); - - if (address === null || typeof address === 'string') { - reject(new Error('Unable to determine server port')); - return; - } - - resolve(`http://127.0.0.1:${address.port}`); - }); - }); - - try { - await run(url); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - return reject(error); - } - - return resolve(); - }); - }); - } -} - describe('ui', () => { - describe('createApp', () => { + describe('finalizeInteractiveRelease', () => { it('squashes the initial release commit before committing an interactive first run', async () => { const project = buildMockProject({ directoryPath: '/path/to/project', @@ -72,7 +31,6 @@ describe('ui', () => { }); const releasePlan = { newVersion: '2.0.0', packages: [] }; const stderr = new MockWritable(); - const closeServer = jest.fn(); const resetLastCommitSpy = jest.spyOn(repoModule, 'resetLastCommit'); const commitAllChangesSpy = jest.spyOn(repoModule, 'commitAllChanges'); jest @@ -80,29 +38,17 @@ describe('ui', () => { .mockResolvedValue(releasePlan); jest.spyOn(releasePlanModule, 'executeReleasePlan').mockResolvedValue(); - const app = createApp({ + const result = await finalizeInteractiveRelease({ project, defaultBranch: 'main', formatter: 'prettier', stderr, version: '2.0.0', firstRun: true, - closeServer, - }); - - await withServer(app, async (url) => { - const response = await fetch(`${url}/api/release`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ '@scope/a': 'major' }), - }); - - expect(response.ok).toBe(true); - expect(JSON.parse(await response.text())).toStrictEqual({ - status: 'success', - }); + releasedPackages: { '@scope/a': 'major' }, }); + expect(result).toStrictEqual({ status: 'success' }); expect( projectModule.restoreChangelogsForSkippedPackages, ).toHaveBeenCalledWith({ @@ -128,7 +74,6 @@ describe('ui', () => { expect(resetLastCommitSpy.mock.invocationCallOrder[0]).toBeLessThan( commitAllChangesSpy.mock.invocationCallOrder[0], ); - expect(closeServer).toHaveBeenCalledTimes(1); }); it('does not reset HEAD before committing an existing interactive release branch', async () => { @@ -142,7 +87,6 @@ describe('ui', () => { }); const releasePlan = { newVersion: '2.0.0', packages: [] }; const stderr = new MockWritable(); - const closeServer = jest.fn(); const resetLastCommitSpy = jest.spyOn(repoModule, 'resetLastCommit'); const commitAllChangesSpy = jest.spyOn(repoModule, 'commitAllChanges'); jest @@ -150,26 +94,17 @@ describe('ui', () => { .mockResolvedValue(releasePlan); jest.spyOn(releasePlanModule, 'executeReleasePlan').mockResolvedValue(); - const app = createApp({ + const result = await finalizeInteractiveRelease({ project, defaultBranch: 'main', formatter: 'prettier', stderr, version: '2.0.0', firstRun: false, - closeServer, - }); - - await withServer(app, async (url) => { - const response = await fetch(`${url}/api/release`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ '@scope/a': 'major' }), - }); - - expect(response.ok).toBe(true); + releasedPackages: { '@scope/a': 'major' }, }); + expect(result).toStrictEqual({ status: 'success' }); expect(resetLastCommitSpy).not.toHaveBeenCalled(); expect(commitAllChangesSpy).toHaveBeenCalledWith( project.directoryPath, diff --git a/src/ui.ts b/src/ui.ts index 16e9925..07381d8 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -42,6 +42,13 @@ type UIOptions = { stderr: Pick; }; +type InteractiveReleaseResult = + | { status: 'success' } + | { + status: 'error'; + errors: ReturnType; + }; + /** * Starts the UI for the release process. * @@ -118,6 +125,98 @@ export async function startUI({ }); } +/** + * Finalizes the release selected in the interactive UI. + * + * @param options - The options. + * @param options.project - The project object. + * @param options.defaultBranch - The default branch name. + * @param options.formatter - The formatter to use for formatting the changelog. + * @param options.stderr - The stderr stream. + * @param options.version - The release version. + * @param options.firstRun - Whether this invocation created the release branch. + * @param options.releasedPackages - The packages selected in the UI. + * @returns The release result to send to the UI. + */ +export async function finalizeInteractiveRelease({ + project, + defaultBranch, + formatter, + stderr, + version, + firstRun, + releasedPackages, +}: { + project: Project; + defaultBranch: string; + formatter: Formatter; + stderr: Pick; + version: string; + firstRun: boolean; + releasedPackages: Record; +}): Promise { + const errors = validateAllPackageEntries(project, releasedPackages, 0); + + if (errors.length > 0) { + return { + status: 'error', + errors, + }; + } + + const releaseSpecificationPackages = Object.keys(releasedPackages).reduce( + (obj, packageName) => { + const versionSpecifierOrDirective = releasedPackages[packageName]; + + if (versionSpecifierOrDirective !== 'intentionally-skip') { + if ( + Object.values(IncrementableVersionParts).includes( + versionSpecifierOrDirective as any, + ) + ) { + return { + ...obj, + [packageName]: + versionSpecifierOrDirective as IncrementableVersionParts, + }; + } + + return { + ...obj, + [packageName]: semver.parse(versionSpecifierOrDirective) as SemVer, + }; + } + + return obj; + }, + {} as ReleaseSpecification['packages'], + ); + + await restoreChangelogsForSkippedPackages({ + project, + releaseSpecificationPackages, + defaultBranch, + }); + + const releasePlan = await planRelease({ + project, + releaseSpecificationPackages, + newReleaseVersion: version, + }); + await executeReleasePlan(project, releasePlan, formatter, stderr); + await fixConstraints(project.directoryPath); + await updateYarnLockfile(project.directoryPath); + await deduplicateDependencies(project.directoryPath); + + if (firstRun) { + await resetLastCommit(project.directoryPath); + } + + await commitAllChanges(project.directoryPath, `Release ${version}`); + + return { status: 'success' }; +} + /** * Creates an Express application for the UI server. * @@ -286,74 +385,21 @@ export function createApp({ async (req: express.Request, res: express.Response): Promise => { try { const releasedPackages: Record = req.body; - - const errors = validateAllPackageEntries(project, releasedPackages, 0); - - if (errors.length > 0) { - res.json({ - status: 'error', - errors, - }); - return; - } - - const releaseSpecificationPackages = Object.keys( - releasedPackages, - ).reduce( - (obj, packageName) => { - const versionSpecifierOrDirective = releasedPackages[packageName]; - - if (versionSpecifierOrDirective !== 'intentionally-skip') { - if ( - Object.values(IncrementableVersionParts).includes( - versionSpecifierOrDirective as any, - ) - ) { - return { - ...obj, - [packageName]: - versionSpecifierOrDirective as IncrementableVersionParts, - }; - } - - return { - ...obj, - [packageName]: semver.parse( - versionSpecifierOrDirective, - ) as SemVer, - }; - } - - return obj; - }, - {} as ReleaseSpecification['packages'], - ); - - await restoreChangelogsForSkippedPackages({ + const result = await finalizeInteractiveRelease({ project, - releaseSpecificationPackages, defaultBranch, + formatter, + stderr, + version, + firstRun, + releasedPackages, }); - const releasePlan = await planRelease({ - project, - releaseSpecificationPackages, - newReleaseVersion: version, - }); - await executeReleasePlan(project, releasePlan, formatter, stderr); - await fixConstraints(project.directoryPath); - await updateYarnLockfile(project.directoryPath); - await deduplicateDependencies(project.directoryPath); + res.json(result); - if (firstRun) { - await resetLastCommit(project.directoryPath); + if (result.status === 'success') { + closeServer(); } - - await commitAllChanges(project.directoryPath, `Release ${version}`); - - res.json({ status: 'success' }); - - closeServer(); } catch (error) { stderr.write(`Release error: ${error}\n`); res.status(400).send('Invalid request'); From 5b024bed3dccc364dd18a85b35b4bbd0d71c2f87 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 20 May 2026 13:51:08 +0000 Subject: [PATCH 11/12] Update release commit flow expectations Co-authored-by: Elliot Winkler --- src/functional.test.ts | 4 +- src/monorepo-workflow-operations.test.ts | 12 +++--- src/repo.test.ts | 14 ------- src/ui.test.ts | 51 +++++++++++++++++++----- 4 files changed, 50 insertions(+), 31 deletions(-) diff --git a/src/functional.test.ts b/src/functional.test.ts index a8f3d92..dd15683 100644 --- a/src/functional.test.ts +++ b/src/functional.test.ts @@ -591,7 +591,7 @@ describe('create-release-branch (functional)', () => { // Tests five things: // * The latest commit should be called "Filter release to only selected packages" - // * The before latest commit should be called "Initialize Release 2.0.0" + // * The before latest commit should be called "Initialize release 2.0.0 by bumping all packages" // * The latest commit should be the current commit (HEAD) // * The latest branch should be called "release/2.0.0" // * The latest branch should point to the latest commit @@ -621,7 +621,7 @@ describe('create-release-branch (functional)', () => { 'Filter release to only selected packages', ); expect(latestCommitsInReverse[1].subject).toBe( - 'Initialize Release 2.0.0', + 'Initialize release 2.0.0 by bumping all packages', ); expect(latestCommitsInReverse[0].revs).toContain('HEAD'); diff --git a/src/monorepo-workflow-operations.test.ts b/src/monorepo-workflow-operations.test.ts index d47e2b4..313428f 100644 --- a/src/monorepo-workflow-operations.test.ts +++ b/src/monorepo-workflow-operations.test.ts @@ -468,7 +468,7 @@ describe('monorepo-workflow-operations', () => { expect(commitAllChangesSpy).toHaveBeenNthCalledWith( 1, projectDirectoryPath, - `Initialize Release ${releaseVersion}`, + `Initialize release ${releaseVersion} by bumping all packages`, ); expect(commitAllChangesSpy).toHaveBeenNthCalledWith( 2, @@ -587,7 +587,7 @@ describe('monorepo-workflow-operations', () => { expect(commitAllChangesSpy).toHaveBeenCalledWith( projectDirectoryPath, - 'Initialize Release 2.0.0', + 'Initialize release 2.0.0 by bumping all packages', ); expect(commitAllChangesSpy).toHaveBeenCalledWith( @@ -684,7 +684,7 @@ describe('monorepo-workflow-operations', () => { expect(commitAllChangesSpy).toHaveBeenCalledWith( projectDirectoryPath, - 'Initialize Release 2.0.0', + 'Initialize release 2.0.0 by bumping all packages', ); expect(commitAllChangesSpy).not.toHaveBeenCalledWith( projectDirectoryPath, @@ -1126,7 +1126,7 @@ describe('monorepo-workflow-operations', () => { expect(commitAllChangesSpy).toHaveBeenCalledWith( projectDirectoryPath, - 'Initialize Release 2.0.0', + 'Initialize release 2.0.0 by bumping all packages', ); expect(commitAllChangesSpy).toHaveBeenCalledWith( @@ -1397,7 +1397,7 @@ describe('monorepo-workflow-operations', () => { expect(commitAllChangesSpy).toHaveBeenCalledWith( projectDirectoryPath, - 'Initialize Release 2.0.0', + 'Initialize release 2.0.0 by bumping all packages', ); expect(commitAllChangesSpy).toHaveBeenCalledWith( @@ -1940,7 +1940,7 @@ describe('monorepo-workflow-operations', () => { expect(commitAllChangesSpy).toHaveBeenCalledWith( projectDirectoryPath, - 'Initialize Release 2.0.0', + 'Initialize release 2.0.0 by bumping all packages', ); expect(commitAllChangesSpy).toHaveBeenCalledWith( diff --git a/src/repo.test.ts b/src/repo.test.ts index 2ab4c6e..c9c81a2 100644 --- a/src/repo.test.ts +++ b/src/repo.test.ts @@ -6,7 +6,6 @@ import { getCurrentBranchName, branchExists, restoreFiles, - resetLastCommit, } from './repo.js'; import * as miscUtils from './misc-utils.js'; @@ -35,19 +34,6 @@ describe('repo', () => { }); }); - describe('resetLastCommit', () => { - it('soft-resets HEAD to the previous commit', async () => { - const runCommandSpy = jest.spyOn(miscUtils, 'runCommand'); - await resetLastCommit('/path/to/project'); - - expect(runCommandSpy).toHaveBeenCalledWith( - 'git', - ['reset', '--soft', 'HEAD~1'], - { cwd: '/path/to/project' }, - ); - }); - }); - describe('getTagNames', () => { it('returns all of the tag names that match a known format, sorted by ascending semantic version order', async () => { when(jest.spyOn(miscUtils, 'getLinesFromCommand')) diff --git a/src/ui.test.ts b/src/ui.test.ts index 7b383e6..a61537a 100644 --- a/src/ui.test.ts +++ b/src/ui.test.ts @@ -1,15 +1,20 @@ import { MockWritable } from 'stdio-mock'; import { buildMockPackage, buildMockProject } from '../tests/unit/helpers.js'; -import { finalizeInteractiveRelease } from './ui.js'; +import { + finalizeInteractiveRelease, + prepareInteractiveReleaseBranch, +} from './ui.js'; import * as projectModule from './project.js'; import * as releasePlanModule from './release-plan.js'; import * as repoModule from './repo.js'; import * as yarnCommands from './yarn-commands.js'; +import * as workflowOperations from './workflow-operations.js'; jest.mock('./project'); jest.mock('./release-plan'); jest.mock('./repo'); jest.mock('./yarn-commands'); +jest.mock('./workflow-operations'); jest.mock('./dirname', () => ({ getCurrentDirectoryPath: jest.fn().mockReturnValue('/path/to/somewhere'), })); @@ -19,8 +24,43 @@ jest.mock('open', () => ({ })); describe('ui', () => { + describe('prepareInteractiveReleaseBranch', () => { + it('updates changelogs without committing when creating an interactive release branch', async () => { + const project = buildMockProject({ directoryPath: '/path/to/project' }); + const stderr = new MockWritable(); + const updateChangelogsForChangedPackagesSpy = jest.spyOn( + projectModule, + 'updateChangelogsForChangedPackages', + ); + const commitAllChangesSpy = jest.spyOn(repoModule, 'commitAllChanges'); + jest.spyOn(workflowOperations, 'createReleaseBranch').mockResolvedValue({ + version: '2.0.0', + firstRun: true, + }); + + const result = await prepareInteractiveReleaseBranch({ + project, + releaseType: 'ordinary', + formatter: 'prettier', + stderr, + }); + + expect(result).toStrictEqual({ + version: '2.0.0', + firstRun: true, + }); + + expect(updateChangelogsForChangedPackagesSpy).toHaveBeenCalledWith({ + project, + formatter: 'prettier', + stderr, + }); + expect(commitAllChangesSpy).not.toHaveBeenCalled(); + }); + }); + describe('finalizeInteractiveRelease', () => { - it('squashes the initial release commit before committing an interactive first run', async () => { + it('commits an interactive first run without resetting HEAD', async () => { const project = buildMockProject({ directoryPath: '/path/to/project', workspacePackages: { @@ -31,7 +71,6 @@ describe('ui', () => { }); const releasePlan = { newVersion: '2.0.0', packages: [] }; const stderr = new MockWritable(); - const resetLastCommitSpy = jest.spyOn(repoModule, 'resetLastCommit'); const commitAllChangesSpy = jest.spyOn(repoModule, 'commitAllChanges'); jest .spyOn(releasePlanModule, 'planRelease') @@ -65,15 +104,11 @@ describe('ui', () => { expect(yarnCommands.deduplicateDependencies).toHaveBeenCalledWith( project.directoryPath, ); - expect(resetLastCommitSpy).toHaveBeenCalledWith(project.directoryPath); expect(commitAllChangesSpy).toHaveBeenCalledTimes(1); expect(commitAllChangesSpy).toHaveBeenCalledWith( project.directoryPath, 'Release 2.0.0', ); - expect(resetLastCommitSpy.mock.invocationCallOrder[0]).toBeLessThan( - commitAllChangesSpy.mock.invocationCallOrder[0], - ); }); it('does not reset HEAD before committing an existing interactive release branch', async () => { @@ -87,7 +122,6 @@ describe('ui', () => { }); const releasePlan = { newVersion: '2.0.0', packages: [] }; const stderr = new MockWritable(); - const resetLastCommitSpy = jest.spyOn(repoModule, 'resetLastCommit'); const commitAllChangesSpy = jest.spyOn(repoModule, 'commitAllChanges'); jest .spyOn(releasePlanModule, 'planRelease') @@ -105,7 +139,6 @@ describe('ui', () => { }); expect(result).toStrictEqual({ status: 'success' }); - expect(resetLastCommitSpy).not.toHaveBeenCalled(); expect(commitAllChangesSpy).toHaveBeenCalledWith( project.directoryPath, 'Release 2.0.0', From 3da12aead42888b6aa33d1208901f5d26ac2fd6a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 20 May 2026 13:52:59 +0000 Subject: [PATCH 12/12] Create single interactive release commit Co-authored-by: Elliot Winkler --- CHANGELOG.md | 2 +- src/monorepo-workflow-operations.ts | 2 +- src/repo.ts | 13 ------- src/ui.test.ts | 2 - src/ui.ts | 59 ++++++++++++++++++----------- 5 files changed, 39 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 129e4db..b6aa8f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Use `Release X.Y.Z` as the single `--interactive` release preparation commit message; non-interactive release preparation now keeps `Initialize Release X.Y.Z` followed by `Filter release to only selected packages`. +- Use `Release X.Y.Z` as the single `--interactive` release preparation commit message; non-interactive release preparation now keeps `Initialize release X.Y.Z by bumping all packages` followed by `Filter release to only selected packages`. ## [4.2.1] diff --git a/src/monorepo-workflow-operations.ts b/src/monorepo-workflow-operations.ts index 868827f..2174c80 100644 --- a/src/monorepo-workflow-operations.ts +++ b/src/monorepo-workflow-operations.ts @@ -89,7 +89,7 @@ export async function followMonorepoWorkflow({ await updateChangelogsForChangedPackages({ project, formatter, stderr }); await commitAllChanges( project.directoryPath, - `Initialize Release ${newReleaseVersion}`, + `Initialize release ${newReleaseVersion} by bumping all packages`, ); } diff --git a/src/repo.ts b/src/repo.ts index 06590da..ec4074f 100644 --- a/src/repo.ts +++ b/src/repo.ts @@ -143,19 +143,6 @@ export async function commitAllChanges( ]); } -/** - * Soft-resets the repository to the previous commit, leaving the reverted - * commit's changes staged so they can be recommitted with other changes. - * - * @param repositoryDirectoryPath - The file system path to the git repository. - */ -export async function resetLastCommit(repositoryDirectoryPath: string) { - await runGitCommandWithin(repositoryDirectoryPath, 'reset', [ - '--soft', - 'HEAD~1', - ]); -} - /** * Retrieves the current branch name of a git repository. * diff --git a/src/ui.test.ts b/src/ui.test.ts index a61537a..48a88c0 100644 --- a/src/ui.test.ts +++ b/src/ui.test.ts @@ -83,7 +83,6 @@ describe('ui', () => { formatter: 'prettier', stderr, version: '2.0.0', - firstRun: true, releasedPackages: { '@scope/a': 'major' }, }); @@ -134,7 +133,6 @@ describe('ui', () => { formatter: 'prettier', stderr, version: '2.0.0', - firstRun: false, releasedPackages: { '@scope/a': 'major' }, }); diff --git a/src/ui.ts b/src/ui.ts index 07381d8..6604c65 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -18,7 +18,7 @@ import { validateAllPackageEntries, } from './release-specification.js'; import { createReleaseBranch } from './workflow-operations.js'; -import { commitAllChanges, resetLastCommit } from './repo.js'; +import { commitAllChanges } from './repo.js'; import { SemVer, semver } from './semver.js'; import { executeReleasePlan, planRelease } from './release-plan.js'; import { @@ -70,26 +70,19 @@ export async function startUI({ stdout, stderr, }: UIOptions): Promise { - const { version: newReleaseVersion, firstRun } = await createReleaseBranch({ + const { version: newReleaseVersion } = await prepareInteractiveReleaseBranch({ project, releaseType, + formatter, + stderr, }); - if (firstRun) { - await updateChangelogsForChangedPackages({ project, formatter, stderr }); - await commitAllChanges( - project.directoryPath, - `Initialize Release ${newReleaseVersion}`, - ); - } - const app = createApp({ project, defaultBranch, formatter, stderr, version: newReleaseVersion, - firstRun, closeServer: () => { // eslint-disable-next-line @typescript-eslint/no-use-before-define server.close(); @@ -125,6 +118,39 @@ export async function startUI({ }); } +/** + * Prepares the release branch for the interactive UI. + * + * @param options - The options. + * @param options.project - The project object. + * @param options.releaseType - The type of release. + * @param options.formatter - The formatter to use for formatting the changelog. + * @param options.stderr - The stderr stream. + * @returns The prepared release branch information. + */ +export async function prepareInteractiveReleaseBranch({ + project, + releaseType, + formatter, + stderr, +}: { + project: Project; + releaseType: 'ordinary' | 'backport'; + formatter: Formatter; + stderr: Pick; +}): Promise<{ version: string; firstRun: boolean }> { + const releaseBranch = await createReleaseBranch({ + project, + releaseType, + }); + + if (releaseBranch.firstRun) { + await updateChangelogsForChangedPackages({ project, formatter, stderr }); + } + + return releaseBranch; +} + /** * Finalizes the release selected in the interactive UI. * @@ -134,7 +160,6 @@ export async function startUI({ * @param options.formatter - The formatter to use for formatting the changelog. * @param options.stderr - The stderr stream. * @param options.version - The release version. - * @param options.firstRun - Whether this invocation created the release branch. * @param options.releasedPackages - The packages selected in the UI. * @returns The release result to send to the UI. */ @@ -144,7 +169,6 @@ export async function finalizeInteractiveRelease({ formatter, stderr, version, - firstRun, releasedPackages, }: { project: Project; @@ -152,7 +176,6 @@ export async function finalizeInteractiveRelease({ formatter: Formatter; stderr: Pick; version: string; - firstRun: boolean; releasedPackages: Record; }): Promise { const errors = validateAllPackageEntries(project, releasedPackages, 0); @@ -208,10 +231,6 @@ export async function finalizeInteractiveRelease({ await updateYarnLockfile(project.directoryPath); await deduplicateDependencies(project.directoryPath); - if (firstRun) { - await resetLastCommit(project.directoryPath); - } - await commitAllChanges(project.directoryPath, `Release ${version}`); return { status: 'success' }; @@ -226,7 +245,6 @@ export async function finalizeInteractiveRelease({ * @param options.formatter - The formatter to use for formatting the changelog. * @param options.stderr - The stderr stream. * @param options.version - The release version. - * @param options.firstRun - Whether this invocation created the release branch. * @param options.closeServer - The function to close the server. * @returns The Express application. */ @@ -236,7 +254,6 @@ export function createApp({ formatter, stderr, version, - firstRun, closeServer, }: { project: Project; @@ -244,7 +261,6 @@ export function createApp({ formatter: Formatter; stderr: Pick; version: string; - firstRun: boolean; closeServer: () => void; }): express.Application { const app = express(); @@ -391,7 +407,6 @@ export function createApp({ formatter, stderr, version, - firstRun, releasedPackages, });