Skip to content

Commit 577de5e

Browse files
authored
Support backport releases (#37)
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.
1 parent db188f1 commit 577de5e

11 files changed

+627
-42
lines changed

src/command-line-arguments.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export interface CommandLineArguments {
55
projectDirectory: string;
66
tempDirectory: string | undefined;
77
reset: boolean;
8+
backport: boolean;
89
}
910

1011
/**
@@ -37,6 +38,12 @@ export async function readCommandLineArguments(
3738
type: 'boolean',
3839
default: false,
3940
})
41+
.option('backport', {
42+
describe:
43+
'Instructs the tool to bump the second part of the version rather than the first for a backport release.',
44+
type: 'boolean',
45+
default: false,
46+
})
4047
.help()
4148
.strict()
4249
.parse();

src/functional.test.ts

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { buildChangelog } from '../tests/functional/helpers/utils';
33

44
describe('create-release-branch (functional)', () => {
55
describe('against a monorepo with independent versions', () => {
6-
it('updates the version of the root package to be the current date along with the versions of the specified packages', async () => {
6+
it('bumps the ordinary part of the root package and updates the versions of the specified packages according to the release spec', async () => {
77
await withMonorepoProjectEnvironment(
88
{
99
packages: {
@@ -114,6 +114,118 @@ describe('create-release-branch (functional)', () => {
114114
);
115115
});
116116

117+
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 () => {
118+
await withMonorepoProjectEnvironment(
119+
{
120+
packages: {
121+
$root$: {
122+
name: '@scope/monorepo',
123+
version: '1.0.0',
124+
directoryPath: '.',
125+
},
126+
a: {
127+
name: '@scope/a',
128+
version: '0.1.2',
129+
directoryPath: 'packages/a',
130+
},
131+
b: {
132+
name: '@scope/b',
133+
version: '1.1.4',
134+
directoryPath: 'packages/b',
135+
},
136+
c: {
137+
name: '@scope/c',
138+
version: '2.0.13',
139+
directoryPath: 'packages/c',
140+
},
141+
d: {
142+
name: '@scope/d',
143+
version: '1.2.3',
144+
directoryPath: 'packages/d',
145+
},
146+
},
147+
workspaces: {
148+
'.': ['packages/*'],
149+
},
150+
},
151+
async (environment) => {
152+
await environment.updateJsonFile('package.json', {
153+
scripts: {
154+
foo: 'bar',
155+
},
156+
});
157+
await environment.updateJsonFileWithinPackage('a', 'package.json', {
158+
scripts: {
159+
foo: 'bar',
160+
},
161+
});
162+
await environment.updateJsonFileWithinPackage('b', 'package.json', {
163+
scripts: {
164+
foo: 'bar',
165+
},
166+
});
167+
await environment.updateJsonFileWithinPackage('c', 'package.json', {
168+
scripts: {
169+
foo: 'bar',
170+
},
171+
});
172+
await environment.updateJsonFileWithinPackage('d', 'package.json', {
173+
scripts: {
174+
foo: 'bar',
175+
},
176+
});
177+
178+
await environment.runTool({
179+
args: ['--backport'],
180+
releaseSpecification: {
181+
packages: {
182+
a: 'major',
183+
b: 'minor',
184+
c: 'patch',
185+
d: '1.2.4',
186+
},
187+
},
188+
});
189+
190+
expect(await environment.readJsonFile('package.json')).toStrictEqual({
191+
name: '@scope/monorepo',
192+
version: '1.1.0',
193+
private: true,
194+
workspaces: ['packages/*'],
195+
scripts: { foo: 'bar' },
196+
});
197+
expect(
198+
await environment.readJsonFileWithinPackage('a', 'package.json'),
199+
).toStrictEqual({
200+
name: '@scope/a',
201+
version: '1.0.0',
202+
scripts: { foo: 'bar' },
203+
});
204+
expect(
205+
await environment.readJsonFileWithinPackage('b', 'package.json'),
206+
).toStrictEqual({
207+
name: '@scope/b',
208+
version: '1.2.0',
209+
scripts: { foo: 'bar' },
210+
});
211+
expect(
212+
await environment.readJsonFileWithinPackage('c', 'package.json'),
213+
).toStrictEqual({
214+
name: '@scope/c',
215+
version: '2.0.14',
216+
scripts: { foo: 'bar' },
217+
});
218+
expect(
219+
await environment.readJsonFileWithinPackage('d', 'package.json'),
220+
).toStrictEqual({
221+
name: '@scope/d',
222+
version: '1.2.4',
223+
scripts: { foo: 'bar' },
224+
});
225+
},
226+
);
227+
});
228+
117229
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 () => {
118230
await withMonorepoProjectEnvironment(
119231
{

src/initial-parameters.test.ts

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ describe('initial-parameters', () => {
3434
projectDirectory: '/path/to/project',
3535
tempDirectory: '/path/to/temp',
3636
reset: true,
37+
backport: false,
3738
});
3839
jest
3940
.spyOn(envModule, 'getEnvironmentVariables')
@@ -42,16 +43,17 @@ describe('initial-parameters', () => {
4243
.calledWith('/path/to/project', { stderr })
4344
.mockResolvedValue(project);
4445

45-
const config = await determineInitialParameters({
46+
const initialParameters = await determineInitialParameters({
4647
argv: ['arg1', 'arg2'],
4748
cwd: '/path/to/somewhere',
4849
stderr,
4950
});
5051

51-
expect(config).toStrictEqual({
52+
expect(initialParameters).toStrictEqual({
5253
project,
5354
tempDirectoryPath: '/path/to/temp',
5455
reset: true,
56+
releaseType: 'ordinary',
5557
});
5658
});
5759

@@ -66,6 +68,7 @@ describe('initial-parameters', () => {
6668
projectDirectory: 'project',
6769
tempDirectory: undefined,
6870
reset: true,
71+
backport: false,
6972
});
7073
jest
7174
.spyOn(envModule, 'getEnvironmentVariables')
@@ -94,6 +97,7 @@ describe('initial-parameters', () => {
9497
projectDirectory: '/path/to/project',
9598
tempDirectory: 'tmp',
9699
reset: true,
100+
backport: false,
97101
});
98102
jest
99103
.spyOn(envModule, 'getEnvironmentVariables')
@@ -102,13 +106,15 @@ describe('initial-parameters', () => {
102106
.calledWith('/path/to/project', { stderr })
103107
.mockResolvedValue(project);
104108

105-
const config = await determineInitialParameters({
109+
const initialParameters = await determineInitialParameters({
106110
argv: ['arg1', 'arg2'],
107111
cwd: '/path/to/cwd',
108112
stderr,
109113
});
110114

111-
expect(config.tempDirectoryPath).toStrictEqual('/path/to/cwd/tmp');
115+
expect(initialParameters.tempDirectoryPath).toStrictEqual(
116+
'/path/to/cwd/tmp',
117+
);
112118
});
113119

114120
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', () => {
122128
projectDirectory: '/path/to/project',
123129
tempDirectory: undefined,
124130
reset: true,
131+
backport: false,
125132
});
126133
jest
127134
.spyOn(envModule, 'getEnvironmentVariables')
@@ -130,13 +137,13 @@ describe('initial-parameters', () => {
130137
.calledWith('/path/to/project', { stderr })
131138
.mockResolvedValue(project);
132139

133-
const config = await determineInitialParameters({
140+
const initialParameters = await determineInitialParameters({
134141
argv: ['arg1', 'arg2'],
135142
cwd: '/path/to/cwd',
136143
stderr,
137144
});
138145

139-
expect(config.tempDirectoryPath).toStrictEqual(
146+
expect(initialParameters.tempDirectoryPath).toStrictEqual(
140147
path.join(os.tmpdir(), 'create-release-branch', '@foo__bar'),
141148
);
142149
});
@@ -150,6 +157,7 @@ describe('initial-parameters', () => {
150157
projectDirectory: '/path/to/project',
151158
tempDirectory: '/path/to/temp',
152159
reset: true,
160+
backport: false,
153161
});
154162
jest
155163
.spyOn(envModule, 'getEnvironmentVariables')
@@ -158,13 +166,13 @@ describe('initial-parameters', () => {
158166
.calledWith('/path/to/project', { stderr })
159167
.mockResolvedValue(project);
160168

161-
const config = await determineInitialParameters({
169+
const initialParameters = await determineInitialParameters({
162170
argv: ['arg1', 'arg2'],
163171
cwd: '/path/to/somewhere',
164172
stderr,
165173
});
166174

167-
expect(config.reset).toBe(true);
175+
expect(initialParameters.reset).toBe(true);
168176
});
169177

170178
it('returns initial parameters including reset: false, derived from a command-line argument of "--reset false"', async () => {
@@ -176,6 +184,61 @@ describe('initial-parameters', () => {
176184
projectDirectory: '/path/to/project',
177185
tempDirectory: '/path/to/temp',
178186
reset: false,
187+
backport: false,
188+
});
189+
jest
190+
.spyOn(envModule, 'getEnvironmentVariables')
191+
.mockReturnValue({ EDITOR: undefined });
192+
when(jest.spyOn(projectModule, 'readProject'))
193+
.calledWith('/path/to/project', { stderr })
194+
.mockResolvedValue(project);
195+
196+
const initialParameters = await determineInitialParameters({
197+
argv: ['arg1', 'arg2'],
198+
cwd: '/path/to/somewhere',
199+
stderr,
200+
});
201+
202+
expect(initialParameters.reset).toBe(false);
203+
});
204+
205+
it('returns initial parameters including a releaseType of "backport", derived from a command-line argument of "--backport true"', async () => {
206+
const project = buildMockProject();
207+
const stderr = createNoopWriteStream();
208+
when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments'))
209+
.calledWith(['arg1', 'arg2'])
210+
.mockResolvedValue({
211+
projectDirectory: '/path/to/project',
212+
tempDirectory: '/path/to/temp',
213+
reset: false,
214+
backport: true,
215+
});
216+
jest
217+
.spyOn(envModule, 'getEnvironmentVariables')
218+
.mockReturnValue({ EDITOR: undefined });
219+
when(jest.spyOn(projectModule, 'readProject'))
220+
.calledWith('/path/to/project', { stderr })
221+
.mockResolvedValue(project);
222+
223+
const initialParameters = await determineInitialParameters({
224+
argv: ['arg1', 'arg2'],
225+
cwd: '/path/to/somewhere',
226+
stderr,
227+
});
228+
229+
expect(initialParameters.releaseType).toBe('backport');
230+
});
231+
232+
it('returns initial parameters including a releaseType of "ordinary", derived from a command-line argument of "--backport false"', async () => {
233+
const project = buildMockProject();
234+
const stderr = createNoopWriteStream();
235+
when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments'))
236+
.calledWith(['arg1', 'arg2'])
237+
.mockResolvedValue({
238+
projectDirectory: '/path/to/project',
239+
tempDirectory: '/path/to/temp',
240+
reset: false,
241+
backport: false,
179242
});
180243
jest
181244
.spyOn(envModule, 'getEnvironmentVariables')
@@ -184,13 +247,13 @@ describe('initial-parameters', () => {
184247
.calledWith('/path/to/project', { stderr })
185248
.mockResolvedValue(project);
186249

187-
const config = await determineInitialParameters({
250+
const initialParameters = await determineInitialParameters({
188251
argv: ['arg1', 'arg2'],
189252
cwd: '/path/to/somewhere',
190253
stderr,
191254
});
192255

193-
expect(config.reset).toBe(false);
256+
expect(initialParameters.releaseType).toBe('ordinary');
194257
});
195258
});
196259
});

src/initial-parameters.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,22 @@ import { readCommandLineArguments } from './command-line-arguments';
44
import { WriteStreamLike } from './fs';
55
import { readProject, Project } from './project';
66

7+
/**
8+
* The type of release being created as determined by the parent release.
9+
*
10+
* - An *ordinary* release includes features or fixes applied against the
11+
* latest release and is designated by bumping the first part of that release's
12+
* version string.
13+
* - A *backport* release includes fixes applied against a previous release and
14+
* is designated by bumping the second part of that release's version string.
15+
*/
16+
export type ReleaseType = 'ordinary' | 'backport';
17+
718
interface InitialParameters {
819
project: Project;
920
tempDirectoryPath: string;
1021
reset: boolean;
22+
releaseType: ReleaseType;
1123
}
1224

1325
/**
@@ -29,18 +41,23 @@ export async function determineInitialParameters({
2941
cwd: string;
3042
stderr: WriteStreamLike;
3143
}): Promise<InitialParameters> {
32-
const inputs = await readCommandLineArguments(argv);
44+
const args = await readCommandLineArguments(argv);
3345

34-
const projectDirectoryPath = path.resolve(cwd, inputs.projectDirectory);
46+
const projectDirectoryPath = path.resolve(cwd, args.projectDirectory);
3547
const project = await readProject(projectDirectoryPath, { stderr });
3648
const tempDirectoryPath =
37-
inputs.tempDirectory === undefined
49+
args.tempDirectory === undefined
3850
? path.join(
3951
os.tmpdir(),
4052
'create-release-branch',
4153
project.rootPackage.validatedManifest.name.replace('/', '__'),
4254
)
43-
: path.resolve(cwd, inputs.tempDirectory);
55+
: path.resolve(cwd, args.tempDirectory);
4456

45-
return { project, tempDirectoryPath, reset: inputs.reset };
57+
return {
58+
project,
59+
tempDirectoryPath,
60+
reset: args.reset,
61+
releaseType: args.backport ? 'backport' : 'ordinary',
62+
};
4663
}

0 commit comments

Comments
 (0)