Skip to content

Commit 63b6798

Browse files
authored
Clarify some of the bumping logic (#1101)
1 parent 0644c26 commit 63b6798

12 files changed

Lines changed: 286 additions & 65 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": "Clarify some of the bumping logic",
4+
"packageName": "beachball",
5+
"email": "elcraig@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}

src/__e2e__/bump.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ describe('version bumping', () => {
3737
repo = undefined;
3838
});
3939

40-
it('bumps only packages with change files', async () => {
40+
it('bumps only packages with change files with bumpDeps: false', async () => {
4141
const monorepo: RepoFixture['folders'] = {
4242
packages: {
4343
'pkg-1': { version: '1.0.0' },
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { describe, it, expect, afterEach, jest, beforeAll, afterAll } from '@jest/globals';
2+
import { setDependentVersions } from '../../bump/setDependentVersions';
3+
import { makePackageInfos, type PartialPackageInfos } from '../../__fixtures__/packageInfos';
4+
import { consideredDependencies } from '../../types/PackageInfo';
5+
6+
type PartialBumpInfo = Parameters<typeof setDependentVersions>[0];
7+
8+
describe('setDependentVersions', () => {
9+
let consoleLogSpy: jest.SpiedFunction<typeof console.log>;
10+
11+
/**
12+
* Make the bump info. Package versions should reflect any bumps applied.
13+
*
14+
* Unless otherwise specified, assumes all packages are in scope and have been modified.
15+
* Realistically this would have been determined based on change types and `dependentChangeTypes`.
16+
*/
17+
function makeBumpInfo(
18+
params: { packageInfos: PartialPackageInfos } & Partial<Omit<PartialBumpInfo, 'packageInfos'>>
19+
): PartialBumpInfo {
20+
const { packageInfos, ...rest } = params;
21+
return {
22+
packageInfos: makePackageInfos(packageInfos),
23+
scopedPackages: new Set(Object.keys(packageInfos)),
24+
modifiedPackages: new Set(Object.keys(packageInfos)),
25+
...rest,
26+
};
27+
}
28+
29+
beforeAll(() => {
30+
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
31+
jest.spyOn(console, 'info').mockImplementation(() => undefined);
32+
});
33+
34+
afterEach(() => {
35+
jest.clearAllMocks();
36+
});
37+
38+
afterAll(() => {
39+
jest.restoreAllMocks();
40+
});
41+
42+
it('returns empty object when no packages are in scope', () => {
43+
const bumpInfo = makeBumpInfo({
44+
packageInfos: { 'pkg-a': {} },
45+
scopedPackages: new Set(),
46+
// not sure if this would be included if out of scope, but check in case
47+
modifiedPackages: new Set(['pkg-a']),
48+
});
49+
50+
const result = setDependentVersions(bumpInfo, {});
51+
52+
expect(result).toEqual({});
53+
});
54+
55+
it('returns empty object when no packages are modified', () => {
56+
const bumpInfo = makeBumpInfo({
57+
packageInfos: { 'pkg-a': {} },
58+
modifiedPackages: new Set(),
59+
});
60+
61+
const result = setDependentVersions(bumpInfo, {});
62+
63+
expect(result).toEqual({});
64+
});
65+
66+
it('bumps dependency version range when dependency is bumped to unsatisfied range', () => {
67+
const bumpInfo = makeBumpInfo({
68+
packageInfos: {
69+
// assume this had change type major
70+
'pkg-a': { version: '2.0.0' },
71+
// and this would be bumped for dependentChangeType patch
72+
'pkg-b': { version: '1.0.1', dependencies: { 'pkg-a': '^1.0.0' } },
73+
},
74+
});
75+
76+
const result = setDependentVersions(bumpInfo, {});
77+
78+
expect(bumpInfo.packageInfos['pkg-b'].dependencies!['pkg-a']).toBe('^2.0.0');
79+
expect(result).toEqual({ 'pkg-b': new Set(['pkg-a']) });
80+
});
81+
82+
it('bumps dependency range even if already satisfied', () => {
83+
const bumpInfo = makeBumpInfo({
84+
packageInfos: {
85+
// assume this had change type minor
86+
'pkg-a': { version: '1.1.0' },
87+
// and this would be bumped for dependentChangeType patch
88+
'pkg-b': { version: '1.0.1', dependencies: { 'pkg-a': '^1.0.0' } },
89+
'pkg-c': { version: '1.0.1', dependencies: { 'pkg-a': '~1.0.0' } },
90+
},
91+
});
92+
93+
const result = setDependentVersions(bumpInfo, {});
94+
95+
expect(bumpInfo.packageInfos['pkg-b'].dependencies!['pkg-a']).toBe('^1.1.0');
96+
expect(bumpInfo.packageInfos['pkg-c'].dependencies!['pkg-a']).toBe('~1.1.0');
97+
expect(result).toEqual({ 'pkg-b': new Set(['pkg-a']), 'pkg-c': new Set(['pkg-a']) });
98+
});
99+
100+
it.each(consideredDependencies)('handles %s', depType => {
101+
const bumpInfo = makeBumpInfo({
102+
packageInfos: {
103+
'pkg-a': { version: '1.1.0' },
104+
'pkg-b': { version: '1.0.1', [depType]: { 'pkg-a': '^1.0.0' } },
105+
},
106+
});
107+
108+
const result = setDependentVersions(bumpInfo, {});
109+
110+
expect(bumpInfo.packageInfos['pkg-b'][depType]!['pkg-a']).toBe('^1.1.0');
111+
expect(result).toEqual({ 'pkg-b': new Set(['pkg-a']) });
112+
113+
// doesn't log by default
114+
expect(consoleLogSpy).not.toHaveBeenCalled();
115+
});
116+
117+
it('handles (ignores) external dependencies', () => {
118+
const bumpInfo = makeBumpInfo({
119+
packageInfos: {
120+
'pkg-a': { version: '1.0.0', dependencies: { external: '^1.0.0' } },
121+
},
122+
});
123+
124+
const result = setDependentVersions(bumpInfo, {});
125+
126+
expect(bumpInfo.packageInfos['pkg-a'].dependencies!['external']).toBe('^1.0.0');
127+
expect(result).toEqual({});
128+
});
129+
130+
it('handles multiple dependencies being bumped', () => {
131+
const bumpInfo = makeBumpInfo({
132+
packageInfos: {
133+
'pkg-a': { version: '1.0.1' },
134+
'pkg-b': { version: '1.1.0' },
135+
'pkg-c': { version: '1.0.1', dependencies: { 'pkg-a': '^1.0.0' }, peerDependencies: { 'pkg-b': '^1.0.0' } },
136+
},
137+
});
138+
139+
const result = setDependentVersions(bumpInfo, {});
140+
141+
expect(bumpInfo.packageInfos['pkg-c'].dependencies!['pkg-a']).toBe('^1.0.1');
142+
expect(bumpInfo.packageInfos['pkg-c'].peerDependencies!['pkg-b']).toBe('^1.1.0');
143+
expect(result).toEqual({ 'pkg-c': new Set(['pkg-a', 'pkg-b']) });
144+
});
145+
146+
it('logs when verbose is true', () => {
147+
const bumpInfo = makeBumpInfo({
148+
packageInfos: {
149+
'pkg-a': { version: '2.0.0' },
150+
'pkg-b': { version: '1.0.1', dependencies: { 'pkg-a': '^1.0.0' } },
151+
},
152+
});
153+
154+
setDependentVersions(bumpInfo, { verbose: true });
155+
156+
expect(consoleLogSpy).toHaveBeenCalledWith('pkg-b needs to be bumped because pkg-a ^1.0.0 -> ^2.0.0');
157+
});
158+
159+
// Documenting this issue
160+
// https://github.com/microsoft/beachball/issues/981
161+
it('currently misses bumps of workspace: ranges', () => {
162+
const bumpInfo = makeBumpInfo({
163+
packageInfos: {
164+
'pkg-a': { version: '1.1.0' },
165+
// * means an exact dependency and should definitely cause a bump
166+
'pkg-b': { version: '1.0.1', dependencies: { 'pkg-a': 'workspace:*' } },
167+
},
168+
});
169+
170+
const result = setDependentVersions(bumpInfo, {});
171+
172+
expect(bumpInfo.packageInfos['pkg-b'].dependencies!['pkg-a']).toBe('workspace:*');
173+
expect(result).toEqual({}); // should have pkg-b depending on pkg-a
174+
});
175+
});

src/__tests__/bump/updateRelatedChangeType.test.ts

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { ChangeFileInfo, ChangeInfo, ChangeType } from '../../types/ChangeI
44
import { PartialPackageInfos, makePackageInfos } from '../../__fixtures__/packageInfos';
55
import { PackageGroups } from '../../types/PackageInfo';
66
import { getDependentsForPackages } from '../../bump/getDependentsForPackages';
7+
import type { BeachballOptions } from '../../types/BeachballOptions';
78

89
type RelatedChangeTypeParams = Omit<Parameters<typeof updateRelatedChangeType>[0], 'change'>;
910

@@ -12,22 +13,24 @@ describe('updateRelatedChangeType', () => {
1213
* Call `updateRelatedChangeType` once for each of `changes`.
1314
* Returns the updated bump info.
1415
*/
15-
function callUpdateRelatedChangeType(options: {
16-
changes: Array<Pick<ChangeInfo, 'packageName' | 'type' | 'dependentChangeType'>>;
17-
/**
18-
* All the packages used in this fixture.
19-
* Must include any dependencies (all versions are 1.0.0).
20-
*/
21-
packages: PartialPackageInfos;
22-
/**
23-
* Initial calculated change types before updates. This is **required** if `packageGroups`
24-
* is specified (since the initial calculation is complex) but otherwise a default can be
25-
* calculated from `changes`.
26-
*/
27-
calculatedChangeTypes?: { [packageName: string]: ChangeType };
28-
packageGroups?: PackageGroups;
29-
}) {
30-
const { packages, packageGroups } = options;
16+
function callUpdateRelatedChangeType(
17+
options: Partial<Pick<BeachballOptions, 'bumpDeps'>> & {
18+
changes: Array<Pick<ChangeInfo, 'packageName' | 'type' | 'dependentChangeType'>>;
19+
/**
20+
* All the packages used in this fixture.
21+
* Must include any dependencies (all versions are 1.0.0).
22+
*/
23+
packages: PartialPackageInfos;
24+
/**
25+
* Initial calculated change types before updates. This is **required** if `packageGroups`
26+
* is specified (since the initial calculation is complex) but otherwise a default can be
27+
* calculated from `changes`.
28+
*/
29+
calculatedChangeTypes?: { [packageName: string]: ChangeType };
30+
packageGroups?: PackageGroups;
31+
}
32+
) {
33+
const { packages, packageGroups, bumpDeps = true } = options;
3134

3235
if (packageGroups && !options.calculatedChangeTypes) {
3336
throw new Error('calculatedChangeTypes must be specified if packageGroups is used');
@@ -51,8 +54,9 @@ describe('updateRelatedChangeType', () => {
5154
},
5255
// Dependents are confusing to reason about directly (or specify in fixtures) since they're
5356
// backwards from dependencies, so just reuse the actual helper that calculates them
54-
dependents: getDependentsForPackages({ packageInfos, scopedPackages: new Set(Object.keys(packageInfos)) }),
55-
bumpDeps: true,
57+
dependents: bumpDeps
58+
? getDependentsForPackages({ packageInfos, scopedPackages: new Set(Object.keys(packageInfos)) })
59+
: undefined,
5660
};
5761

5862
for (const change of changes) {
@@ -77,6 +81,19 @@ describe('updateRelatedChangeType', () => {
7781
});
7882
});
7983

84+
it('does not bump dependents with bumpDeps: false', () => {
85+
const bumpInfo = callUpdateRelatedChangeType({
86+
bumpDeps: false,
87+
changes: [{ packageName: 'foo', type: 'patch', dependentChangeType: 'minor' }],
88+
packages: {
89+
bar: { dependencies: { foo: '1.0.0' } },
90+
foo: {},
91+
},
92+
});
93+
94+
expect(bumpInfo.calculatedChangeTypes).toEqual({ foo: 'patch' });
95+
});
96+
8097
it("respects bumped dependent package's own change type if higher than dependentChangeType", () => {
8198
const bumpInfo = callUpdateRelatedChangeType({
8299
changes: [

src/bump/bumpInPlace.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,9 @@ import { ChangeType } from '../types/ChangeInfo';
1414
*/
1515
export function bumpInPlace(bumpInfo: BumpInfo, options: BeachballOptions): void {
1616
const { bumpDeps } = options;
17-
const { calculatedChangeTypes, changeFileChangeInfos, modifiedPackages } = bumpInfo;
18-
19-
// pass 1: figure out all the change types for all the packages taking into account the bumpDeps option and version groups
20-
const dependents = bumpDeps ? getDependentsForPackages(bumpInfo) : {};
17+
// Precondition (pass 1): calculatedChangeTypes includes ONLY changes direct from the change files
18+
// (no dependents or groups)
19+
const { calculatedChangeTypes, changeFileChangeInfos } = bumpInfo;
2120

2221
// TODO: when we do "locked", or "lock step" versioning, we could simply skip setting grouped change types
2322
// - set the version for all packages in the group in (bumpPackageInfoVersion())
@@ -43,17 +42,19 @@ export function bumpInPlace(bumpInfo: BumpInfo, options: BeachballOptions): void
4342
}
4443
}
4544

46-
// Calculate change types for packages and dependencies
45+
// Pass 3: Calculate change types for dependents and groups.
46+
// TODO: fix weird behavior - https://github.com/microsoft/beachball/issues/620
47+
const dependents = bumpDeps ? getDependentsForPackages(bumpInfo) : undefined;
4748
for (const { change } of changeFileChangeInfos) {
48-
updateRelatedChangeType({ change, bumpInfo, dependents, bumpDeps });
49+
updateRelatedChangeType({ change, bumpInfo, dependents });
4950
}
5051

5152
// pass 3: actually bump the packages in the bumpInfo in memory (no disk writes at this point)
52-
Object.keys(calculatedChangeTypes).forEach(pkgName => {
53+
for (const pkgName of Object.keys(calculatedChangeTypes)) {
5354
bumpPackageInfoVersion(pkgName, bumpInfo, options);
54-
});
55+
}
5556

56-
// step 4: Bump all the dependencies packages
57+
// step 4: Bump all the dependency version ranges and collect dependentChangedBy for the changelog
58+
// (also add any modifiedPackages not previously detected--this should only happen if bumpDeps was false)
5759
bumpInfo.dependentChangedBy = setDependentVersions(bumpInfo, options);
58-
Object.keys(bumpInfo.dependentChangedBy).forEach(pkg => modifiedPackages.add(pkg));
5960
}

src/bump/setDependentVersions.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,19 @@ import { bumpMinSemverRange } from './bumpMinSemverRange';
55

66
/**
77
* Go through the deps of each package and bump the version range for in-repo deps if needed.
8+
* Prior to calling this, it's expected that:
9+
* - package versions in `bumpInfo.packageInfos` have been bumped per change files and dependentChangeTypes
10+
* - `bumpInfo.modifiedPackages` contains all packages whose version has been bumped
811
*
9-
* **This mutates dep versions in `packageInfos`** as well as returning `dependentChangedBy`.
12+
* **This mutates dependency versions in `packageInfos`** and might add to `bumpInfo.modifiedPackages`.
13+
* Probably the only case where it will change `modifiedPackages` is if `BeachballOptions.bumpDeps` is false
14+
* (or if this is being called by `sync` which didn't previously bump dependents).
1015
*/
1116
export function setDependentVersions(
12-
bumpInfo: Pick<BumpInfo, 'packageInfos' | 'scopedPackages'>,
17+
bumpInfo: Pick<BumpInfo, 'packageInfos' | 'scopedPackages' | 'modifiedPackages'>,
1318
options: Pick<BeachballOptions, 'verbose'>
1419
): BumpInfo['dependentChangedBy'] {
15-
const { packageInfos, scopedPackages } = bumpInfo;
20+
const { packageInfos, scopedPackages, modifiedPackages } = bumpInfo;
1621
const { verbose } = options;
1722
const dependentChangedBy: BumpInfo['dependentChangedBy'] = {};
1823

@@ -26,8 +31,11 @@ export function setDependentVersions(
2631

2732
for (const [dep, existingVersionRange] of Object.entries(deps)) {
2833
const depPackage = packageInfos[dep];
29-
if (!depPackage) {
30-
continue; // external dependency
34+
// TODO: should this use the initial modifiedPackages rather than the possibly-updated one?
35+
// (considering updates could introduce order sensitivity, though the old logic that didn't
36+
// check modifiedPackages at all also had that issue)
37+
if (!depPackage || !modifiedPackages.has(dep)) {
38+
continue; // external dependency or not modified
3139
}
3240

3341
const bumpedVersionRange = bumpMinSemverRange(depPackage.version, existingVersionRange);
@@ -38,6 +46,12 @@ export function setDependentVersions(
3846

3947
dependentChangedBy[pkgName] ??= new Set<string>();
4048
dependentChangedBy[pkgName].add(dep);
49+
50+
// Unless bumpDeps was false, the package should have been added to modifiedPackages
51+
// by updateRelatedChangeType plus bumpPackageInfoVersion, but to be safe we add it here too.
52+
// TODO: fix behavior - https://github.com/microsoft/beachball/issues/620
53+
modifiedPackages.add(pkgName);
54+
4155
if (verbose) {
4256
console.log(
4357
`${pkgName} needs to be bumped because ${dep} ${existingVersionRange} -> ${bumpedVersionRange}`

0 commit comments

Comments
 (0)