Skip to content

Commit 3b90b07

Browse files
authored
Support catalog versions (#1128)
1 parent be6dca6 commit 3b90b07

17 files changed

Lines changed: 743 additions & 148 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "Support catalog versions",
4+
"packageName": "beachball",
5+
"email": "elcraig@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
"prompts": "^2.4.2",
6161
"semver": "^7.0.0",
6262
"toposort": "^2.0.2",
63-
"workspace-tools": "^0.40.0",
63+
"workspace-tools": "^0.40.2",
6464
"yargs-parser": "^21.0.0"
6565
},
6666
"devDependencies": {

src/__e2e__/bump.test.ts

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { createCommandContext } from '../monorepo/createCommandContext';
1717
import type { CommandContext } from '../types/CommandContext';
1818
import type { BumpInfo } from '../types/BumpInfo';
1919
import { deepFreeze } from '../__fixtures__/object';
20+
import { catalogsToYaml, type Catalogs } from 'workspace-tools';
2021

2122
//
2223
// These tests use git repos and are slow, so besides a few basic scenarios, this file should
@@ -446,10 +447,11 @@ describe('bump command', () => {
446447
it('bumps dependents with workspace: deps', async () => {
447448
const monorepo: RepoFixture['folders'] = {
448449
packages: {
449-
'pkg-1': { version: '1.0.0' },
450+
// Include some external deps to make sure nothing weird happens there
451+
'pkg-1': { version: '1.0.0', dependencies: { extra: '~1.2.3' } },
450452
'pkg-2': { version: '1.0.0', dependencies: { 'pkg-1': 'workspace:~' } },
451453
// this workspace version will be updated
452-
'pkg-3': { version: '1.0.0', dependencies: { 'pkg-2': 'workspace:^1.0.0' } },
454+
'pkg-3': { version: '1.0.0', dependencies: { 'pkg-2': 'workspace:^1.0.0', other: 'npm:lodash' } },
453455
},
454456
};
455457
repositoryFactory = new RepositoryFactory({ folders: monorepo });
@@ -467,12 +469,15 @@ describe('bump command', () => {
467469
const packageInfos = getPackageInfos(parsedOptions);
468470

469471
// All the dependent packages are bumped despite the workspace: dep specs
470-
expect(packageInfos['pkg-1'].version).toBe('1.1.0');
472+
expect(packageInfos['pkg-1']).toEqual({ ...originalPackageInfos['pkg-1'], version: '1.1.0' });
471473
// workspace:~ range isn't changed
472474
expect(packageInfos['pkg-2']).toEqual({ ...originalPackageInfos['pkg-2'], version: '1.0.1' });
473-
expect(packageInfos['pkg-2'].version).toBe('1.0.1');
474475
// workspace: range with number is updated
475-
expect(packageInfos['pkg-3'].dependencies).toEqual({ 'pkg-2': 'workspace:^1.0.1' });
476+
expect(packageInfos['pkg-3']).toEqual({
477+
...originalPackageInfos['pkg-3'],
478+
version: '1.0.1',
479+
dependencies: { 'pkg-2': 'workspace:^1.0.1', other: 'npm:lodash' },
480+
});
476481

477482
expect(readChangelogJson(repo.pathTo('packages/pkg-1'))).not.toBeNull();
478483
const pkg3Changelog = readChangelogJson(repo.pathTo('packages/pkg-3'));
@@ -485,6 +490,48 @@ describe('bump command', () => {
485490
expect(readChangelogJson(repo.pathTo('packages/pkg-2'))).toBeNull();
486491
});
487492

493+
it('bumps dependents with catalog: deps', async () => {
494+
const monorepo: RepoFixture['folders'] = {
495+
packages: {
496+
'pkg-1': { version: '1.0.0' },
497+
'pkg-2': { version: '1.0.0', dependencies: { 'pkg-1': 'catalog:' } },
498+
'pkg-3': { version: '1.0.0', dependencies: { 'pkg-2': 'catalog:' } },
499+
},
500+
};
501+
const catalogs: Catalogs = {
502+
default: { 'pkg-1': 'workspace:~', 'pkg-2': 'workspace:^1.0.0' },
503+
};
504+
repositoryFactory = new RepositoryFactory({
505+
folders: monorepo,
506+
// This isn't currently read by bump() but should be present for completeness
507+
extraFiles: { '.yarnrc.yml': catalogsToYaml(catalogs) },
508+
});
509+
repo = repositoryFactory.cloneRepository();
510+
511+
const { options, parsedOptions } = getOptions({
512+
bumpDeps: true,
513+
});
514+
generateChangeFiles([{ packageName: 'pkg-1', type: 'minor' }], options);
515+
repo.push();
516+
517+
// The bumpInfo object is covered by the similar test in bumpInMemory.test.ts
518+
const { originalPackageInfos } = await bumpWrapper(parsedOptions);
519+
520+
const packageInfos = getPackageInfos(parsedOptions);
521+
522+
// All the dependent packages are bumped despite the catalog: dep specs
523+
expect(packageInfos['pkg-1'].version).toBe('1.1.0');
524+
// catalog: ranges aren't changed
525+
expect(packageInfos['pkg-2']).toEqual({ ...originalPackageInfos['pkg-2'], version: '1.0.1' });
526+
expect(packageInfos['pkg-3']).toEqual({ ...originalPackageInfos['pkg-3'], version: '1.0.1' });
527+
528+
expect(readChangelogJson(repo.pathTo('packages/pkg-1'))).not.toBeNull();
529+
// Current behavior: dependentChangedBy misses catalog: deps, so there are no changelog entries
530+
// https://github.com/microsoft/beachball/issues/981
531+
expect(readChangelogJson(repo.pathTo('packages/pkg-2'))).toBeNull();
532+
expect(readChangelogJson(repo.pathTo('packages/pkg-3'))).toBeNull();
533+
});
534+
488535
// Explicit tests for sync/async hooks aren't necessary, especially since these are slow tests.
489536
// Async is slightly trickier, so test that.
490537
it('calls prebump/postbump hooks', async () => {

src/__e2e__/publishE2E.test.ts

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { describe, expect, it, afterEach, jest } from '@jest/globals';
22
import fs from 'fs';
33
import path from 'path';
4-
import { addGitObserver, clearGitObservers } from 'workspace-tools';
4+
import { addGitObserver, catalogsToYaml, clearGitObservers, type Catalogs } from 'workspace-tools';
55
import { generateChangeFiles, getChangeFiles } from '../__fixtures__/changeFiles';
66
import { defaultBranchName, defaultRemoteBranchName } from '../__fixtures__/gitDefaults';
77
import { initMockLogs } from '../__fixtures__/mockLogs';
88
import type { Repository } from '../__fixtures__/repository';
9-
import { type PackageJsonFixture, RepositoryFactory } from '../__fixtures__/repositoryFactory';
9+
import { type PackageJsonFixture, type RepoFixture, RepositoryFactory } from '../__fixtures__/repositoryFactory';
1010
import { publish } from '../commands/publish';
1111
import type { ParsedOptions, RepoOptions } from '../types/BeachballOptions';
1212
import { _mockNpmPublish, initNpmMock } from '../__fixtures__/mockNpm';
@@ -62,6 +62,7 @@ describe('publish command (e2e)', () => {
6262
deepFreezeProperties(context.bumpInfo);
6363
deepFreezeProperties(context.originalPackageInfos);
6464
await publish(parsedOptions.options, context);
65+
return context;
6566
}
6667

6768
afterEach(() => {
@@ -334,6 +335,77 @@ describe('publish command (e2e)', () => {
334335
expect(newPackageInfos.foo.version).toBe('1.0.0');
335336
});
336337

338+
// Combine workspace/catalog/file cases since these tests are slow
339+
it('publishes packages with workspace: and catalog: deps and replaces versions', async () => {
340+
const monorepo: RepoFixture['folders'] = {
341+
packages: {
342+
// Include some external deps to make sure nothing weird happens there
343+
'pkg-1': { version: '1.0.0', dependencies: { extra: '~1.2.3' } },
344+
'pkg-2': { version: '1.0.0', dependencies: { 'pkg-1': 'workspace:~', react: 'catalog:react18' } },
345+
'pkg-3': { version: '1.0.0', dependencies: { 'pkg-2': 'workspace:^1.0.0', other: 'npm:lodash' } },
346+
'pkg-4': {
347+
version: '1.0.0',
348+
dependencies: { 'pkg-1': 'catalog:' },
349+
devDependencies: { 'pkg-2': 'file:../pkg-2' },
350+
},
351+
},
352+
};
353+
const catalogs: Catalogs = {
354+
default: { 'pkg-1': 'workspace:~' }, // only yarn supports workspace: inside catalog
355+
named: { react18: { react: '^18.0.0' } },
356+
};
357+
repositoryFactory = new RepositoryFactory({
358+
folders: monorepo,
359+
extraFiles: { '.yarnrc.yml': catalogsToYaml(catalogs) },
360+
});
361+
repo = repositoryFactory.cloneRepository();
362+
363+
const { options, parsedOptions } = getOptions({ bumpDeps: true, fetch: false });
364+
generateChangeFiles([{ packageName: 'pkg-1', type: 'minor' }], options);
365+
repo.push();
366+
367+
const { originalPackageInfos } = await publishWrapper(parsedOptions);
368+
repo.checkout(defaultBranchName);
369+
repo.pull();
370+
371+
expect(repo.getCurrentTags()).toEqual(['pkg-1_v1.1.0', 'pkg-2_v1.0.1', 'pkg-3_v1.0.1', 'pkg-4_v1.0.1']);
372+
373+
// All the dependent packages are bumped despite the workspace: dep specs.
374+
// The literal workspace: specs are preserved in git.
375+
const packageInfos = getPackageInfos(parsedOptions);
376+
expect(packageInfos['pkg-1']).toEqual({ ...originalPackageInfos['pkg-1'], version: '1.1.0' });
377+
// workspace:~ and catalog: ranges aren't changed
378+
expect(packageInfos['pkg-2']).toEqual({ ...originalPackageInfos['pkg-2'], version: '1.0.1' });
379+
expect(packageInfos['pkg-2'].version).toBe('1.0.1');
380+
// workspace: range with number is updated
381+
expect(packageInfos['pkg-3']).toEqual({
382+
...originalPackageInfos['pkg-3'],
383+
version: '1.0.1',
384+
dependencies: { 'pkg-2': 'workspace:^1.0.1', other: 'npm:lodash' },
385+
});
386+
// catalog: range isn't changed
387+
expect(packageInfos['pkg-4']).toEqual({ ...originalPackageInfos['pkg-4'], version: '1.0.1' });
388+
389+
// The changelogs are adequately covered by the similar bump test.
390+
391+
// Verify that the published packages have the actual resolved versions
392+
expect(npmMock.getPublishedPackage('pkg-1')).toMatchObject({ version: '1.1.0' });
393+
expect(npmMock.getPublishedPackage('pkg-2')).toMatchObject({
394+
version: '1.0.1',
395+
dependencies: { 'pkg-1': '~1.1.0', react: '^18.0.0' },
396+
});
397+
expect(npmMock.getPublishedPackage('pkg-3')).toMatchObject({
398+
version: '1.0.1',
399+
dependencies: { 'pkg-2': '^1.0.1', other: 'npm:lodash' },
400+
});
401+
expect(npmMock.getPublishedPackage('pkg-4')).toMatchObject({
402+
version: '1.0.1',
403+
dependencies: { 'pkg-1': '~1.1.0' },
404+
// file: deps aren't currently replaced (definitely fine for dev deps, questionable for prod)
405+
devDependencies: { 'pkg-2': 'file:../pkg-2' },
406+
});
407+
});
408+
337409
// These tests are slow, so combine pre and post hooks
338410
it('respects prepublish/postpublish hooks', async () => {
339411
repositoryFactory = new RepositoryFactory('monorepo');
@@ -499,6 +571,7 @@ describe('publish command (e2e)', () => {
499571
});
500572

501573
// Just test postpublish (prepublish should have the same logic)
574+
// TODO: possibly move to in-memory test
502575
it('respects concurrency limit for publish hooks', async () => {
503576
const packagesToPublish = ['pkg1', 'pkg2', 'pkg3', 'pkg4'];
504577
type ExtraPackageJsonFixture = PackageJsonFixture & { customAfterPublish?: { notify: string } };

src/__fixtures__/repository.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ ${gitResult.stderr.toString()}`);
187187
return result.stdout.trim();
188188
}
189189

190-
/** Get tags pointing to the current HEAD commit */
190+
/** Get sorted list of tags pointing to the current HEAD commit */
191191
getCurrentTags(): string[] {
192192
const tagsResult = this.git(['tag', '--points-at', 'HEAD']);
193193
const trimmedResult = tagsResult.stdout.trim();

src/__fixtures__/repositoryFactory.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ export type RepoFixture = {
5151

5252
/** Description to include in the temp folder name */
5353
tempDescription?: string;
54+
55+
/**
56+
* Mapping from extra file relative path to contents.
57+
* (A `yarn.lock` file is written automatically.)
58+
*/
59+
extraFiles?: Record<string, string>;
5460
};
5561

5662
/** Repo fixture with all required props filled out */
@@ -233,6 +239,14 @@ export class RepositoryFactory {
233239
writeJson(path.join(pkgFolder, 'package.json'), packageJson);
234240
}
235241
}
242+
243+
if (fixture.extraFiles) {
244+
for (const [relativePath, contents] of Object.entries(fixture.extraFiles)) {
245+
const fullPath = tmpRepo.pathTo(parentFolder, relativePath);
246+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
247+
fs.writeFileSync(fullPath, contents);
248+
}
249+
}
236250
}
237251
tmpRepo.commitAll(`committing fixture`);
238252

src/__tests__/bump/bumpInMemory.test.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -289,9 +289,9 @@ describe('bumpInMemory', () => {
289289
const { bumpInfo, originalPackageInfos } = gatherBumpInfoWrapper({
290290
packageFolders: {
291291
'pkg-1': { version: '1.0.0' },
292-
'pkg-2': { version: '1.0.0', dependencies: { 'pkg-1': 'workspace:~' } },
292+
'pkg-2': { version: '1.0.0', dependencies: { 'pkg-1': 'workspace:~', extra: '~1.2.3' } },
293293
// this workspace version will be updated
294-
'pkg-3': { version: '1.0.0', dependencies: { 'pkg-2': 'workspace:^1.0.0' } },
294+
'pkg-3': { version: '1.0.0', dependencies: { 'pkg-2': 'workspace:^1.0.0', other: 'npm:lodash' } },
295295
},
296296
repoOptions: { bumpDeps: true },
297297
changes: ['pkg-1'],
@@ -312,9 +312,41 @@ describe('bumpInMemory', () => {
312312
expect(packageInfos['pkg-1'].version).toBe('1.1.0');
313313
// workspace:~ range isn't changed
314314
expect(packageInfos['pkg-2']).toEqual({ ...originalPackageInfos['pkg-2'], version: '1.0.1' });
315-
expect(packageInfos['pkg-2'].version).toBe('1.0.1');
316315
// workspace: range with number is updated
317-
expect(packageInfos['pkg-3'].dependencies).toEqual({ 'pkg-2': 'workspace:^1.0.1' });
316+
expect(packageInfos['pkg-3'].dependencies).toEqual({ 'pkg-2': 'workspace:^1.0.1', other: 'npm:lodash' });
317+
});
318+
319+
it('bumps dependents with catalog: deps', () => {
320+
const { bumpInfo, originalPackageInfos } = gatherBumpInfoWrapper({
321+
// Say there's a catalog like this:
322+
// catalog:
323+
// pkg-1: workspace:~
324+
// pkg-2: workspace:^1.0.0
325+
packageFolders: {
326+
'pkg-1': { version: '1.0.0' },
327+
// both of these are detected as dependent bumps but currently missed from changelog
328+
'pkg-2': { version: '1.0.0', dependencies: { 'pkg-1': 'catalog:' } },
329+
'pkg-3': { version: '1.0.0', dependencies: { 'pkg-2': 'catalog:' } },
330+
},
331+
repoOptions: { bumpDeps: true },
332+
changes: ['pkg-1'],
333+
});
334+
335+
const { packageInfos, modifiedPackages, calculatedChangeTypes, dependentChangedBy } = bumpInfo;
336+
expect(modifiedPackages).toEqual(new Set(['pkg-1', 'pkg-2', 'pkg-3']));
337+
expect(calculatedChangeTypes).toEqual({ 'pkg-1': 'minor', 'pkg-2': 'patch', 'pkg-3': 'patch' });
338+
expect(dependentChangedBy).toEqual({
339+
// Current behavior: dependentChangedBy misses all catalog: deps pointing to workspace: versions
340+
// https://github.com/microsoft/beachball/issues/981
341+
// 'pkg-2': new Set(['pkg-1']),
342+
// 'pkg-3': new Set(['pkg-2']),
343+
});
344+
345+
// All the dependent packages are bumped despite the catalog: dep specs
346+
expect(packageInfos['pkg-1'].version).toBe('1.1.0');
347+
// catalog: ranges aren't changed
348+
expect(packageInfos['pkg-2']).toEqual({ ...originalPackageInfos['pkg-2'], version: '1.0.1' });
349+
expect(packageInfos['pkg-3']).toEqual({ ...originalPackageInfos['pkg-3'], version: '1.0.1' });
318350
});
319351

320352
it('bumps to prerelease using prefix, and uses prerelease version for dependents', () => {

src/__tests__/bump/bumpMinSemverRange.test.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,74 +3,74 @@ import { bumpMinSemverRange } from '../../bump/bumpMinSemverRange';
33

44
describe('bumpMinSemverRange', () => {
55
it('preserves *', () => {
6-
const result = bumpMinSemverRange('1.0.0', '*');
6+
const result = bumpMinSemverRange({ newVersion: '1.0.0', currentRange: '*' });
77
expect(result).toBe('*');
88
});
99

1010
it('preserves file: protocol with relative path', () => {
11-
const result = bumpMinSemverRange('1.0.0', 'file:../local-package');
11+
const result = bumpMinSemverRange({ newVersion: '1.0.0', currentRange: 'file:../local-package' });
1212
expect(result).toBe('file:../local-package');
1313
});
1414

1515
it('preserves file: protocol with absolute path', () => {
16-
const result = bumpMinSemverRange('1.0.0', 'file:/absolute/path/to/package');
16+
const result = bumpMinSemverRange({ newVersion: '1.0.0', currentRange: 'file:/absolute/path/to/package' });
1717
expect(result).toBe('file:/absolute/path/to/package');
1818
});
1919

2020
it('preserves catalog: protocol', () => {
21-
let result = bumpMinSemverRange('1.0.0', 'catalog:');
21+
let result = bumpMinSemverRange({ newVersion: '1.0.0', currentRange: 'catalog:' });
2222
expect(result).toBe('catalog:');
23-
result = bumpMinSemverRange('1.0.0', 'catalog:foo');
23+
result = bumpMinSemverRange({ newVersion: '1.0.0', currentRange: 'catalog:foo' });
2424
expect(result).toBe('catalog:foo');
2525
});
2626

2727
it.each(['~', '^'])('preserves %s and bumps to new version', prefix => {
28-
const result = bumpMinSemverRange('1.3.0', `${prefix}1.2.0`);
28+
const result = bumpMinSemverRange({ newVersion: '1.3.0', currentRange: `${prefix}1.2.0` });
2929
expect(result).toBe(`${prefix}1.3.0`);
3030
});
3131

3232
it('returns range from new version to next major with >=', () => {
33-
const result = bumpMinSemverRange('1.3.0', '>=1.2.0 <2.0.0');
33+
const result = bumpMinSemverRange({ newVersion: '1.3.0', currentRange: '>=1.2.0 <2.0.0' });
3434
expect(result).toBe('>=1.3.0 <2.0.0');
3535
});
3636

3737
it('returns range from new version to next major with >', () => {
38-
const result = bumpMinSemverRange('1.3.0', '>1.2.0 <2.0.0');
38+
const result = bumpMinSemverRange({ newVersion: '1.3.0', currentRange: '>1.2.0 <2.0.0' });
3939
expect(result).toBe('>=1.3.0 <2.0.0');
4040
});
4141

4242
it('returns range from new version to next major with -', () => {
43-
const result = bumpMinSemverRange('1.3.0', '1.2.0 - 2.0.0');
43+
const result = bumpMinSemverRange({ newVersion: '1.3.0', currentRange: '1.2.0 - 2.0.0' });
4444
expect(result).toBe('1.3.0 - 2.0.0');
4545
});
4646

4747
it.each(['workspace:*', 'workspace:~', 'workspace:^'])('preserves %s', workspaceVersion => {
48-
const result = bumpMinSemverRange('1.3.0', workspaceVersion);
48+
const result = bumpMinSemverRange({ newVersion: '1.3.0', currentRange: workspaceVersion });
4949
expect(result).toBe(workspaceVersion);
5050
});
5151

5252
it('bumps workspace:~x.y.z to workspace range with new version', () => {
53-
const result = bumpMinSemverRange('1.2.1', 'workspace:~1.2.0');
53+
const result = bumpMinSemverRange({ newVersion: '1.2.1', currentRange: 'workspace:~1.2.0' });
5454
expect(result).toBe('workspace:~1.2.1');
5555
});
5656

5757
it('bumps workspace:^x.y.z to workspace range with new version', () => {
58-
const result = bumpMinSemverRange('1.3.0', 'workspace:^1.2.0');
58+
const result = bumpMinSemverRange({ newVersion: '1.3.0', currentRange: 'workspace:^1.2.0' });
5959
expect(result).toBe('workspace:^1.3.0');
6060
});
6161

6262
it('uses the new version for exact version match', () => {
63-
const result = bumpMinSemverRange('1.3.0', '1.2.0');
63+
const result = bumpMinSemverRange({ newVersion: '1.3.0', currentRange: '1.2.0' });
6464
expect(result).toBe('1.3.0');
6565
});
6666

6767
it('uses the new version if unknown non-semver format', () => {
68-
const result = bumpMinSemverRange('1.3.0', '#1.2.0');
68+
const result = bumpMinSemverRange({ newVersion: '1.3.0', currentRange: '#1.2.0' });
6969
expect(result).toBe('1.3.0');
7070
});
7171

7272
it('preserves unrecognized range if new version satisfies it', () => {
73-
const result = bumpMinSemverRange('1.3.0', '1');
73+
const result = bumpMinSemverRange({ newVersion: '1.3.0', currentRange: '1' });
7474
expect(result).toBe('1');
7575
});
7676
});

0 commit comments

Comments
 (0)