Skip to content

Commit 5f00fd4

Browse files
authored
Implement "layer" pack style 🎂 (#1158)
1 parent 03e29dd commit 5f00fd4

23 files changed

+799
-257
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "minor",
3+
"comment": "Implement \"layer\" pack style. Also handle all topological package sorting internally, and update the logic to ignore devDependencies since they can't cause breakages.",
4+
"packageName": "beachball",
5+
"email": "elcraig@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}

‎docs/overview/configuration.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ For the latest full list of supported options, see `RepoOptions` [in this file](
9494
| `npmReadConcurrency` | number | 5 | repo | Maximum concurrency for fetching package versions from the registry (see `concurrency` for write operations) |
9595
| `package` | `string` | | repo | Specifies which package the command relates to (overrides change detection based on `git diff`) |
9696
| `prereleasePrefix` | `string` | | repo | Prerelease prefix, e.g. `"beta"`. Note that if this is specified, packages with change type major/minor/patch will be bumped as prerelease instead. |
97+
| `packStyle` | `'sequential' \| 'layer'` | `'sequential'` | repo | With `packToPath`, how to organize the tgz files. `'sequential'` uses numeric prefixes to ensure topological ordering. `'layer'` groups the packages into numbered subfolders based on dependency tree layers. |
98+
| `packToPath` | `string` | | repo | Instead of publishing to npm, pack packages to tgz files under the specified path. |
9799
| `publish` | `boolean` | `true` | repo | Whether to publish to npm registry |
98100
| `push` | `boolean` | `true` | repo | Whether to push to the remote git branch |
99101
| `registry` | `string` | | repo | Publish to this npm registry |

‎package.json‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@
5858
"p-limit": "^3.0.2",
5959
"prompts": "^2.4.2",
6060
"semver": "^7.0.0",
61-
"toposort": "^2.0.2",
6261
"workspace-tools": "^0.41.0",
6362
"yargs-parser": "^21.0.0"
6463
},
@@ -69,7 +68,6 @@
6968
"@types/prompts": "^2.4.2",
7069
"@types/semver": "^7.3.13",
7170
"@types/tmp": "^0.2.3",
72-
"@types/toposort": "^2.0.3",
7371
"@types/yargs-parser": "^21.0.0",
7472
"@typescript-eslint/eslint-plugin": "^5.0.0",
7573
"@typescript-eslint/parser": "^5.0.0",

‎src/__fixtures__/changeFiles.ts‎

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import fs from 'fs';
22
import path from 'path';
33
import { writeChangeFiles } from '../changefile/writeChangeFiles';
44
import { getChangePath } from '../paths';
5-
import type { ChangeFileInfo, ChangeType } from '../types/ChangeInfo';
5+
import type { ChangeFileInfo, ChangeSet, ChangeType } from '../types/ChangeInfo';
66
import type { BeachballOptions } from '../types/BeachballOptions';
77

88
/** Change file with `packageName` required and other props optional */
@@ -13,6 +13,11 @@ export const fakeEmail = 'test@test.com';
1313

1414
/**
1515
* Generate a change file for the given package.
16+
* Default values:
17+
* - `type: 'minor'`
18+
* - `dependentChangeType: 'patch'`
19+
* - `comment: '<packageName> comment'`
20+
* - `email: 'test@test.com'`
1621
*/
1722
export function getChange(
1823
packageName: string,
@@ -29,18 +34,11 @@ export function getChange(
2934
}
3035

3136
/**
32-
* Generates and writes change files for the given packages.
33-
* Also commits if `options.commit` is true (the default with full options) and the context is a git repo.
34-
* @param changes Array of package names or partial change files (which must include `packageName`).
35-
* Default values:
36-
* - `type: 'minor'`
37-
* - `dependentChangeType: 'patch'`
38-
* - `comment: '<packageName> comment'`
39-
* - `email: 'test@test.com'`
40-
*
37+
* Generates change files for the given packages.
4138
* @param changes Array of package names or partial change files (which must include `packageName`).
39+
* See {@link getChange} for default values.
4240
*/
43-
export function generateChanges(changes: (string | PartialChangeFile)[]): ChangeFileInfo[] {
41+
function generateChanges(changes: (string | PartialChangeFile)[]): ChangeFileInfo[] {
4442
return changes.map(change => {
4543
change = typeof change === 'string' ? { packageName: change } : change;
4644
return {
@@ -50,6 +48,18 @@ export function generateChanges(changes: (string | PartialChangeFile)[]): Change
5048
});
5149
}
5250

51+
/**
52+
* Generates a change set for the given packages (the file names will not be realistic).
53+
* @param changes Array of package names or partial change files (which must include `packageName`).
54+
* See {@link getChange} for default values.
55+
*/
56+
export function generateChangeSet(changes: (string | PartialChangeFile)[]): ChangeSet {
57+
return generateChanges(changes).map((change, i) => ({
58+
change,
59+
changeFile: `change${i}.json`,
60+
}));
61+
}
62+
5363
/**
5464
* Generates and writes change files for the given packages.
5565
* Also commits if `options.commit` is true.

‎src/__functional__/options/getOptions.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ describe('getOptions (deprecated)', () => {
102102
});
103103

104104
describe('getParsedOptions', () => {
105+
initMockLogs({ alsoLog: ['error', 'warn'] });
106+
105107
let repositoryFactory: RepositoryFactory;
106108
// Don't reuse a repo in these tests! If multiple tests load beachball.config.js from the same path,
107109
// it will use the version from the require cache, which will have outdated contents.

‎src/__functional__/packageManager/packPackage.test.ts‎

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,10 @@ describe('packPackage', () => {
5656
const testPkg = getTestPackage('testpkg');
5757
writeJson(tempPackageJsonPath, testPkg.json);
5858

59-
const packResult = await packPackage(testPkg.info, { packToPath: tempPackPath, index: 0, total: 1 });
59+
const packResult = await packPackage(testPkg.info, {
60+
packToPath: tempPackPath,
61+
packInfo: { index: 0, total: 1 },
62+
});
6063
expect(packResult).toEqual(true);
6164
expect(npmMock.mock).toHaveBeenCalledTimes(1);
6265
expect(npmMock.mock).toHaveBeenCalledWith(
@@ -76,7 +79,10 @@ describe('packPackage', () => {
7679
const testPkg = getTestPackage('@foo/bar');
7780
writeJson(tempPackageJsonPath, testPkg.json);
7881

79-
const packResult = await packPackage(testPkg.info, { packToPath: tempPackPath, index: 0, total: 1 });
82+
const packResult = await packPackage(testPkg.info, {
83+
packToPath: tempPackPath,
84+
packInfo: { index: 0, total: 1 },
85+
});
8086
expect(packResult).toEqual(true);
8187
expect(npmMock.mock).toHaveBeenCalledTimes(1);
8288
expect(npmMock.mock).toHaveBeenCalledWith(
@@ -97,7 +103,10 @@ describe('packPackage', () => {
97103
writeJson(tempPackageJsonPath, testPkg.json);
98104

99105
// There are 100 packages to pack, so index 1 should be prefixed with "002-"
100-
const packResult = await packPackage(testPkg.info, { packToPath: tempPackPath, index: 1, total: 100 });
106+
const packResult = await packPackage(testPkg.info, {
107+
packToPath: tempPackPath,
108+
packInfo: { index: 1, total: 100 },
109+
});
101110
expect(packResult).toEqual(true);
102111
expect(npmMock.mock).toHaveBeenCalledTimes(1);
103112
expect(npmMock.mock).toHaveBeenCalledWith(
@@ -112,14 +121,43 @@ describe('packPackage', () => {
112121
expect(allLogs).toMatch(`Packed ${testPkg.spec} to ${path.join(tempPackPath, `002-${testPkg.packName}`)}`);
113122
});
114123

124+
it('packs package with packMode: "layers"', async () => {
125+
const testPkg = getTestPackage('testpkg');
126+
writeJson(tempPackageJsonPath, testPkg.json);
127+
128+
const layers = Array.from({ length: 10 }, () => [] as string[]);
129+
layers[2].push(testPkg.name);
130+
131+
const packResult = await packPackage(testPkg.info, {
132+
packToPath: tempPackPath,
133+
packInfo: { layers },
134+
});
135+
expect(packResult).toEqual(true);
136+
expect(npmMock.mock).toHaveBeenCalledTimes(1);
137+
expect(npmMock.mock).toHaveBeenCalledWith(
138+
['pack', '--loglevel', 'warn'],
139+
expect.objectContaining({ cwd: tempRoot })
140+
);
141+
const outFile = path.join(tempPackPath, '03', testPkg.packName);
142+
expect(fs.existsSync(outFile)).toBe(true);
143+
expect(fs.existsSync(path.join(tempRoot, testPkg.packName))).toBe(false);
144+
145+
const allLogs = logs.getMockLines('all');
146+
expect(allLogs).toMatch(`Packing - ${testPkg.spec}`);
147+
expect(allLogs).toMatch(`Packed ${testPkg.spec} to ${outFile}`);
148+
});
149+
115150
it('handles failure packing', async () => {
116151
const testPkg = getTestPackage('testpkg');
117152
// It's difficult to simulate actual error conditions, so mock an npm call failure.
118153
npmMock.setCommandOverride('pack', () =>
119154
Promise.resolve({ success: false, stdout: 'oh no', all: 'oh no' } as NpmResult)
120155
);
121156

122-
const packResult = await packPackage(testPkg.info, { packToPath: tempPackPath, index: 0, total: 1 });
157+
const packResult = await packPackage(testPkg.info, {
158+
packToPath: tempPackPath,
159+
packInfo: { index: 0, total: 1 },
160+
});
123161
expect(packResult).toEqual(false);
124162
expect(npmMock.mock).toHaveBeenCalledTimes(1);
125163
expect(fs.readdirSync(tempPackPath)).toEqual([]);
@@ -136,7 +174,10 @@ describe('packPackage', () => {
136174
Promise.resolve({ success: true, stdout: 'not a file', all: 'not a file' } as NpmResult)
137175
);
138176

139-
const packResult = await packPackage(testPkg.info, { packToPath: tempPackPath, index: 0, total: 1 });
177+
const packResult = await packPackage(testPkg.info, {
178+
packToPath: tempPackPath,
179+
packInfo: { index: 0, total: 1 },
180+
});
140181
expect(packResult).toEqual(false);
141182
expect(npmMock.mock).toHaveBeenCalledTimes(1);
142183
expect(fs.existsSync(path.join(tempRoot, testPkg.packName))).toBe(false);
@@ -153,7 +194,10 @@ describe('packPackage', () => {
153194
Promise.resolve({ success: true, stdout: 'nope.tgz', all: 'nope.tgz' } as NpmResult)
154195
);
155196

156-
const packResult = await packPackage(testPkg.info, { packToPath: tempPackPath, index: 0, total: 1 });
197+
const packResult = await packPackage(testPkg.info, {
198+
packToPath: tempPackPath,
199+
packInfo: { index: 0, total: 1 },
200+
});
157201
expect(packResult).toEqual(false);
158202
expect(npmMock.mock).toHaveBeenCalledTimes(1);
159203
expect(fs.existsSync(path.join(tempRoot, testPkg.packName))).toBe(false);
@@ -172,7 +216,10 @@ describe('packPackage', () => {
172216
fs.writeFileSync(destPath, 'other content');
173217
const origPath = path.join(tempRoot, testPkg.packName);
174218

175-
const packResult = await packPackage(testPkg.info, { packToPath: tempPackPath, index: 0, total: 1 });
219+
const packResult = await packPackage(testPkg.info, {
220+
packToPath: tempPackPath,
221+
packInfo: { index: 0, total: 1 },
222+
});
176223
expect(packResult).toEqual(false);
177224
expect(npmMock.mock).toHaveBeenCalledTimes(1);
178225

@@ -196,7 +243,10 @@ describe('packPackage', () => {
196243
const testPkg = getTestPackage('@foo/bar');
197244
writeJson(tempPackageJsonPath, testPkg.json);
198245

199-
const packResult = await packPackage(testPkg.info, { packToPath: tempPackPath, index: 0, total: 1 });
246+
const packResult = await packPackage(testPkg.info, {
247+
packToPath: tempPackPath,
248+
packInfo: { index: 0, total: 1 },
249+
});
200250
expect(packResult).toEqual(true);
201251
expect(npmMock.mock).toHaveBeenCalledTimes(1);
202252
expect(npmMock.mock).toHaveBeenCalledWith(

‎src/__functional__/publish/__snapshots__/publishToRegistry.test.ts.snap‎

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ exports[`publishToRegistry publishes multiple packages in dependency order 1`] =
44
"[log] Validating new package versions...
55
66
[log] Package versions are OK to publish:
7-
• lib@1.0.0
87
• app@1.0.0
8+
• lib@1.0.0
99
1010
[log] Validating no private package among package dependencies
1111
[log] OK!
@@ -129,9 +129,9 @@ exports[`publishToRegistry with concurrency > 1 throws and shows recovery info o
129129
"[log] Validating new package versions...
130130
131131
[log] Package versions are OK to publish:
132-
• pkg3@1.0.0
133-
• pkg2@1.0.0
134132
• pkg1@1.0.0
133+
• pkg2@1.0.0
134+
• pkg3@1.0.0
135135
• pkg4@1.0.0
136136
• pkg5@1.0.0
137137
@@ -214,12 +214,43 @@ oh no
214214
To recover from this, run "beachball sync" to synchronize local package.json files with the registry."
215215
`;
216216
217-
exports[`publishToRegistry with packToPath packs packages 1`] = `
217+
exports[`publishToRegistry with packToPath packs packages into layer folders 1`] = `
218218
"[log] Validating new package versions...
219219
220220
[log] Package versions are OK to publish:
221+
• app@1.0.0
222+
• other@1.0.0
221223
• lib@1.0.0
224+
225+
[log] Validating no private package among package dependencies
226+
[log] OK!
227+
228+
[log] Packing - app@1.0.0
229+
[log] (cwd: <root>/packages/app)
230+
231+
[log] app-1.0.0.tgz
232+
233+
[log] Packed app@1.0.0 to <packPath>/2/app-1.0.0.tgz
234+
[log] Packing - other@1.0.0
235+
[log] (cwd: <root>/packages/other)
236+
237+
[log] other-1.0.0.tgz
238+
239+
[log] Packed other@1.0.0 to <packPath>/2/other-1.0.0.tgz
240+
[log] Packing - lib@1.0.0
241+
[log] (cwd: <root>/packages/lib)
242+
243+
[log] lib-1.0.0.tgz
244+
245+
[log] Packed lib@1.0.0 to <packPath>/1/lib-1.0.0.tgz"
246+
`;
247+
248+
exports[`publishToRegistry with packToPath packs packages sequentially by default 1`] = `
249+
"[log] Validating new package versions...
250+
251+
[log] Package versions are OK to publish:
222252
• app@1.0.0
253+
• lib@1.0.0
223254
224255
[log] Validating no private package among package dependencies
225256
[log] OK!

‎src/__functional__/publish/publishToRegistry.test.ts‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ describe('publishToRegistry', () => {
310310
removeTempDir(packToPath);
311311
});
312312

313-
it('packs packages', async () => {
313+
it('packs packages sequentially by default', async () => {
314314
const bumpInfo = makeBumpInfo({
315315
app: { dependencies: { lib: '1.0.0' } },
316316
lib: {},
@@ -349,5 +349,33 @@ describe('publishToRegistry', () => {
349349
logs.getMockLines('all', { replacePaths: { [tempRoot]: '<root>', [packToPath]: '<packPath>' } })
350350
).toMatchSnapshot();
351351
});
352+
353+
it('packs packages into layer folders', async () => {
354+
const bumpInfo = makeBumpInfo({
355+
app: { dependencies: { lib: '1.0.0' } },
356+
other: { dependencies: { lib: '1.0.0' } },
357+
lib: {},
358+
});
359+
360+
await publishToRegistry(bumpInfo, { ...defaultOptions, packToPath, packStyle: 'layer' });
361+
362+
// Nothing should be published to the registry
363+
expect(npmMock.getPublishedVersions('lib')).toBeUndefined();
364+
expect(npmMock.getPublishedVersions('app')).toBeUndefined();
365+
366+
// Tgz files should be in numbered subdirectories by layer
367+
// lib has no deps → layer 0 (folder "1"), app and other depend on lib → layer 1 (folder "2")
368+
const layer1 = fs.readdirSync(path.join(packToPath, '1'));
369+
const layer2 = fs.readdirSync(path.join(packToPath, '2'));
370+
expect(layer1).toEqual([getMockNpmPackName(bumpInfo.packageInfos.lib)]);
371+
expect(layer2.sort()).toEqual([
372+
getMockNpmPackName(bumpInfo.packageInfos.app),
373+
getMockNpmPackName(bumpInfo.packageInfos.other),
374+
]);
375+
376+
expect(
377+
logs.getMockLines('all', { replacePaths: { [tempRoot]: '<root>', [packToPath]: '<packPath>' } })
378+
).toMatchSnapshot();
379+
});
352380
});
353381
});

‎src/__tests__/bump/bumpInMemory.test.ts‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import { describe, expect, it } from '@jest/globals';
22
import path from 'path';
3-
import { generateChanges, type PartialChangeFile } from '../../__fixtures__/changeFiles';
3+
import { generateChangeSet, type PartialChangeFile } from '../../__fixtures__/changeFiles';
44
import { initMockLogs } from '../../__fixtures__/mockLogs';
55
import { makePackageInfosByFolder, type PartialPackageInfo } from '../../__fixtures__/packageInfos';
66
import { bumpInMemory } from '../../bump/bumpInMemory';
77
import { getParsedOptions } from '../../options/getOptions';
88
import type { RepoOptions } from '../../types/BeachballOptions';
9-
import type { ChangeSet } from '../../types/ChangeInfo';
109
import { getScopedPackages } from '../../monorepo/getScopedPackages';
1110
import { getPackageGroups } from '../../monorepo/getPackageGroups';
1211

@@ -29,10 +28,7 @@ describe('bumpInMemory', () => {
2928
cwd,
3029
cliOptions,
3130
});
32-
const changeSet: ChangeSet = generateChanges(params.changes).map((change, i) => ({
33-
change,
34-
changeFile: `change${i}.json`,
35-
}));
31+
const changeSet = generateChangeSet(params.changes);
3632
const scopedPackages = getScopedPackages(options, originalPackageInfos);
3733
const packageGroups = getPackageGroups(originalPackageInfos, cwd, options.groups);
3834

‎src/__tests__/bump/callHook.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ describe('callHook', () => {
1414
// This graph only has one possible ordering
1515
pkg1: { dependencies: { pkg2: '*' } },
1616
pkg2: { version: '2.0.0', peerDependencies: { pkg3: '*', pkg4: '*' } },
17-
pkg3: { devDependencies: { pkg4: '*' } },
17+
pkg3: { dependencies: { pkg4: '*' } },
1818
pkg4: { optionalDependencies: { pkg5: '*' } },
1919
pkg5: {},
2020
},

0 commit comments

Comments
 (0)