diff --git a/src/command-line-arguments.ts b/src/command-line-arguments.ts index 753a162..631c84a 100644 --- a/src/command-line-arguments.ts +++ b/src/command-line-arguments.ts @@ -5,6 +5,7 @@ export interface CommandLineArguments { projectDirectory: string; tempDirectory: string | undefined; reset: boolean; + backport: boolean; } /** @@ -37,6 +38,12 @@ export async function readCommandLineArguments( type: 'boolean', default: false, }) + .option('backport', { + describe: + 'Instructs the tool to bump the second part of the version rather than the first for a backport release.', + type: 'boolean', + default: false, + }) .help() .strict() .parse(); diff --git a/src/functional.test.ts b/src/functional.test.ts index 4b7f51c..7bed5b8 100644 --- a/src/functional.test.ts +++ b/src/functional.test.ts @@ -3,7 +3,7 @@ import { buildChangelog } from '../tests/functional/helpers/utils'; describe('create-release-branch (functional)', () => { describe('against a monorepo with independent versions', () => { - it('updates the version of the root package to be the current date along with the versions of the specified packages', async () => { + it('bumps the ordinary part of the root package and updates the versions of the specified packages according to the release spec', async () => { await withMonorepoProjectEnvironment( { packages: { @@ -114,6 +114,118 @@ describe('create-release-branch (functional)', () => { ); }); + it('bumps the backport part of the root package and updates the versions of the specified packages according to the release spec if --backport is provided', async () => { + await withMonorepoProjectEnvironment( + { + packages: { + $root$: { + name: '@scope/monorepo', + version: '1.0.0', + directoryPath: '.', + }, + a: { + name: '@scope/a', + version: '0.1.2', + directoryPath: 'packages/a', + }, + b: { + name: '@scope/b', + version: '1.1.4', + directoryPath: 'packages/b', + }, + c: { + name: '@scope/c', + version: '2.0.13', + directoryPath: 'packages/c', + }, + d: { + name: '@scope/d', + version: '1.2.3', + directoryPath: 'packages/d', + }, + }, + workspaces: { + '.': ['packages/*'], + }, + }, + async (environment) => { + await environment.updateJsonFile('package.json', { + scripts: { + foo: 'bar', + }, + }); + await environment.updateJsonFileWithinPackage('a', 'package.json', { + scripts: { + foo: 'bar', + }, + }); + await environment.updateJsonFileWithinPackage('b', 'package.json', { + scripts: { + foo: 'bar', + }, + }); + await environment.updateJsonFileWithinPackage('c', 'package.json', { + scripts: { + foo: 'bar', + }, + }); + await environment.updateJsonFileWithinPackage('d', 'package.json', { + scripts: { + foo: 'bar', + }, + }); + + await environment.runTool({ + args: ['--backport'], + releaseSpecification: { + packages: { + a: 'major', + b: 'minor', + c: 'patch', + d: '1.2.4', + }, + }, + }); + + expect(await environment.readJsonFile('package.json')).toStrictEqual({ + name: '@scope/monorepo', + version: '1.1.0', + private: true, + workspaces: ['packages/*'], + scripts: { foo: 'bar' }, + }); + expect( + await environment.readJsonFileWithinPackage('a', 'package.json'), + ).toStrictEqual({ + name: '@scope/a', + version: '1.0.0', + scripts: { foo: 'bar' }, + }); + expect( + await environment.readJsonFileWithinPackage('b', 'package.json'), + ).toStrictEqual({ + name: '@scope/b', + version: '1.2.0', + scripts: { foo: 'bar' }, + }); + expect( + await environment.readJsonFileWithinPackage('c', 'package.json'), + ).toStrictEqual({ + name: '@scope/c', + version: '2.0.14', + scripts: { foo: 'bar' }, + }); + expect( + await environment.readJsonFileWithinPackage('d', 'package.json'), + ).toStrictEqual({ + name: '@scope/d', + version: '1.2.4', + scripts: { foo: 'bar' }, + }); + }, + ); + }); + it("updates each of the specified packages' changelogs by adding a new section which lists all commits concerning the package over the entire history of the repo", async () => { await withMonorepoProjectEnvironment( { diff --git a/src/initial-parameters.test.ts b/src/initial-parameters.test.ts index 6c22685..a9d1d56 100644 --- a/src/initial-parameters.test.ts +++ b/src/initial-parameters.test.ts @@ -34,6 +34,7 @@ describe('initial-parameters', () => { projectDirectory: '/path/to/project', tempDirectory: '/path/to/temp', reset: true, + backport: false, }); jest .spyOn(envModule, 'getEnvironmentVariables') @@ -42,16 +43,17 @@ describe('initial-parameters', () => { .calledWith('/path/to/project', { stderr }) .mockResolvedValue(project); - const config = await determineInitialParameters({ + const initialParameters = await determineInitialParameters({ argv: ['arg1', 'arg2'], cwd: '/path/to/somewhere', stderr, }); - expect(config).toStrictEqual({ + expect(initialParameters).toStrictEqual({ project, tempDirectoryPath: '/path/to/temp', reset: true, + releaseType: 'ordinary', }); }); @@ -66,6 +68,7 @@ describe('initial-parameters', () => { projectDirectory: 'project', tempDirectory: undefined, reset: true, + backport: false, }); jest .spyOn(envModule, 'getEnvironmentVariables') @@ -94,6 +97,7 @@ describe('initial-parameters', () => { projectDirectory: '/path/to/project', tempDirectory: 'tmp', reset: true, + backport: false, }); jest .spyOn(envModule, 'getEnvironmentVariables') @@ -102,13 +106,15 @@ describe('initial-parameters', () => { .calledWith('/path/to/project', { stderr }) .mockResolvedValue(project); - const config = await determineInitialParameters({ + const initialParameters = await determineInitialParameters({ argv: ['arg1', 'arg2'], cwd: '/path/to/cwd', stderr, }); - expect(config.tempDirectoryPath).toStrictEqual('/path/to/cwd/tmp'); + expect(initialParameters.tempDirectoryPath).toStrictEqual( + '/path/to/cwd/tmp', + ); }); it('uses a default temporary directory based on the name of the package if no temporary directory was given', async () => { @@ -122,6 +128,7 @@ describe('initial-parameters', () => { projectDirectory: '/path/to/project', tempDirectory: undefined, reset: true, + backport: false, }); jest .spyOn(envModule, 'getEnvironmentVariables') @@ -130,13 +137,13 @@ describe('initial-parameters', () => { .calledWith('/path/to/project', { stderr }) .mockResolvedValue(project); - const config = await determineInitialParameters({ + const initialParameters = await determineInitialParameters({ argv: ['arg1', 'arg2'], cwd: '/path/to/cwd', stderr, }); - expect(config.tempDirectoryPath).toStrictEqual( + expect(initialParameters.tempDirectoryPath).toStrictEqual( path.join(os.tmpdir(), 'create-release-branch', '@foo__bar'), ); }); @@ -150,6 +157,7 @@ describe('initial-parameters', () => { projectDirectory: '/path/to/project', tempDirectory: '/path/to/temp', reset: true, + backport: false, }); jest .spyOn(envModule, 'getEnvironmentVariables') @@ -158,13 +166,13 @@ describe('initial-parameters', () => { .calledWith('/path/to/project', { stderr }) .mockResolvedValue(project); - const config = await determineInitialParameters({ + const initialParameters = await determineInitialParameters({ argv: ['arg1', 'arg2'], cwd: '/path/to/somewhere', stderr, }); - expect(config.reset).toBe(true); + expect(initialParameters.reset).toBe(true); }); it('returns initial parameters including reset: false, derived from a command-line argument of "--reset false"', async () => { @@ -176,6 +184,61 @@ describe('initial-parameters', () => { projectDirectory: '/path/to/project', tempDirectory: '/path/to/temp', reset: false, + backport: false, + }); + jest + .spyOn(envModule, 'getEnvironmentVariables') + .mockReturnValue({ EDITOR: undefined }); + when(jest.spyOn(projectModule, 'readProject')) + .calledWith('/path/to/project', { stderr }) + .mockResolvedValue(project); + + const initialParameters = await determineInitialParameters({ + argv: ['arg1', 'arg2'], + cwd: '/path/to/somewhere', + stderr, + }); + + expect(initialParameters.reset).toBe(false); + }); + + it('returns initial parameters including a releaseType of "backport", derived from a command-line argument of "--backport true"', async () => { + const project = buildMockProject(); + const stderr = createNoopWriteStream(); + when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) + .calledWith(['arg1', 'arg2']) + .mockResolvedValue({ + projectDirectory: '/path/to/project', + tempDirectory: '/path/to/temp', + reset: false, + backport: true, + }); + jest + .spyOn(envModule, 'getEnvironmentVariables') + .mockReturnValue({ EDITOR: undefined }); + when(jest.spyOn(projectModule, 'readProject')) + .calledWith('/path/to/project', { stderr }) + .mockResolvedValue(project); + + const initialParameters = await determineInitialParameters({ + argv: ['arg1', 'arg2'], + cwd: '/path/to/somewhere', + stderr, + }); + + expect(initialParameters.releaseType).toBe('backport'); + }); + + it('returns initial parameters including a releaseType of "ordinary", derived from a command-line argument of "--backport false"', async () => { + const project = buildMockProject(); + const stderr = createNoopWriteStream(); + when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) + .calledWith(['arg1', 'arg2']) + .mockResolvedValue({ + projectDirectory: '/path/to/project', + tempDirectory: '/path/to/temp', + reset: false, + backport: false, }); jest .spyOn(envModule, 'getEnvironmentVariables') @@ -184,13 +247,13 @@ describe('initial-parameters', () => { .calledWith('/path/to/project', { stderr }) .mockResolvedValue(project); - const config = await determineInitialParameters({ + const initialParameters = await determineInitialParameters({ argv: ['arg1', 'arg2'], cwd: '/path/to/somewhere', stderr, }); - expect(config.reset).toBe(false); + expect(initialParameters.releaseType).toBe('ordinary'); }); }); }); diff --git a/src/initial-parameters.ts b/src/initial-parameters.ts index ae82a95..842bcaf 100644 --- a/src/initial-parameters.ts +++ b/src/initial-parameters.ts @@ -4,10 +4,22 @@ import { readCommandLineArguments } from './command-line-arguments'; import { WriteStreamLike } from './fs'; import { readProject, Project } from './project'; +/** + * The type of release being created as determined by the parent release. + * + * - An *ordinary* release includes features or fixes applied against the + * latest release and is designated by bumping the first part of that release's + * version string. + * - A *backport* release includes fixes applied against a previous release and + * is designated by bumping the second part of that release's version string. + */ +export type ReleaseType = 'ordinary' | 'backport'; + interface InitialParameters { project: Project; tempDirectoryPath: string; reset: boolean; + releaseType: ReleaseType; } /** @@ -29,18 +41,23 @@ export async function determineInitialParameters({ cwd: string; stderr: WriteStreamLike; }): Promise { - const inputs = await readCommandLineArguments(argv); + const args = await readCommandLineArguments(argv); - const projectDirectoryPath = path.resolve(cwd, inputs.projectDirectory); + const projectDirectoryPath = path.resolve(cwd, args.projectDirectory); const project = await readProject(projectDirectoryPath, { stderr }); const tempDirectoryPath = - inputs.tempDirectory === undefined + args.tempDirectory === undefined ? path.join( os.tmpdir(), 'create-release-branch', project.rootPackage.validatedManifest.name.replace('/', '__'), ) - : path.resolve(cwd, inputs.tempDirectory); + : path.resolve(cwd, args.tempDirectory); - return { project, tempDirectoryPath, reset: inputs.reset }; + return { + project, + tempDirectoryPath, + reset: args.reset, + releaseType: args.backport ? 'backport' : 'ordinary', + }; } diff --git a/src/main.test.ts b/src/main.test.ts index 539275c..82dff54 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -17,7 +17,8 @@ describe('main', () => { .mockResolvedValue({ project, tempDirectoryPath: '/path/to/temp/directory', - reset: false, + reset: true, + releaseType: 'backport', }); const followMonorepoWorkflowSpy = jest .spyOn(monorepoWorkflowOperations, 'followMonorepoWorkflow') @@ -33,7 +34,8 @@ describe('main', () => { expect(followMonorepoWorkflowSpy).toHaveBeenCalledWith({ project, tempDirectoryPath: '/path/to/temp/directory', - firstRemovingExistingReleaseSpecification: false, + firstRemovingExistingReleaseSpecification: true, + releaseType: 'backport', stdout, stderr, }); @@ -49,6 +51,7 @@ describe('main', () => { project, tempDirectoryPath: '/path/to/temp/directory', reset: false, + releaseType: 'backport', }); const followMonorepoWorkflowSpy = jest .spyOn(monorepoWorkflowOperations, 'followMonorepoWorkflow') diff --git a/src/main.ts b/src/main.ts index e1a2568..7c5e876 100644 --- a/src/main.ts +++ b/src/main.ts @@ -25,7 +25,7 @@ export async function main({ stdout: Pick; stderr: Pick; }) { - const { project, tempDirectoryPath, reset } = + const { project, tempDirectoryPath, reset, releaseType } = await determineInitialParameters({ argv, cwd, stderr }); if (project.isMonorepo) { @@ -36,6 +36,7 @@ export async function main({ project, tempDirectoryPath, firstRemovingExistingReleaseSpecification: reset, + releaseType, stdout, stderr, }); diff --git a/src/monorepo-workflow-operations.test.ts b/src/monorepo-workflow-operations.test.ts index 07b6d42..a6a543a 100644 --- a/src/monorepo-workflow-operations.test.ts +++ b/src/monorepo-workflow-operations.test.ts @@ -7,6 +7,7 @@ import { buildMockProject, Require } from '../tests/unit/helpers'; import { followMonorepoWorkflow } from './monorepo-workflow-operations'; import * as editorModule from './editor'; import type { Editor } from './editor'; +import { ReleaseType } from './initial-parameters'; import * as releaseSpecificationModule from './release-specification'; import type { ReleaseSpecification } from './release-specification'; import * as releasePlanModule from './release-plan'; @@ -147,6 +148,7 @@ function buildMockEditor({ * `executeReleasePlan` will throw. * @param args.releaseVersion - The new version that the release plan will * contain. + * @param args.releaseType - The type of release. * @returns Mock functions and other data that can be used in tests to make * assertions. */ @@ -159,6 +161,7 @@ async function setupFollowMonorepoWorkflow({ errorUponPlanningRelease, errorUponExecutingReleasePlan, releaseVersion = '1.0.0', + releaseType = 'ordinary', }: { sandbox: Sandbox; doesReleaseSpecFileExist: boolean; @@ -168,6 +171,7 @@ async function setupFollowMonorepoWorkflow({ errorUponPlanningRelease?: Error; errorUponExecutingReleasePlan?: Error; releaseVersion?: string; + releaseType?: ReleaseType; }) { const { determineEditorSpy, @@ -218,11 +222,11 @@ async function setupFollowMonorepoWorkflow({ if (errorUponPlanningRelease) { when(planReleaseSpy) - .calledWith({ project, releaseSpecification }) + .calledWith({ project, releaseSpecification, releaseType }) .mockRejectedValue(errorUponPlanningRelease); } else { when(planReleaseSpy) - .calledWith({ project, releaseSpecification }) + .calledWith({ project, releaseSpecification, releaseType }) .mockResolvedValue(releasePlan); } @@ -256,6 +260,8 @@ async function setupFollowMonorepoWorkflow({ stderr, generateReleaseSpecificationTemplateForMonorepoSpy, waitForUserToEditReleaseSpecificationSpy, + releaseSpecification, + planReleaseSpy, executeReleasePlanSpy, captureChangesInReleaseBranchSpy, releasePlan, @@ -267,6 +273,70 @@ async function setupFollowMonorepoWorkflow({ describe('monorepo-workflow-operations', () => { describe('followMonorepoWorkflow', () => { describe('when firstRemovingExistingReleaseSpecification is false, the release spec file does not already exist, and an editor is available', () => { + it('plans an ordinary release if given releaseType: "ordinary"', async () => { + await withSandbox(async (sandbox) => { + const { + project, + stdout, + stderr, + releaseSpecification, + planReleaseSpy, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + releaseType: 'ordinary', + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + releaseType: 'ordinary', + stdout, + stderr, + }); + + expect(planReleaseSpy).toHaveBeenCalledWith({ + project, + releaseSpecification, + releaseType: 'ordinary', + }); + }); + }); + + it('plans a backport release if given releaseType: "backport"', async () => { + await withSandbox(async (sandbox) => { + const { + project, + stdout, + stderr, + releaseSpecification, + planReleaseSpy, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + releaseType: 'backport', + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + releaseType: 'backport', + stdout, + stderr, + }); + + expect(planReleaseSpy).toHaveBeenCalledWith({ + project, + releaseSpecification, + releaseType: 'backport', + }); + }); + }); + it('attempts to execute the release spec if it was successfully edited', async () => { await withSandbox(async (sandbox) => { const { @@ -285,6 +355,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, + releaseType: 'ordinary', stdout, stderr, }); @@ -316,6 +387,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, + releaseType: 'ordinary', stdout, stderr, }); @@ -340,6 +412,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, + releaseType: 'ordinary', stdout, stderr, }); @@ -363,6 +436,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, + releaseType: 'ordinary', stdout, stderr, }), @@ -387,6 +461,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, + releaseType: 'ordinary', stdout, stderr, }), @@ -411,6 +486,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, + releaseType: 'ordinary', stdout, stderr, }), @@ -436,6 +512,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, + releaseType: 'ordinary', stdout, stderr, }), @@ -459,6 +536,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, + releaseType: 'ordinary', stdout, stderr, }), @@ -484,6 +562,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, + releaseType: 'ordinary', stdout, stderr, }), @@ -509,6 +588,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, + releaseType: 'ordinary', stdout, stderr, }), @@ -533,6 +613,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, + releaseType: 'ordinary', stdout, stderr, }); @@ -554,6 +635,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, + releaseType: 'ordinary', stdout, stderr, }); @@ -576,6 +658,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, + releaseType: 'ordinary', stdout, stderr, }); @@ -599,6 +682,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, + releaseType: 'ordinary', stdout, stderr, }); @@ -625,6 +709,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, + releaseType: 'ordinary', stdout, stderr, }); @@ -635,6 +720,68 @@ describe('monorepo-workflow-operations', () => { }); }); + it('plans an ordinary release if given releaseType: "ordinary"', async () => { + await withSandbox(async (sandbox) => { + const { + project, + stdout, + stderr, + releaseSpecification, + planReleaseSpy, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + releaseType: 'ordinary', + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + releaseType: 'ordinary', + stdout, + stderr, + }); + + expect(planReleaseSpy).toHaveBeenCalledWith({ + project, + releaseSpecification, + releaseType: 'ordinary', + }); + }); + }); + + it('plans a backport release if given releaseType: "backport"', async () => { + await withSandbox(async (sandbox) => { + const { + project, + stdout, + stderr, + releaseSpecification, + planReleaseSpy, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + releaseType: 'backport', + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + releaseType: 'backport', + stdout, + stderr, + }); + + expect(planReleaseSpy).toHaveBeenCalledWith({ + project, + releaseSpecification, + releaseType: 'backport', + }); + }); + }); + it('attempts to execute the edited release spec', async () => { await withSandbox(async (sandbox) => { const { @@ -652,6 +799,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, + releaseType: 'ordinary', stdout, stderr, }); @@ -682,6 +830,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, + releaseType: 'ordinary', stdout, stderr, }); @@ -705,6 +854,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, + releaseType: 'ordinary', stdout, stderr, }); @@ -728,6 +878,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, + releaseType: 'ordinary', stdout, stderr, }), @@ -752,6 +903,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, + releaseType: 'ordinary', stdout, stderr, }), @@ -776,6 +928,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, + releaseType: 'ordinary', stdout, stderr, }), @@ -787,6 +940,70 @@ describe('monorepo-workflow-operations', () => { }); describe('when firstRemovingExistingReleaseSpecification is true, the release spec file does not already exist, and an editor is available', () => { + it('plans an ordinary release if given releaseType: "ordinary"', async () => { + await withSandbox(async (sandbox) => { + const { + project, + stdout, + stderr, + releaseSpecification, + planReleaseSpy, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + releaseType: 'ordinary', + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', + stdout, + stderr, + }); + + expect(planReleaseSpy).toHaveBeenCalledWith({ + project, + releaseSpecification, + releaseType: 'ordinary', + }); + }); + }); + + it('plans a backport release if given releaseType: "backport"', async () => { + await withSandbox(async (sandbox) => { + const { + project, + stdout, + stderr, + releaseSpecification, + planReleaseSpy, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + releaseType: 'backport', + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + releaseType: 'backport', + stdout, + stderr, + }); + + expect(planReleaseSpy).toHaveBeenCalledWith({ + project, + releaseSpecification, + releaseType: 'backport', + }); + }); + }); + it('attempts to execute the release spec if it was successfully edited', async () => { await withSandbox(async (sandbox) => { const { @@ -805,6 +1022,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', stdout, stderr, }); @@ -836,6 +1054,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', stdout, stderr, }); @@ -860,6 +1079,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', stdout, stderr, }); @@ -883,6 +1103,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', stdout, stderr, }), @@ -907,6 +1128,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', stdout, stderr, }), @@ -931,6 +1153,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', stdout, stderr, }), @@ -956,6 +1179,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', stdout, stderr, }), @@ -979,6 +1203,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', stdout, stderr, }), @@ -1004,6 +1229,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', stdout, stderr, }), @@ -1029,6 +1255,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', stdout, stderr, }), @@ -1053,6 +1280,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', stdout, stderr, }); @@ -1074,6 +1302,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', stdout, stderr, }); @@ -1096,6 +1325,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', stdout, stderr, }); @@ -1119,6 +1349,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', stdout, stderr, }); @@ -1146,6 +1377,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', stdout, stderr, }); @@ -1159,6 +1391,70 @@ describe('monorepo-workflow-operations', () => { }); }); + it('plans an ordinary release if given releaseType: "ordinary"', async () => { + await withSandbox(async (sandbox) => { + const { + project, + stdout, + stderr, + releaseSpecification, + planReleaseSpy, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: true, + releaseType: 'ordinary', + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', + stdout, + stderr, + }); + + expect(planReleaseSpy).toHaveBeenCalledWith({ + project, + releaseSpecification, + releaseType: 'ordinary', + }); + }); + }); + + it('plans a backport release if given releaseType: "backport"', async () => { + await withSandbox(async (sandbox) => { + const { + project, + stdout, + stderr, + releaseSpecification, + planReleaseSpy, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: true, + releaseType: 'backport', + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + releaseType: 'backport', + stdout, + stderr, + }); + + expect(planReleaseSpy).toHaveBeenCalledWith({ + project, + releaseSpecification, + releaseType: 'backport', + }); + }); + }); + it('attempts to execute the release spec if it was successfully edited', async () => { await withSandbox(async (sandbox) => { const { @@ -1177,6 +1473,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', stdout, stderr, }); @@ -1208,6 +1505,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', stdout, stderr, }); @@ -1232,6 +1530,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', stdout, stderr, }); @@ -1255,6 +1554,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', stdout, stderr, }), @@ -1279,6 +1579,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', stdout, stderr, }), @@ -1303,6 +1604,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', stdout, stderr, }), @@ -1328,6 +1630,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', stdout, stderr, }), @@ -1351,6 +1654,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', stdout, stderr, }), @@ -1376,6 +1680,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', stdout, stderr, }), @@ -1401,6 +1706,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', stdout, stderr, }), @@ -1429,6 +1735,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', stdout, stderr, }); @@ -1455,6 +1762,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', stdout, stderr, }); @@ -1476,6 +1784,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', stdout, stderr, }); @@ -1498,6 +1807,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', stdout, stderr, }); @@ -1521,6 +1831,7 @@ describe('monorepo-workflow-operations', () => { project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, + releaseType: 'ordinary', stdout, stderr, }); diff --git a/src/monorepo-workflow-operations.ts b/src/monorepo-workflow-operations.ts index 6466dab..db4764e 100644 --- a/src/monorepo-workflow-operations.ts +++ b/src/monorepo-workflow-operations.ts @@ -7,6 +7,7 @@ import { writeFile, } from './fs'; import { determineEditor } from './editor'; +import { ReleaseType } from './initial-parameters'; import { Project } from './project'; import { planRelease, executeReleasePlan } from './release-plan'; import { captureChangesInReleaseBranch } from './repo'; @@ -35,27 +36,31 @@ import { * commit that includes the changes, then create a branch using the current date * as the name. * - * @param options - The options. - * @param options.project - Information about the project. - * @param options.tempDirectoryPath - A directory in which to hold the generated + * @param args - The arguments to this function. + * @param args.project - Information about the project. + * @param args.tempDirectoryPath - A directory in which to hold the generated * release spec file. - * @param options.firstRemovingExistingReleaseSpecification - Sometimes it's + * @param args.firstRemovingExistingReleaseSpecification - Sometimes it's * possible for a release specification that was created in a previous run to * stick around (due to an error). This will ensure that the file is removed * first. - * @param options.stdout - A stream that can be used to write to standard out. - * @param options.stderr - A stream that can be used to write to standard error. + * @param args.releaseType - The type of release ("ordinary" or "backport"), + * which affects how the version is bumped. + * @param args.stdout - A stream that can be used to write to standard out. + * @param args.stderr - A stream that can be used to write to standard error. */ export async function followMonorepoWorkflow({ project, tempDirectoryPath, firstRemovingExistingReleaseSpecification, + releaseType, stdout, stderr, }: { project: Project; tempDirectoryPath: string; firstRemovingExistingReleaseSpecification: boolean; + releaseType: ReleaseType; stdout: Pick; stderr: Pick; }) { @@ -107,6 +112,7 @@ export async function followMonorepoWorkflow({ const releasePlan = await planRelease({ project, releaseSpecification, + releaseType, }); await executeReleasePlan(project, releasePlan, stderr); await removeFile(releaseSpecificationPath); diff --git a/src/release-plan.test.ts b/src/release-plan.test.ts index 03ef4db..d49e9ea 100644 --- a/src/release-plan.test.ts +++ b/src/release-plan.test.ts @@ -9,7 +9,7 @@ jest.mock('./package'); describe('release-plan-utils', () => { describe('planRelease', () => { - it('calculates final versions for all packages in the release spec', async () => { + it('calculates final versions for all packages in the release spec, including bumping the ordinary part of the root package if this is an ordinary release', async () => { const project = buildMockProject({ rootPackage: buildMockPackage('root', '1.0.0'), workspacePackages: { @@ -32,6 +32,7 @@ describe('release-plan-utils', () => { const releasePlan = await planRelease({ project, releaseSpecification, + releaseType: 'ordinary', }); expect(releasePlan).toMatchObject({ @@ -61,6 +62,59 @@ describe('release-plan-utils', () => { }); }); + it('calculates final versions for all packages in the release spec, including bumping the backport part of the root package if this is a backport release', async () => { + const project = buildMockProject({ + rootPackage: buildMockPackage('root', '1.0.0'), + workspacePackages: { + a: buildMockPackage('a', '1.0.0'), + b: buildMockPackage('b', '1.0.0'), + c: buildMockPackage('c', '1.0.0'), + d: buildMockPackage('d', '1.0.0'), + }, + }); + const releaseSpecification = { + packages: { + a: IncrementableVersionParts.major, + b: IncrementableVersionParts.minor, + c: IncrementableVersionParts.patch, + d: new SemVer('1.2.3'), + }, + path: '/path/to/release/spec', + }; + + const releasePlan = await planRelease({ + project, + releaseSpecification, + releaseType: 'backport', + }); + + expect(releasePlan).toMatchObject({ + newVersion: '1.1.0', + packages: [ + { + package: project.rootPackage, + newVersion: '1.1.0', + }, + { + package: project.workspacePackages.a, + newVersion: '2.0.0', + }, + { + package: project.workspacePackages.b, + newVersion: '1.1.0', + }, + { + package: project.workspacePackages.c, + newVersion: '1.0.1', + }, + { + package: project.workspacePackages.d, + newVersion: '1.2.3', + }, + ], + }); + }); + it('records that the changelog for the root package does not need to be updated, while those for the workspace packages do', async () => { const project = buildMockProject({ rootPackage: buildMockPackage('root'), @@ -84,6 +138,7 @@ describe('release-plan-utils', () => { const releasePlan = await planRelease({ project, releaseSpecification, + releaseType: 'ordinary', }); expect(releasePlan).toMatchObject({ diff --git a/src/release-plan.ts b/src/release-plan.ts index f840807..1db17b1 100644 --- a/src/release-plan.ts +++ b/src/release-plan.ts @@ -1,5 +1,6 @@ import type { WriteStream } from 'fs'; import { SemVer } from 'semver'; +import { ReleaseType } from './initial-parameters'; import { debug } from './misc-utils'; import { Package, updatePackage } from './package'; import { Project } from './project'; @@ -54,16 +55,25 @@ export interface PackageReleasePlan { * packages and where they can found). * @param args.releaseSpecification - A parsed version of the release spec * entered by the user. + * @param args.releaseType - The type of release ("ordinary" or "backport"), + * which affects how the version is bumped. * @returns A promise for information about the new release. */ export async function planRelease({ project, releaseSpecification, + releaseType, }: { project: Project; releaseSpecification: ReleaseSpecification; + releaseType: ReleaseType; }): Promise { - const newReleaseVersion = `${project.releaseVersion.ordinaryNumber + 1}.0.0`; + const newReleaseVersion = + releaseType === 'backport' + ? `${project.releaseVersion.ordinaryNumber}.${ + project.releaseVersion.backportNumber + 1 + }.0` + : `${project.releaseVersion.ordinaryNumber + 1}.0.0`; const rootReleasePlan: PackageReleasePlan = { package: project.rootPackage, diff --git a/tests/functional/helpers/monorepo-environment.ts b/tests/functional/helpers/monorepo-environment.ts index b849d3c..8ec4971 100644 --- a/tests/functional/helpers/monorepo-environment.ts +++ b/tests/functional/helpers/monorepo-environment.ts @@ -73,6 +73,7 @@ export default class MonorepoEnvironment< * continuing. * * @param args - The arguments to this function. + * @param args.args - Additional arguments to pass to the command. * @param args.releaseSpecification - An object which specifies which packages * should be bumped, where keys are the *nicknames* of packages as specified * in the set of options passed to `withMonorepoProjectEnvironment`. Will be @@ -80,8 +81,10 @@ export default class MonorepoEnvironment< * @returns The result of the command. */ async runTool({ + args: additionalArgs = [], releaseSpecification: releaseSpecificationWithPackageNicknames, }: { + args?: string[]; releaseSpecification: ReleaseSpecification; }): Promise> { const releaseSpecificationPath = path.join( @@ -122,22 +125,19 @@ cat "${releaseSpecificationPath}" > "$1" ); await fs.promises.chmod(releaseSpecificationEditorPath, 0o777); + const args = [ + '--transpileOnly', + TOOL_EXECUTABLE_PATH, + '--project-directory', + this.localRepo.getWorkingDirectoryPath(), + '--temp-directory', + this.tempDirectoryPath, + ...additionalArgs, + ]; const env = { EDITOR: releaseSpecificationEditorPath, }; - - const result = await this.localRepo.runCommand( - TS_NODE_PATH, - [ - '--transpileOnly', - TOOL_EXECUTABLE_PATH, - '--project-directory', - this.localRepo.getWorkingDirectoryPath(), - '--temp-directory', - this.tempDirectoryPath, - ], - { env }, - ); + const result = await this.localRepo.runCommand(TS_NODE_PATH, args, { env }); debug( ['---- START OUTPUT -----', result.all, '---- END OUTPUT -----'].join(