diff --git a/bin/cli.js b/bin/cli.js index 6f341cb..3a8ead9 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -9,13 +9,16 @@ const cli = meow( $ multi-semantic-release Options - --sequential-init Avoid hypothetical concurrent initialization collisions. --debug Output debugging information. + --sequential-init Avoid hypothetical concurrent initialization collisions. --first-parent Apply commit filtering to current branch only. + --deps.bump Define deps version updating rule. Allowed: override, satisfy, inherit. + --deps.release Define release type for dependant package if any of its deps changes. Supported values: patch, minor, major, inherit. --help Help info. Examples - $ multi-semantic-release + $ multi-semantic-release --debug + $ multi-semantic-release --deps.bump=satisfy --deps.release=patch `, { flags: { @@ -28,6 +31,14 @@ const cli = meow( debug: { type: "boolean", }, + "deps.bump": { + type: "string", + default: "override", + }, + "deps.release": { + type: "string", + default: "patch", + }, }, } ); diff --git a/lib/createInlinePluginCreator.js b/lib/createInlinePluginCreator.js index 63fc504..21ef525 100644 --- a/lib/createInlinePluginCreator.js +++ b/lib/createInlinePluginCreator.js @@ -1,10 +1,6 @@ -const { writeFileSync } = require("fs"); const debug = require("debug")("msr:inlinePlugin"); const getCommitsFiltered = require("./getCommitsFiltered"); -const getManifest = require("./getManifest"); -const hasChangedDeep = require("./hasChangedDeep"); -const recognizeFormat = require("./recognizeFormat"); -const { get } = require("lodash"); +const { updateManifestDeps, resolveReleaseType } = require("./updateDeps"); /** * Create an inline plugin creator for a multirelease. @@ -23,43 +19,6 @@ function createInlinePluginCreator(packages, multiContext, synchronizer, flags) const { cwd } = multiContext; const { todo, waitFor, waitForAll, emit, getLucky } = synchronizer; - /** - * Update pkg deps. - * @param {Package} pkg The package this function is being called on. - * @param {string} path Path to package.json file - * @returns {undefined} - * @internal - */ - const updateManifestDeps = (pkg, path) => { - // Get and parse manifest file contents. - const manifest = getManifest(path); - const { indent, trailingWhitespace } = recognizeFormat(manifest.__contents__); - const updateDependency = (scope, name, version) => { - if (get(manifest, `${scope}.${name}`)) { - manifest[scope][name] = version; - } - }; - - // Loop through localDeps to update dependencies/devDependencies/peerDependencies in manifest. - pkg._localDeps.forEach((d) => { - // Get version of dependency. - const release = d._nextRelease || d._lastRelease; - - // Cannot establish version. - if (!release || !release.version) - throw Error(`Cannot release because dependency ${d.name} has not been released`); - - // Update version of dependency in manifest. - updateDependency("dependencies", d.name, release.version); - updateDependency("devDependencies", d.name, release.version); - updateDependency("peerDependencies", d.name, release.version); - updateDependency("optionalDependencies", d.name, release.version); - }); - - // Write package.json back out. - writeFileSync(path, JSON.stringify(manifest, null, indent) + trailingWhitespace); - }; - /** * Create an inline plugin for an individual package in a multirelease. * This is called once per package and returns the inline plugin used for semanticRelease() @@ -71,7 +30,7 @@ function createInlinePluginCreator(packages, multiContext, synchronizer, flags) */ function createInlinePlugin(pkg) { // Vars. - const { deps, plugins, dir, path, name } = pkg; + const { deps, plugins, dir, name } = pkg; /** * @var {Commit[]} List of _filtered_ commits that only apply to this package. @@ -141,7 +100,7 @@ function createInlinePluginCreator(packages, multiContext, synchronizer, flags) await waitForAll("_analyzed"); // Make sure type is "patch" if the package has any deps that have changed. - if (!pkg._nextType && hasChangedDeep(pkg._localDeps)) pkg._nextType = "patch"; + pkg._nextType = resolveReleaseType(pkg, flags.deps.bump, flags.deps.release); debug("commits analyzed: %s", pkg.name); debug("release type: %s", pkg._nextType); @@ -188,7 +147,7 @@ function createInlinePluginCreator(packages, multiContext, synchronizer, flags) await waitFor("_readyToGenerateNotes", pkg); // Update pkg deps. - updateManifestDeps(pkg, path); + updateManifestDeps(pkg); pkg._depsUpdated = true; // Vars. diff --git a/lib/hasChangedDeep.js b/lib/hasChangedDeep.js deleted file mode 100644 index 5d90deb..0000000 --- a/lib/hasChangedDeep.js +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Have a package's local deps changed? Checks recursively. - * - * @param {Package[]} packages The package with local deps to check. - * @param {Package[]} ignore=[] Packages to ignore (to prevent infinite loops). - * @returns {boolean} `true` if any deps have changed and `false` otherwise - * - * @internal - */ -function hasChangedDeep(packages, ignore = []) { - // Has changed if... - return packages - .filter((p) => ignore.indexOf(p) === -1) - .some((p) => { - // 1. Any local dep package itself has changed - if (p._nextType) return true; - // 2. Any local dep package has local deps that have changed. - else if (hasChangedDeep(p._localDeps, [...ignore, ...packages])) return true; - // Nope. - else return false; - }); -} - -// Exports. -module.exports = hasChangedDeep; diff --git a/lib/multiSemanticRelease.js b/lib/multiSemanticRelease.js index ff8150a..47fa03f 100644 --- a/lib/multiSemanticRelease.js +++ b/lib/multiSemanticRelease.js @@ -1,6 +1,5 @@ const { dirname } = require("path"); const semanticRelease = require("semantic-release"); - const { check } = require("./blork"); const getLogger = require("./getLogger"); const getSynchronizer = require("./getSynchronizer"); @@ -48,7 +47,7 @@ async function multiSemanticRelease( paths, inputOptions = {}, { cwd = process.cwd(), env = process.env, stdout = process.stdout, stderr = process.stderr } = {}, - flags = {} + flags = { deps: {} } ) { // Check params. check(paths, "paths: string[]"); diff --git a/lib/updateDeps.js b/lib/updateDeps.js new file mode 100644 index 0000000..ba50c02 --- /dev/null +++ b/lib/updateDeps.js @@ -0,0 +1,162 @@ +const { writeFileSync } = require("fs"); +const recognizeFormat = require("./recognizeFormat"); +const semver = require("semver"); + +/** + * Resolve next package version. + * + * @param {Package} pkg Package object. + * @returns {string|undefined} Next pkg version. + * @internal + */ +const getNextVersion = (pkg) => { + const lastVersion = pkg._lastRelease && pkg._lastRelease.version; + + return lastVersion && typeof pkg._nextType === "string" ? semver.inc(lastVersion, pkg._nextType) : "1.0.0"; +}; + +/** + * Resolve package release type taking into account the cascading dependency update. + * + * @param {Package} pkg Package object. + * @param {string|undefined} bumpStrategy Dependency resolution strategy: override, satisfy, inherit. + * @param {string|undefined} releaseStrategy Release type triggered by deps updating: patch, minor, major, inherit. + * @param {Package[]} ignore=[] Packages to ignore (to prevent infinite loops). + * @returns {string|undefined} Resolved release type. + * @internal + */ +const resolveReleaseType = (pkg, bumpStrategy = "override", releaseStrategy = "patch", ignore = []) => { + // NOTE This fn also updates pkg deps, so it must be invoked anyway. + const dependantReleaseType = getDependantRelease(pkg, bumpStrategy, releaseStrategy, ignore); + + // Release type found by commitAnalyzer. + if (pkg._nextType) { + return pkg._nextType; + } + + if (!dependantReleaseType) { + return undefined; + } + + pkg._nextType = releaseStrategy === "inherit" ? dependantReleaseType : releaseStrategy; + + return pkg._nextType; +}; + +/** + * Get dependant release type by recursive scanning and updating its deps. + * + * @param {Package} pkg The package with local deps to check. + * @param {string} bumpStrategy Dependency resolution strategy: override, satisfy, inherit. + * @param {string} releaseStrategy Release type triggered by deps updating: patch, minor, major, inherit. + * @param {Package[]} ignore Packages to ignore (to prevent infinite loops). + * @returns {string|undefined} Returns the highest release type if found, undefined otherwise + * @internal + */ +const getDependantRelease = (pkg, bumpStrategy, releaseStrategy, ignore) => { + const severityOrder = ["patch", "minor", "major"]; + const { _localDeps, manifest = {} } = pkg; + const { dependencies = {}, devDependencies = {}, peerDependencies = {}, optionalDependencies = {} } = manifest; + const scopes = [dependencies, devDependencies, peerDependencies, optionalDependencies]; + const bumpDependency = (scope, name, nextVersion) => { + const currentVersion = scope[name]; + if (!nextVersion || !currentVersion) { + return; + } + const resolvedVersion = resolveNextVersion(currentVersion, nextVersion, releaseStrategy); + + if (currentVersion !== resolvedVersion) { + scope[name] = resolvedVersion; + + return true; + } + }; + + // prettier-ignore + return _localDeps + .filter((p) => ignore.indexOf(p) === -1) + .reduce((releaseType, p) => { + const name = p.name; + + // Has changed if... + // 1. Any local dep package itself has changed + // 2. Any local dep package has local deps that have changed. + const nextType = resolveReleaseType(p, bumpStrategy, releaseStrategy,[...ignore, ..._localDeps]); + const nextVersion = getNextVersion(p); + const lastVersion = pkg._lastRelease && pkg._lastRelease.version; + + // 3. And this change should correspond to manifest updating rule. + const requireRelease = [ + ...scopes.map((scope) => bumpDependency(scope, name, nextVersion)), + ].some(v => v) || !lastVersion; + + return requireRelease && (severityOrder.indexOf(nextType) > severityOrder.indexOf(releaseType)) + ? nextType + : releaseType; + }, undefined); +}; + +/** + * Resolve next version of dependency. + * + * @param {string} currentVersion Current dep version + * @param {string} nextVersion Next release type: patch, minor, major + * @param {string|undefined} strategy Resolution strategy: inherit, override, satisfy + * @returns {string} Next dependency version + * @internal + */ +const resolveNextVersion = (currentVersion, nextVersion, strategy = "override") => { + if (strategy === "satisfy" && semver.satisfies(nextVersion, currentVersion)) { + return currentVersion; + } + + if (strategy === "inherit") { + const sep = "."; + const nextChunks = nextVersion.split(sep); + const currentChunks = currentVersion.split(sep); + // prettier-ignore + const resolvedChunks = currentChunks.map((chunk, i) => + nextChunks[i] + ? chunk.replace(/\d+/, nextChunks[i]) + : chunk + ); + + return resolvedChunks.join(sep); + } + + // By default next package version would be set as is for the all dependants + return nextVersion; +}; + +/** + * Update pkg deps. + * + * @param {Package} pkg The package this function is being called on. + * @param {string} strategy Dependency version updating rule + * @returns {undefined} + * @internal + */ +const updateManifestDeps = (pkg, strategy) => { + const { manifest, path } = pkg; + const { indent, trailingWhitespace } = recognizeFormat(manifest.__contents__); + + // Loop through localDeps to verify release consistency. + pkg._localDeps.forEach((d) => { + // Get version of dependency. + const release = d._nextRelease || d._lastRelease; + + // Cannot establish version. + if (!release || !release.version) + throw Error(`Cannot release because dependency ${d.name} has not been released`); + }); + + // Write package.json back out. + writeFileSync(path, JSON.stringify(manifest, null, indent) + trailingWhitespace); +}; + +module.exports = { + getNextVersion, + updateManifestDeps, + resolveReleaseType, + resolveNextVersion, +}; diff --git a/test/fixtures/yarnWorkspaces/packages/d/package.json b/test/fixtures/yarnWorkspaces/packages/d/package.json index fa64f23..222eb15 100644 --- a/test/fixtures/yarnWorkspaces/packages/d/package.json +++ b/test/fixtures/yarnWorkspaces/packages/d/package.json @@ -1,4 +1,4 @@ { "name": "msr-test-d", "version": "0.0.0" -} \ No newline at end of file +} diff --git a/test/lib/hasChangedDeep.test.js b/test/lib/hasChangedDeep.test.js deleted file mode 100644 index 14d762e..0000000 --- a/test/lib/hasChangedDeep.test.js +++ /dev/null @@ -1,95 +0,0 @@ -const hasChangedDeep = require("../../lib/hasChangedDeep"); - -// Tests. -describe("hasChangedDeep()", () => { - test("Works correctly with no deps", () => { - expect(hasChangedDeep([])).toBe(false); - }); - test("Works correctly with deps", () => { - const pkgs1 = [{ _nextType: "patch", _localDeps: [] }]; - expect(hasChangedDeep(pkgs1)).toBe(true); - const pkgs2 = [{ _nextType: undefined, _localDeps: [] }]; - expect(hasChangedDeep(pkgs2)).toBe(false); - const pkgs3 = [ - { - _nextType: undefined, - _localDeps: [ - { _nextType: false, _localDeps: [] }, - { _nextType: false, _localDeps: [] }, - ], - }, - ]; - expect(hasChangedDeep(pkgs3)).toBe(false); - const pkgs4 = [ - { - _nextType: undefined, - _localDeps: [ - { _nextType: "patch", _localDeps: [] }, - { _nextType: false, _localDeps: [] }, - ], - }, - ]; - expect(hasChangedDeep(pkgs4)).toBe(true); - const pkgs5 = [ - { - _nextType: undefined, - _localDeps: [ - { - _nextType: false, - _localDeps: [ - { _nextType: false, _localDeps: [] }, - { _nextType: false, _localDeps: [] }, - ], - }, - ], - }, - ]; - expect(hasChangedDeep(pkgs5)).toBe(false); - const pkgs6 = [ - { - _nextType: undefined, - _localDeps: [ - { - _nextType: false, - _localDeps: [ - { _nextType: false, _localDeps: [] }, - { _nextType: "patch", _localDeps: [] }, - { _nextType: false, _localDeps: [] }, - ], - }, - ], - }, - ]; - expect(hasChangedDeep(pkgs6)).toBe(true); - }); - test("No infinite loops", () => { - const pkgs1 = [{ _nextType: "patch", _localDeps: [] }]; - pkgs1[0]._localDeps.push(pkgs1[0]); - expect(hasChangedDeep(pkgs1)).toBe(true); - const pkgs2 = [{ _nextType: undefined, _localDeps: [] }]; - pkgs2[0]._localDeps.push(pkgs2[0]); - expect(hasChangedDeep(pkgs2)).toBe(false); - const pkgs3 = [ - { - _nextType: undefined, - _localDeps: [ - { _nextType: false, _localDeps: [] }, - { _nextType: false, _localDeps: [] }, - ], - }, - ]; - pkgs3[0]._localDeps[0]._localDeps.push(pkgs3[0]._localDeps[0]); - expect(hasChangedDeep(pkgs3)).toBe(false); - const pkgs4 = [ - { - _nextType: undefined, - _localDeps: [ - { _nextType: "patch", _localDeps: [] }, - { _nextType: false, _localDeps: [] }, - ], - }, - ]; - pkgs4[0]._localDeps[0]._localDeps.push(pkgs4[0]._localDeps[0]); - expect(hasChangedDeep(pkgs4)).toBe(true); - }); -}); diff --git a/test/lib/multiSemanticRelease.test.js b/test/lib/multiSemanticRelease.test.js index 8db0061..6d5e818 100644 --- a/test/lib/multiSemanticRelease.test.js +++ b/test/lib/multiSemanticRelease.test.js @@ -361,7 +361,7 @@ describe("multiSemanticRelease()", () => { ], {}, { cwd, stdout, stderr }, - { sequentialInit: true } + { sequentialInit: true, deps: {} } ); // Check manifests. diff --git a/test/lib/recognizeFormat.test.js b/test/lib/recognizeFormat.test.js index 70afaa0..a4de167 100644 --- a/test/lib/recognizeFormat.test.js +++ b/test/lib/recognizeFormat.test.js @@ -1,4 +1,3 @@ -/** @typedef {import('@types/jest')} */ const recognizeFormat = require("../../lib/recognizeFormat"); // Tests. diff --git a/test/lib/resolveReleaseType.test.js b/test/lib/resolveReleaseType.test.js new file mode 100644 index 0000000..64c1168 --- /dev/null +++ b/test/lib/resolveReleaseType.test.js @@ -0,0 +1,89 @@ +const { resolveReleaseType } = require("../../lib/updateDeps"); + +// Tests. +describe("resolveReleaseType()", () => { + test("Works correctly with no deps", () => { + expect(resolveReleaseType({ _localDeps: [] })).toBe(undefined); + }); + test("Works correctly with deps", () => { + const pkg1 = { _nextType: "patch", _localDeps: [] }; + expect(resolveReleaseType(pkg1)).toBe("patch"); + const pkg2 = { _nextType: undefined, _localDeps: [] }; + expect(resolveReleaseType(pkg2)).toBe(undefined); + const pkg3 = { + _nextType: undefined, + _localDeps: [ + { _nextType: false, _localDeps: [] }, + { _nextType: false, _localDeps: [] }, + ], + }; + expect(resolveReleaseType(pkg3)).toBe(undefined); + const pkg4 = { + manifest: { dependencies: { a: "1.0.0" } }, + _nextType: undefined, + _localDeps: [ + { name: "a", _nextType: "patch", _localDeps: [], _lastRelease: { version: "1.0.0" } }, + { name: "b", _nextType: false, _localDeps: [], _lastRelease: { version: "1.0.0" } }, + ], + }; + expect(resolveReleaseType(pkg4)).toBe("patch"); + const pkg5 = { + _nextType: undefined, + _localDeps: [ + { + _nextType: false, + _localDeps: [ + { _nextType: false, _localDeps: [] }, + { _nextType: false, _localDeps: [] }, + ], + }, + ], + }; + expect(resolveReleaseType(pkg5)).toBe(undefined); + const pkg6 = { + manifest: { dependencies: { a: "1.0.0" } }, + _nextType: undefined, + _localDeps: [ + { + name: "a", + _lastRelease: { version: "1.0.0" }, + _nextType: false, + manifest: { dependencies: { b: "1.0.0", c: "1.0.0", d: "1.0.0" } }, + _localDeps: [ + { name: "b", _nextType: false, _localDeps: [], _lastRelease: { version: "1.0.0" } }, + { name: "c", _nextType: "patch", _localDeps: [], _lastRelease: { version: "1.0.0" } }, + { name: "d", _nextType: "major", _localDeps: [], _lastRelease: { version: "1.0.0" } }, + ], + }, + ], + }; + expect(resolveReleaseType(pkg6, "override", "inherit")).toBe("major"); + }); + test("No infinite loops", () => { + const pkg1 = { _nextType: "patch", _localDeps: [] }; + pkg1._localDeps.push(pkg1); + expect(resolveReleaseType(pkg1)).toBe("patch"); + const pkg2 = { _nextType: undefined, _localDeps: [] }; + pkg2._localDeps.push(pkg2); + expect(resolveReleaseType(pkg2)).toBe(undefined); + const pkg3 = { + _nextType: undefined, + _localDeps: [ + { _nextType: false, _localDeps: [] }, + { _nextType: false, _localDeps: [] }, + ], + }; + pkg3._localDeps[0]._localDeps.push(pkg3._localDeps[0]); + expect(resolveReleaseType(pkg3)).toBe(undefined); + const pkg4 = { + manifest: { dependencies: { a: "1.0.0", b: "1.0.0" } }, + _nextType: undefined, + _localDeps: [ + { name: "a", _nextType: "patch", _localDeps: [], _lastRelease: { version: "1.0.0" } }, + { name: "b", _nextType: "major", _localDeps: [], _lastRelease: { version: "1.0.0" } }, + ], + }; + pkg4._localDeps[0]._localDeps.push(pkg4._localDeps[0]); + expect(resolveReleaseType(pkg4)).toBe("patch"); + }); +}); diff --git a/test/lib/updateDeps.test.js b/test/lib/updateDeps.test.js new file mode 100644 index 0000000..af8081a --- /dev/null +++ b/test/lib/updateDeps.test.js @@ -0,0 +1,140 @@ +const { resolveReleaseType, resolveNextVersion } = require("../../lib/updateDeps"); + +describe("resolveNextVersion()", () => { + // prettier-ignore + const cases = [ + ["1.0.0", "1.0.1", undefined, "1.0.1"], + ["1.0.0", "1.0.1", "override", "1.0.1"], + + ["*", "1.3.0", "satisfy", "*"], + ["^1.0.0", "1.0.1", "satisfy", "^1.0.0"], + ["^1.2.0", "1.3.0", "satisfy", "^1.2.0"], + ["1.2.x", "1.2.2", "satisfy", "1.2.x"], + + ["1.2.x", "1.3.0", "inherit", "1.3.x"], + ["^1.0.0", "2.0.0", "inherit", "^2.0.0"], + ["*", "2.0.0", "inherit", "*"], + ["~1.0", "2.0.0", "inherit", "~2.0"], + ["~2.0", "2.1.0", "inherit", "~2.1"], + ] + + cases.forEach(([currentVersion, nextVersion, strategy, resolvedVersion]) => { + it(`${currentVersion}/${nextVersion}/${strategy} gives ${resolvedVersion}`, () => { + expect(resolveNextVersion(currentVersion, nextVersion, strategy)).toBe(resolvedVersion); + }); + }); +}); + +describe("resolveReleaseType()", () => { + // prettier-ignore + const cases = [ + [ + "returns own package's _nextType if exists", + { + _nextType: "patch", + _localDeps: [], + }, + undefined, + undefined, + "patch", + ], + [ + "implements `inherit` strategy: returns the highest release type of any deps", + { + manifest: { dependencies: { a: "1.0.0" } }, + _nextType: undefined, + _localDeps: [ + { + name: "a", + manifest: { dependencies: { b: "1.0.0", c: "1.0.0", d: "1.0.0" } }, + _lastRelease: { version: "1.0.0" }, + _nextType: false, + _localDeps: [ + { name: "b", _nextType: false, _localDeps: [], _lastRelease: { version: "1.0.0" } }, + { name: "c", _nextType: "patch", _localDeps: [], _lastRelease: { version: "1.0.0" } }, + { name: "d", _nextType: "major", _localDeps: [], _lastRelease: { version: "1.0.0" } }, + ], + }, + ], + }, + undefined, + "inherit", + "major" + ], + [ + "overrides dependant release type with custom value if defined", + { + manifest: { dependencies: { a: "1.0.0" } }, + _nextType: undefined, + _localDeps: [ + { + name: "a", + // _lastRelease: { version: "1.0.0" }, + manifest: { dependencies: { b: "1.0.0", c: "1.0.0", d: "1.0.0" } }, + _nextType: false, + _localDeps: [ + { name: "b", _nextType: false, _localDeps: [], _lastRelease: { version: "1.0.0" } }, + { name: "c", _nextType: "minor", _localDeps: [], _lastRelease: { version: "1.0.0" } }, + { name: "d", _nextType: "patch", _localDeps: [], _lastRelease: { version: "1.0.0" } }, + ], + }, + ], + }, + undefined, + "major", + "major" + ], + [ + "uses `patch` strategy as default (legacy flow)", + { + manifest: { dependencies: { a: "1.0.0" } }, + _nextType: undefined, + _localDeps: [ + { + name: "a", + _nextType: false, + //_lastRelease: { version: "1.0.0" }, + manifest: { dependencies: { b: "1.0.0", c: "1.0.0", d: "1.0.0" } }, + _localDeps: [ + { name: "b", _nextType: false, _localDeps: [], _lastRelease: { version: "1.0.0" } }, + { name: "c", _nextType: "minor", _localDeps: [], _lastRelease: { version: "1.0.0" } }, + { name: "d", _nextType: "major", _localDeps: [], _lastRelease: { version: "1.0.0" } }, + ], + }, + ], + }, + undefined, + undefined, + "patch" + ], + [ + "returns undefined if no _nextRelease found", + { + _nextType: undefined, + _localDeps: [ + { + _nextType: false, + _localDeps: [ + { _nextType: false, _localDeps: [] }, + { + _nextType: undefined, + _localDeps: [ + { _nextType: undefined, _localDeps: [] } + ] + }, + ], + }, + ], + }, + undefined, + undefined, + undefined, + ], + ] + + cases.forEach(([name, pkg, bumpStrategy, releaseStrategy, result]) => { + it(name, () => { + expect(resolveReleaseType(pkg, bumpStrategy, releaseStrategy)).toBe(result); + }); + }); +});