Skip to content

Commit d234477

Browse files
authored
fix: improve algorithm to detect dependency changes (#74)
* fix: support merge workflows in generateNotes * fix: misalignment between manifest and notes * test: add failing missing dependency test case * fix: cyclic dependency errors * refactor: add helper to find highest release type
1 parent bb2ea25 commit d234477

File tree

8 files changed

+450
-196
lines changed

8 files changed

+450
-196
lines changed

lib/createInlinePluginCreator.js

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,27 @@ function createInlinePluginCreator(packages, multiContext, synchronizer, flags)
107107
pkg._analyzed = true;
108108
await waitForAll("_analyzed");
109109

110-
// Make sure type is "patch" if the package has any deps that have changed.
111-
pkg._nextType = resolveReleaseType(pkg, flags.deps.bump, flags.deps.release);
110+
//go in rounds to check for dependency changes that impact the release type until no changes
111+
//are found in any of the packages. Doing this in rounds will have the changes "bubble-up" in
112+
//the dependency graph until all have been processed.
113+
114+
let round = 0;
115+
let stable = false;
116+
117+
while (!stable) {
118+
const signal = "_depCheck" + round++;
119+
120+
//estimate the type of update for the package
121+
const nextType = resolveReleaseType(pkg, flags.deps.bump, flags.deps.release);
122+
123+
//indicate if it changed
124+
pkg[signal] = pkg._nextType === nextType ? "stable" : "changed";
125+
pkg._nextType = nextType;
126+
127+
await waitForAll(signal);
128+
129+
stable = packages.every((p) => p[signal] === "stable");
130+
}
112131

113132
debug("commits analyzed: %s", pkg.name);
114133
debug("release type: %s", pkg._nextType);

lib/updateDeps.js

Lines changed: 44 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -124,58 +124,67 @@ const _nextPreHighestVersion = (latestTag, lastVersion, pkgPreRelease) => {
124124
return bumpFromTags ? getHighestVersion(bumpFromLast, bumpFromTags) : bumpFromLast;
125125
};
126126

127+
/**
128+
* Returns the 'highest' type of release update, major > minor > patch > undefined.
129+
* @param {...string} releaseTypes types (patch | minor | major | undefined) of which the highest to return.
130+
* @returns {string} release type considered highest
131+
*/
132+
const getHighestReleaseType = (...releaseTypes) =>
133+
["major", "minor", "patch"].find((type) => releaseTypes.includes(type));
134+
127135
/**
128136
* Resolve package release type taking into account the cascading dependency update.
129137
*
130138
* @param {Package} pkg Package object.
131139
* @param {string|undefined} bumpStrategy Dependency resolution strategy: override, satisfy, inherit.
132140
* @param {string|undefined} releaseStrategy Release type triggered by deps updating: patch, minor, major, inherit.
133-
* @param {Package[]} ignore=[] Packages to ignore (to prevent infinite loops when traversing graphs of dependencies).
134141
* @returns {string|undefined} Resolved release type.
135142
* @internal
136143
*/
137-
const resolveReleaseType = (pkg, bumpStrategy = "override", releaseStrategy = "patch", ignore = []) => {
138-
//make sure any dependency changes are resolved before returning the release type
139-
if (!pkg._depsResolved) {
140-
//create a list of dependencies that require change
141-
pkg._depsChanged = pkg.localDeps
142-
.filter((d) => !ignore.includes(d))
143-
.filter((d) => needsDependencyUpdate(pkg, d, bumpStrategy, releaseStrategy, [pkg, ...ignore]));
144-
145-
//get the (preliminary) release type of the package based on release strategy (and analyzed changed dependencies)
146-
pkg._nextType = getDependentRelease(pkg, releaseStrategy);
147-
148-
//indicate that all deps are resolved (fixates the next type and depsChanged)
149-
pkg._depsResolved = ignore.length === 0;
144+
const resolveReleaseType = (pkg, bumpStrategy = "override", releaseStrategy = "patch") => {
145+
// Define release type for dependent package if any of its deps changes.
146+
// `patch`, `minor`, `major` — strictly declare the release type that occurs when any dependency is updated.
147+
// `inherit` — applies the "highest" release of updated deps to the package.
148+
// For example, if any dep has a breaking change, `major` release will be applied to the all dependants up the chain.
149+
150+
//create a list of dependencies that require change to the manifest
151+
pkg._depsChanged = pkg.localDeps.filter((d) => needsDependencyUpdate(pkg, d, bumpStrategy));
152+
153+
//check if any dependencies have changed. If not return the current type of release
154+
if (
155+
!pkg._lastRelease || //not released yet
156+
pkg._depsChanged.length === 0 || //no deps available
157+
pkg._depsChanged.every((dep) => dep._lastRelease && !dep._nextType) //no new deps or deps upgraded
158+
)
159+
return pkg._nextType;
160+
161+
//find highest release type if strategy is inherit, starting of type set by commit analyzer
162+
if (releaseStrategy === "inherit") {
163+
return getHighestReleaseType(pkg._nextType, ...pkg._depsChanged.map((d) => d._nextType));
150164
}
151165

152-
return pkg._nextType;
166+
//set to highest of commit analyzer found change and releaseStrategy
167+
//releaseStrategy of major could override local update of minor
168+
return getHighestReleaseType(pkg._nextType, releaseStrategy);
153169
};
154170

155171
/**
156172
* Indicates if the manifest file requires a change for the given dependency
157173
* @param {Package} pkg Package object.
174+
* @param {Package} dependency dependency to check
158175
* @param {string|undefined} bumpStrategy Dependency resolution strategy: override, satisfy, inherit.
159-
* @param {string|undefined} releaseStrategy Release type triggered by deps updating: patch, minor, major, inherit.
160-
* @param {Package[]} ignore Packages to ignore (to prevent infinite loops).
176+
* @returns {boolean } true if dependency needs to change
161177
*/
162-
163-
const needsDependencyUpdate = (pkg, dependency, bumpStrategy, releaseStrategy, ignore) => {
178+
const needsDependencyUpdate = (pkg, dependency, bumpStrategy) => {
164179
//get last release of dependency
165180
const depLastVersion = dependency._lastRelease && dependency._lastRelease.version;
166181

167-
// 3. check if dependency was released before (if not, this is assumed to be a new package + dependency)
182+
//Check if dependency was released before (if not, this is assumed to be a new package + dependency)
168183
const wasReleased = depLastVersion !== undefined;
169184
if (!wasReleased) return true; //new packages always require a package re-release
170185

171-
//get nextType of the dependency (recursion occurs here!)
172-
// Has changed if...
173-
// 1. Any local dep package itself triggered changed
174-
// 2. Any local dep package has local deps that triggered change.
175-
const depNextType = resolveReleaseType(dependency, bumpStrategy, releaseStrategy, ignore);
176-
177186
//get estimated next version of dependency (which is lastVersion if no change expected)
178-
const depNextVersion = depNextType
187+
const depNextVersion = dependency._nextType
179188
? dependency._preRelease
180189
? getNextPreVersion(dependency)
181190
: getNextVersion(dependency)
@@ -185,7 +194,7 @@ const needsDependencyUpdate = (pkg, dependency, bumpStrategy, releaseStrategy, i
185194
const { dependencies = {}, devDependencies = {}, peerDependencies = {}, optionalDependencies = {} } = pkg.manifest;
186195
const scopes = [dependencies, devDependencies, peerDependencies, optionalDependencies];
187196

188-
// 4. Check if the manifest dependency rules warrants an update (in any of the dependency scopes)
197+
//Check if the manifest dependency rules warrants an update (in any of the dependency scopes)
189198
const requireUpdate = scopes.some((scope) =>
190199
manifestUpdateNecessary(scope, dependency.name, depNextVersion, bumpStrategy)
191200
);
@@ -227,6 +236,9 @@ const manifestUpdateNecessary = (scope, name, nextVersion, bumpStrategy) => {
227236
* @internal
228237
*/
229238
const resolveNextVersion = (currentVersion, nextVersion, bumpStrategy = "override") => {
239+
//no change...
240+
if (currentVersion === nextVersion) return currentVersion;
241+
230242
// Check the next pkg version against its current references.
231243
// If it matches (`*` matches to any, `1.1.0` matches `1.1.x`, `1.5.0` matches to `^1.0.0` and so on)
232244
// release will not be triggered, if not `override` strategy will be applied instead.
@@ -256,56 +268,16 @@ const resolveNextVersion = (currentVersion, nextVersion, bumpStrategy = "overrid
256268
return nextVersion;
257269
};
258270

259-
/**
260-
* Get dependent release type by analyzing the current nextType and changed dependencies
261-
* @param {Package} pkg The package to determine next type of release of
262-
* @param {string} releaseStrategy Release type triggered by deps updating: patch, minor, major, inherit.
263-
* @returns {string|undefined} Returns the highest release type if found, undefined otherwise
264-
* @internal
265-
*/
266-
const getDependentRelease = (pkg, releaseStrategy) => {
267-
const severityOrder = ["patch", "minor", "major"];
268-
269-
// Define release type for dependent package if any of its deps changes.
270-
// `patch`, `minor`, `major` — strictly declare the release type that occurs when any dependency is updated.
271-
// `inherit` — applies the "highest" release of updated deps to the package.
272-
// For example, if any dep has a breaking change, `major` release will be applied to the all dependants up the chain.
273-
274-
//return type set by commit analyzer if no deps changed
275-
if (
276-
!pkg._lastRelease || //new package
277-
!pkg._depsChanged || //no deps analyzed
278-
pkg._depsChanged.length === 0 || //no deps available
279-
pkg._depsChanged.every((dep) => !dep._nextType && dep._lastRelease) //no new deps or deps upgraded
280-
)
281-
return pkg._nextType;
282-
283-
if (releaseStrategy === "inherit") {
284-
//find highest release type if strategy is inherit, starting of type set by commit analyzer
285-
return pkg._depsChanged.reduce((maxReleaseType, dependency) => {
286-
return severityOrder.indexOf(dependency._nextType) > severityOrder.indexOf(maxReleaseType)
287-
? dependency._nextType
288-
: maxReleaseType;
289-
}, pkg._nextType);
290-
}
291-
292-
//return highest of commit analyzer found change and releaseStrategy
293-
//releaseStrategy of major could override local update of minor
294-
return severityOrder.indexOf(pkg._nextType) > severityOrder.indexOf(releaseStrategy)
295-
? pkg._nextType
296-
: releaseStrategy;
297-
};
298-
299271
/**
300272
* Update pkg deps.
301273
*
302274
* @param {Package} pkg The package this function is being called on.
275+
* @param {boolean} writeOut Commit the package to the file store (set to false to suppres)
303276
* @returns {undefined}
304277
* @internal
305278
*/
306-
const updateManifestDeps = (pkg) => {
279+
const updateManifestDeps = (pkg, writeOut = true) => {
307280
const { manifest, path } = pkg;
308-
const { indent, trailingWhitespace } = recognizeFormat(manifest.__contents__);
309281

310282
// Loop through changed deps to verify release consistency.
311283
pkg._depsChanged.forEach((dependency) => {
@@ -329,11 +301,12 @@ const updateManifestDeps = (pkg) => {
329301
});
330302
});
331303

332-
if (!auditManifestChanges(manifest, path)) {
304+
if (!writeOut || !auditManifestChanges(manifest, path)) {
333305
return;
334306
}
335307

336308
// Write package.json back out.
309+
const { indent, trailingWhitespace } = recognizeFormat(manifest.__contents__);
337310
writeFileSync(path, JSON.stringify(manifest, null, indent) + trailingWhitespace);
338311
};
339312

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "msr-test-yarn",
3+
"author": "Joost Reuzel",
4+
"version": "0.0.0-semantically-released",
5+
"private": true,
6+
"license": "0BSD",
7+
"engines": {
8+
"node": ">=8.3"
9+
},
10+
"workspaces": [
11+
"packages/*"
12+
],
13+
"release": {
14+
"plugins": [
15+
"@semantic-release/commit-analyzer",
16+
"@semantic-release/release-notes-generator"
17+
],
18+
"noCi": true
19+
}
20+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"name": "msr-test-a",
3+
"version": "1.0.0",
4+
"dependencies": {
5+
"msr-test-b": "1.0.0"
6+
}
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"name": "msr-test-b",
3+
"version": "1.0.0",
4+
"dependencies": {
5+
"msr-test-a": "1.0.0"
6+
}
7+
}

test/lib/multiSemanticRelease.test.js

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1060,4 +1060,99 @@ describe("multiSemanticRelease()", () => {
10601060
message: expect.stringMatching("Package peerDependencies must be object"),
10611061
});
10621062
});
1063+
1064+
test("Changes in packages with mutual dependency", async () => {
1065+
// Create Git repo.
1066+
const cwd = gitInit();
1067+
// Initial commit.
1068+
copyDirectory(`test/fixtures/yarnWorkspacesMutualDependency/`, cwd);
1069+
const sha1 = gitCommitAll(cwd, "feat: Initial release");
1070+
gitTag(cwd, "msr-test-a@1.0.0");
1071+
gitTag(cwd, "msr-test-b@1.0.0");
1072+
// Second commit.
1073+
writeFileSync(`${cwd}/packages/a/aaa.txt`, "AAA");
1074+
const sha2 = gitCommitAll(cwd, "feat(aaa): Add missing text file");
1075+
const url = gitInitOrigin(cwd);
1076+
gitPush(cwd);
1077+
1078+
// Capture output.
1079+
const stdout = new WritableStreamBuffer();
1080+
const stderr = new WritableStreamBuffer();
1081+
1082+
// Call multiSemanticRelease()
1083+
// Doesn't include plugins that actually publish.
1084+
const multiSemanticRelease = require("../../");
1085+
const result = await multiSemanticRelease(
1086+
[`packages/a/package.json`, `packages/b/package.json`],
1087+
{},
1088+
{ cwd, stdout, stderr },
1089+
{ deps: { bump: "satisfy" }, dryRun: false }
1090+
);
1091+
1092+
// Get stdout and stderr output.
1093+
const err = stderr.getContentsAsString("utf8");
1094+
expect(err).toBe(false);
1095+
const out = stdout.getContentsAsString("utf8");
1096+
expect(out).toMatch("Started multirelease! Loading 2 packages...");
1097+
expect(out).toMatch("Loaded package msr-test-a");
1098+
expect(out).toMatch("Loaded package msr-test-b");
1099+
expect(out).toMatch("Queued 2 packages! Starting release...");
1100+
expect(out).toMatch("Created tag msr-test-a@1.1.0");
1101+
expect(out).toMatch("Created tag msr-test-b@1.0.1");
1102+
expect(out).toMatch("Released 2 of 2 packages, semantically!");
1103+
1104+
const a = 0;
1105+
const b = 1;
1106+
// A.
1107+
expect(result[a].name).toBe("msr-test-a");
1108+
expect(result[a].result.lastRelease).toMatchObject({
1109+
gitHead: sha1,
1110+
gitTag: "msr-test-a@1.0.0",
1111+
version: "1.0.0",
1112+
});
1113+
expect(result[a].result.nextRelease).toMatchObject({
1114+
gitHead: sha2,
1115+
gitTag: "msr-test-a@1.1.0",
1116+
type: "minor",
1117+
version: "1.1.0",
1118+
});
1119+
expect(result[a].result.nextRelease.notes).toMatch("# msr-test-a [1.1.0]");
1120+
expect(result[a].result.nextRelease.notes).toMatch("### Features\n\n* **aaa:** Add missing text file");
1121+
expect(result[a].result.nextRelease.notes).toMatch("### Dependencies\n\n* **msr-test-b:** upgraded to 1.0.1");
1122+
1123+
// B.
1124+
expect(result[b].name).toBe("msr-test-b");
1125+
expect(result[b].result.lastRelease).toEqual({
1126+
channels: [null],
1127+
gitHead: sha1,
1128+
gitTag: "msr-test-b@1.0.0",
1129+
name: "msr-test-b@1.0.0",
1130+
version: "1.0.0",
1131+
});
1132+
expect(result[b].result.nextRelease).toMatchObject({
1133+
gitHead: sha2,
1134+
gitTag: "msr-test-b@1.0.1",
1135+
type: "patch",
1136+
version: "1.0.1",
1137+
});
1138+
expect(result[b].result.nextRelease.notes).toMatch("# msr-test-b [1.0.1]");
1139+
expect(result[b].result.nextRelease.notes).not.toMatch("### Features");
1140+
expect(result[b].result.nextRelease.notes).not.toMatch("### Bug Fixes");
1141+
expect(result[b].result.nextRelease.notes).toMatch("### Dependencies\n\n* **msr-test-a:** upgraded to 1.1.0");
1142+
1143+
// ONLY 3 times.
1144+
expect(result[2]).toBe(undefined);
1145+
1146+
// Check manifests.
1147+
expect(require(`${cwd}/packages/a/package.json`)).toMatchObject({
1148+
dependencies: {
1149+
"msr-test-b": "1.0.1",
1150+
},
1151+
});
1152+
expect(require(`${cwd}/packages/b/package.json`)).toMatchObject({
1153+
dependencies: {
1154+
"msr-test-a": "1.1.0",
1155+
},
1156+
});
1157+
});
10631158
});

0 commit comments

Comments
 (0)