From 21a42e42bf2837b9fbcc3de67671aa055010aaed Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Tue, 4 Oct 2022 14:22:34 -0600 Subject: [PATCH] Support backport releases Every so often we may want to copy a fix from the latest version of a package to a previously-released version. This type of release is called a backport. Outside of any automation, when we want to apply a backport, we will switch to the Git tag that corresponds to the previous release, cut a new branch that corresponds to that particular version line (e.g. `1.x`), apply the fixes, push a pull request for those fixes, and merge them into that branch. When we want to *release* these changes, we will make another branch, bump versions and update changelogs, push that branch as a pull request, merge it in, and then finally publish NPM packages and create the GitHub release. So we want to automate this workflow, but one question that comes to mind is, what version do we assign to such a release? Typically, when we issue a new release that *isn't* a backport, we will bump the first part of the version string (e.g. if the current version is "1.0.0", we will assign "2.0.0" as the new version string). Since backports are applied to previous releases, they are assigned a new version that is a modification of the version they are fixing. So when we name a backport release, we will take the base version and bump its *second* part (e.g. if the existing version is "1.0.0", the backport release will be called "1.1.0"). With that in mind, this commit adds a `--backport` option to the tool. When you specify this, it will assume that you are already on a version line branch (e.g. `1.x`) and that the current version of the primary package (the root package for a monorepo, the sole package for a polyrepo package) is the one that you want to fix. It will then use *this* version of the package (instead of the latest released version) to apply the changes. In the case of a monorepo, it will determine which workspace packages have changed since the Git tag that corresponds to the current version of the primary package (again, not the latest tag) and use this to populate the release spec. It will then bump the second part of the primary package version (as opposed to the first) and proceed as usual. --- src/command-line-arguments.ts | 7 + src/functional.test.ts | 131 +++++++- src/initial-parameters.test.ts | 83 ++++- src/initial-parameters.ts | 27 +- src/main.test.ts | 7 +- src/main.ts | 3 +- src/monorepo-workflow-operations.test.ts | 315 +++++++++++++++++- src/monorepo-workflow-operations.ts | 18 +- src/release-plan.test.ts | 57 +++- src/release-plan.ts | 12 +- .../helpers/monorepo-environment.ts | 26 +- 11 files changed, 644 insertions(+), 42 deletions(-) diff --git a/src/command-line-arguments.ts b/src/command-line-arguments.ts index 753a1626..631c84a6 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 4be3f46d..16bdbecc 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: { @@ -131,6 +131,135 @@ 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', + }, + e: { + name: '@scope/e', + version: '0.0.3', + directoryPath: 'packages/e', + }, + }, + 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.updateJsonFileWithinPackage('e', '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' }, + }); + expect( + await environment.readJsonFileWithinPackage('e', 'package.json'), + ).toStrictEqual({ + name: '@scope/e', + version: '0.0.3', + 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 6c226856..a9d1d569 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 ae82a952..842bcaf7 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 539275cb..82dff548 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 e1a2568e..7c5e8768 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 07b6d421..a6a543a8 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 6466dabe..db4764e6 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 03ef4db3..d49e9eab 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 f8408078..1db17b1b 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 fc082467..392f1745 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', + path.join(this.localRepo.getWorkingDirectoryPath(), 'tmp'), + ...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', - path.join(this.localRepo.getWorkingDirectoryPath(), 'tmp'), - ], - { env }, - ); + const result = await this.localRepo.runCommand(TS_NODE_PATH, args, { env }); debug( ['---- START OUTPUT -----', result.all, '---- END OUTPUT -----'].join(