From 916c2685c57f3490fb1e50afbf72ea8dce11e188 Mon Sep 17 00:00:00 2001 From: Pierre Vanduynslager Date: Wed, 4 Dec 2019 17:53:27 -0500 Subject: [PATCH] feat: allow to release any version on a branch if up to date with next branch --- docs/usage/workflow-configuration.md | 21 ++++----- lib/branches/normalize.js | 33 ++++++-------- test/branches/branches.test.js | 49 ++++++++++++--------- test/branches/normalize.test.js | 65 ++++++++++------------------ test/index.test.js | 6 +-- 5 files changed, 77 insertions(+), 97 deletions(-) diff --git a/docs/usage/workflow-configuration.md b/docs/usage/workflow-configuration.md index c2d27265d9..8c08990c80 100644 --- a/docs/usage/workflow-configuration.md +++ b/docs/usage/workflow-configuration.md @@ -100,7 +100,7 @@ For example the configuration `['master', {name: 'pre/rc', prerelease: '${name.r ### Release branches -A release branch is the base type of branch used by **semantic-release** that allows to publish releases with a [semantic version](https://semver.org), optionally on a specific distribution channel. Distribution channels (for example [npm dist-tags](https://docs.npmjs.com/cli/dist-tag) or [Chrome release channels](https://www.chromium.org/getting-involved/dev-channel)) are a way to distribute new releases only to a subset of users in order to get early feedback. Later on those releases can be added to the general distribution channel to be made available to all users. +A release branch is the base type of branch used by **semantic-release** that allows to publish releases with a [semantic version](https://semver.org), optionally on a specific distribution channel. Distribution channels (for example [npm dist-tags](https://docs.npmjs.com/cli/dist-tag) or [Chrome release channels](https://www.chromium.org/getting-involved/dev-channel)) are a way to distribute new releases only to a subset of users in order to get early feedback. Later on, those releases can be added to the general distribution channel to be made available to all users. **semantic-release** will automatically add releases to the corresponding distribution channel when code is [merged from a release branch to another](#merging-into-a-release-branch). @@ -112,20 +112,17 @@ See [publishing on distribution channels recipe](../recipes/distribution-channel #### Pushing to a release branch -With the configuration `"branches": ["master", "next"]`, if the last release published from `master` is `1.0.0` then: +With the configuration `"branches": ["master", "next"]`, if the last release published from `master` is `1.0.0` and the last one from `next` is `2.0.0` then: - Only versions in range `1.x.x` can be published from `master`, so only `fix` and `feat` commits can be pushed to `master` -- Only versions in range `>=2.0.0` release can be published from `next`, so a `BREAKING CHANGE` commit must be pushed first on `next` and can be followed by any type of commits +- Once `next` get merged into `master` the release `2.0.0` will be made available on the channel associated with `master` and both `master` and `next` will accept any commit type -With the configuration `"branches": ["master", "next", "next-major"]`, if the last release published from `master` is `1.0.0` then: -- Only versions in range `1.0.x` can be published from `master`, so only `fix` commits can be pushed to `master` -- Only versions in range `>=1.1.0` can be published from `next`, so a `feat` commit must be pushed first on `next` and can be followed by `fix` and `feat` commits -- Only versions in range `>=2.0.0` release can be published from `next`, so a `BREAKING CHANGE` commit must be pushed first on `next-major` and can be followed by any type of commits - -Those verifications prevent situations such as: -1. Create a `feat` commit on `next` which triggers the release of version `1.0.0` on channel `next` +This verification prevent scenario such as: +1. Create a `feat` commit on `next` which triggers the release of version `1.0.0` on the `next` channel 2. Merge `next` into `master` which adds `1.0.0` on the default channel -3. Push a `fix` commit to `master` which triggers the release of version `1.0.1` on the default channel -4. Push a `fix` commit to `next` which would attempt to release the version `1.0.1` on channel `next` and fails as this version already exists +3. Create a `feat` commit on `next` which triggers the release of version `1.1.0` on the `next` channel +4. Create a `feat` commit on `master` which would attempt to release the version `1.1.0` on the default channel + +In step 4 **semantic-release** will throw an `EINVALIDNEXTVERSION` error to prevent the attempt at releasing version `1.1.0` which was already released on step 3 with a different codebase. The error will indicate that the commit should be created on `next` instead. Alternatively if the `next` branch is merged into `master`, the version `1.1.0` will be made available on the default channel and the `feat` commit would be allowed on `master` to release `1.2.0`. #### Merging into a release branch diff --git a/lib/branches/normalize.js b/lib/branches/normalize.js index 103519c10b..369bd9d717 100644 --- a/lib/branches/normalize.js +++ b/lib/branches/normalize.js @@ -1,5 +1,4 @@ const {sortBy, isNil} = require('lodash'); -const semver = require('semver'); const semverDiff = require('semver-diff'); const {FIRST_RELEASE, RELEASE_TYPE} = require('../definitions/constants'); const { @@ -61,35 +60,29 @@ function release({release}) { return release; } - const breakpoints = release.length > 2 ? ['minor', 'major'] : ['major']; - - // The intial bound is the last release from the base branch of `FIRST_RELEASE` (1.0.0) - let bound = getLatestVersion(tagsToVersions(release[0].tags)) || FIRST_RELEASE; + // The intial lastVersion is the last release from the base branch of `FIRST_RELEASE` (1.0.0) + let lastVersion = getLatestVersion(tagsToVersions(release[0].tags)) || FIRST_RELEASE; return release.map(({name, tags, channel, ...rest}, idx) => { const versions = tagsToVersions(tags); - // The lower bound is the highest version between the current branch last release and the previous branch upper bound (`bound`) - const min = highest(getLatestVersion(versions), bound); - if (release.length - 1 === idx) { - // If the current branch is the last one of the release branch, there is no upper bound - bound = undefined; - } else { - // The default upper bound is the lower bound increment with the release type of the current branch position - const upperBound = semver.inc(min, breakpoints[idx]); - // Find the lowest version that is present on the current branch but none of the previous ones - const nextFirstVersion = getFirstVersion(tagsToVersions(release[idx + 1].tags), release.slice(0, idx + 1)); - // The upper bound is the lowest version between `nextFirstVersion` and the default upper bound - bound = lowest(nextFirstVersion, upperBound); - } + // The new lastVersion is the highest version between the current branch last release and the previous branch lastVersion + lastVersion = highest(getLatestVersion(versions), lastVersion); + // The upper bound is: + // - None if the current branch is the last one of the release branches + // - Otherwise, The upper bound is the lowest version that is present on the current branch but none of the previous ones + const bound = + release.length - 1 === idx + ? undefined + : getFirstVersion(tagsToVersions(release[idx + 1].tags), release.slice(0, idx + 1)); - const diff = bound ? semverDiff(min, bound) : null; + const diff = bound ? semverDiff(lastVersion, bound) : null; return { ...rest, channel: idx === 0 ? channel : isNil(channel) ? name : channel, tags, type: 'release', name, - range: getRange(min, bound), + range: getRange(lastVersion, bound), accept: bound ? RELEASE_TYPE.slice(0, RELEASE_TYPE.indexOf(diff)) : RELEASE_TYPE, main: idx === 0, }; diff --git a/test/branches/branches.test.js b/test/branches/branches.test.js index a658aa2bd0..a87bbba790 100644 --- a/test/branches/branches.test.js +++ b/test/branches/branches.test.js @@ -30,9 +30,9 @@ test('Enforce ranges with branching release workflow', async t => { })); t.is(getBranch(result, '1.0.x').range, '>=1.0.0 <1.0.0', 'Cannot release on 1.0.x before a releasing on master'); t.is(getBranch(result, '1.x').range, '>=1.1.0 <1.0.0', 'Cannot release on 1.x before a releasing on master'); - t.is(getBranch(result, 'master').range, '>=1.0.0 <1.1.0', 'Can release only patch on master'); - t.is(getBranch(result, 'next').range, '>=1.1.0 <2.0.0', 'Can release only minor on next'); - t.is(getBranch(result, 'next-major').range, '>=2.0.0', 'Can release only major on next-major'); + t.is(getBranch(result, 'master').range, '>=1.0.0'); + t.is(getBranch(result, 'next').range, '>=1.0.0'); + t.is(getBranch(result, 'next-major').range, '>=1.0.0'); release(branches, 'master', '1.0.0'); result = (await getBranches('repositoryUrl', 'master', {options: {branches}})).map(({name, range}) => ({ @@ -41,14 +41,18 @@ test('Enforce ranges with branching release workflow', async t => { })); t.is(getBranch(result, '1.0.x').range, '>=1.0.0 <1.0.0', 'Cannot release on 1.0.x before a releasing on master'); t.is(getBranch(result, '1.x').range, '>=1.1.0 <1.0.0', 'Cannot release on 1.x before a releasing on master'); - t.is(getBranch(result, 'master').range, '>=1.0.0 <1.1.0', 'Can release only patch on master'); + t.is(getBranch(result, 'master').range, '>=1.0.0'); + t.is(getBranch(result, 'next').range, '>=1.0.0'); + t.is(getBranch(result, 'next-major').range, '>=1.0.0'); release(branches, 'master', '1.0.1'); result = (await getBranches('repositoryUrl', 'master', {options: {branches}})).map(({name, range}) => ({ name, range, })); - t.is(getBranch(result, 'master').range, '>=1.0.1 <1.1.0', 'Can release only patch, > than 1.0.1 on master'); + t.is(getBranch(result, 'master').range, '>=1.0.1', 'Can release only > than 1.0.1 on master'); + t.is(getBranch(result, 'next').range, '>=1.0.1', 'Can release only > than 1.0.1 on next'); + t.is(getBranch(result, 'next-major').range, '>=1.0.1', 'Can release only > than 1.0.1 on next-major'); merge(branches, 'master', 'next'); merge(branches, 'master', 'next-major'); @@ -56,9 +60,9 @@ test('Enforce ranges with branching release workflow', async t => { name, range, })); - t.is(getBranch(result, 'master').range, '>=1.0.1 <1.1.0', 'Can release only patch, > than 1.0.1 on master'); - t.is(getBranch(result, 'next').range, '>=1.1.0 <2.0.0', 'Can release only minor on next'); - t.is(getBranch(result, 'next-major').range, '>=2.0.0', 'Can release only major on next-major'); + t.is(getBranch(result, 'master').range, '>=1.0.1', 'Can release only > than 1.0.1 on master'); + t.is(getBranch(result, 'next').range, '>=1.0.1', 'Can release only > than 1.0.1 on next'); + t.is(getBranch(result, 'next-major').range, '>=1.0.1', 'Can release only > than 1.0.1 on next-major'); release(branches, 'next', '1.1.0'); release(branches, 'next', '1.1.1'); @@ -66,7 +70,9 @@ test('Enforce ranges with branching release workflow', async t => { name, range, })); - t.is(getBranch(result, 'next').range, '>=1.1.1 <2.0.0', 'Can release only patch or minor, > than 1.1.0 on next'); + t.is(getBranch(result, 'master').range, '>=1.0.1 <1.1.0', 'Can release only patch, > than 1.0.1 on master'); + t.is(getBranch(result, 'next').range, '>=1.1.1', 'Can release only > than 1.1.1 on next'); + t.is(getBranch(result, 'next-major').range, '>=1.1.1', 'Can release > than 1.1.1 on next-major'); release(branches, 'next-major', '2.0.0'); release(branches, 'next-major', '2.0.1'); @@ -74,6 +80,8 @@ test('Enforce ranges with branching release workflow', async t => { name, range, })); + t.is(getBranch(result, 'master').range, '>=1.0.1 <1.1.0', 'Can release only patch, > than 1.0.1 on master'); + t.is(getBranch(result, 'next').range, '>=1.1.1 <2.0.0', 'Can release only patch or minor, > than 1.1.0 on next'); t.is(getBranch(result, 'next-major').range, '>=2.0.1', 'Can release any version, > than 2.0.1 on next-major'); merge(branches, 'next-major', 'beta'); @@ -88,7 +96,6 @@ test('Enforce ranges with branching release workflow', async t => { merge(branches, 'master', '1.0.x'); merge(branches, 'master', '1.x'); - release(branches, 'master', '1.0.1'); result = (await getBranches('repositoryUrl', 'master', {options: {branches}})).map(({name, range}) => ({ name, range, @@ -121,8 +128,9 @@ test('Enforce ranges with branching release workflow', async t => { name, range, })); - t.is(getBranch(result, 'master').range, '>=1.1.1 <1.2.0', 'Can release only patch, > than 1.1.1 on master'); - t.is(getBranch(result, 'next').range, '>=1.2.0 <2.0.0', 'Can release only patch or minor, > than 1.2.0 on next'); + + t.is(getBranch(result, 'master').range, '>=1.1.1', 'Can release only > than 1.1.1 on master'); + t.is(getBranch(result, 'next').range, '>=1.1.1 <2.0.0', 'Can release only patch or minor, > than 1.1.1 on next'); t.is(getBranch(result, 'next-major').range, '>=2.0.1', 'Can release any version, > than 2.0.1 on next-major'); t.is( getBranch(result, '1.0.x').range, @@ -136,7 +144,7 @@ test('Enforce ranges with branching release workflow', async t => { name, range, })); - t.is(getBranch(result, 'master').range, '>=1.1.1 <1.2.0', 'Can release only patch, > than 1.1.1 on master'); + t.is(getBranch(result, 'master').range, '>=1.1.1', 'Can release only > than 1.1.1 on master'); t.is(getBranch(result, '1.0.x').range, '>=1.0.4 <1.1.0', 'Can release on 1.0.x only within range'); t.is(getBranch(result, '1.x').range, '>=1.1.0 <1.1.0', 'Cannot release on 1.x before >= 2.0.0 is released on master'); @@ -145,7 +153,7 @@ test('Enforce ranges with branching release workflow', async t => { name, range, })); - t.is(getBranch(result, 'master').range, '>=1.1.1 <1.2.0', 'Can release only patch, > than 1.1.1 on master'); + t.is(getBranch(result, 'master').range, '>=1.1.1', 'Can release only > than 1.1.1 on master'); t.is(getBranch(result, '1.0.x').range, '>=1.0.4 <1.1.0', 'Can release on 1.0.x only within range'); t.is(getBranch(result, '1.x').range, '>=1.1.1 <1.1.1', 'Cannot release on 1.x before >= 2.0.0 is released on master'); @@ -155,19 +163,20 @@ test('Enforce ranges with branching release workflow', async t => { name, range, })); - t.is(getBranch(result, 'master').range, '>=2.0.1 <2.1.0', 'Can release only patch, > than 2.0.1 on master'); - t.is(getBranch(result, 'next').range, '>=2.1.0 <3.0.0', 'Can release only minor on next'); - t.is(getBranch(result, 'next-major').range, '>=3.0.0', 'Can release only major on next-major'); + t.is(getBranch(result, 'master').range, '>=2.0.1', 'Can release only > than 2.0.1 on master'); + t.is(getBranch(result, 'next').range, '>=2.0.1', 'Can release only > than 2.0.1 on next'); + t.is(getBranch(result, 'next-major').range, '>=2.0.1', 'Can release only > than 2.0.1 on next-major'); t.is(getBranch(result, '1.x').range, '>=1.1.1 <2.0.0', 'Can release on 1.x only within range'); merge(branches, 'beta', 'master'); + release(branches, 'master', '3.0.0'); result = (await getBranches('repositoryUrl', 'master', {options: {branches}})).map(({name, range}) => ({ name, range, })); - t.is(getBranch(result, 'master').range, '>=2.0.1 <2.1.0', 'Can release only patch, > than 2.0.1 on master'); - t.is(getBranch(result, 'next').range, '>=2.1.0 <3.0.0', 'Can release only minor on next'); - t.is(getBranch(result, 'next-major').range, '>=3.0.0', 'Can release only major on next-major'); + t.is(getBranch(result, 'master').range, '>=3.0.0', 'Can release only > than 3.0.0 on master'); + t.is(getBranch(result, 'next').range, '>=3.0.0', 'Can release only > than 3.0.0 on next'); + t.is(getBranch(result, 'next-major').range, '>=3.0.0', 'Can release only > than 3.0.0 on next-major'); branches.push({name: '1.1.x', tags: []}); merge(branches, '1.x', '1.1.x'); diff --git a/test/branches/normalize.test.js b/test/branches/normalize.test.js index b7e01a1a13..765ec866ae 100644 --- a/test/branches/normalize.test.js +++ b/test/branches/normalize.test.js @@ -159,19 +159,26 @@ test('Release branches - initial state', t => { .release({release}) .map(({type, name, range, accept, channel, main}) => ({type, name, range, accept, channel, main})), [ - {type: 'release', name: 'master', range: '>=1.0.0 <1.1.0', accept: ['patch'], channel: undefined, main: true}, + { + type: 'release', + name: 'master', + range: '>=1.0.0', + accept: ['patch', 'minor', 'major'], + channel: undefined, + main: true, + }, { type: 'release', name: 'next', - range: '>=1.1.0 <2.0.0', - accept: ['patch', 'minor'], + range: '>=1.0.0', + accept: ['patch', 'minor', 'major'], channel: 'next', main: false, }, { type: 'release', name: 'next-major', - range: '>=2.0.0', + range: '>=1.0.0', accept: ['patch', 'minor', 'major'], channel: 'next-major', main: false, @@ -293,15 +300,15 @@ test('Release branches - Handle missing previous tags in branch history', t => { { type: 'release', name: 'master', - range: '>=2.0.0 <3.0.0', - accept: ['patch', 'minor'], + range: '>=2.0.0', + accept: ['patch', 'minor', 'major'], channel: undefined, main: true, }, { type: 'release', name: 'next', - range: '>=3.0.0', + range: '>=2.0.0', accept: ['patch', 'minor', 'major'], channel: 'next', main: false, @@ -310,10 +317,10 @@ test('Release branches - Handle missing previous tags in branch history', t => { ); }); -test('Release branches - enforce release gaps after downstream merge', t => { +test('Release branches - limit releases on 2nd and 3rd branch based on 1st branch last release', t => { const release = [ - {name: 'master', tags: toTags(['1.0.0', '1.1.0', '2.0.0'])}, - {name: 'next', tags: toTags(['1.0.0', '1.1.0', '2.0.0'])}, + {name: 'master', tags: toTags(['1.0.0', '1.1.0', '2.0.0', '3.0.0'])}, + {name: 'next', tags: toTags(['1.0.0', '1.1.0'])}, {name: 'next-major', tags: toTags(['1.0.0', '1.1.0', '2.0.0'])}, ]; @@ -322,52 +329,26 @@ test('Release branches - enforce release gaps after downstream merge', t => { .release({release}) .map(({type, name, range, accept, channel, main}) => ({type, name, range, accept, channel, main})), [ - {type: 'release', name: 'master', range: '>=2.0.0 <2.1.0', accept: ['patch'], channel: undefined, main: true}, - { - type: 'release', - name: 'next', - range: '>=2.1.0 <3.0.0', - accept: ['patch', 'minor'], - channel: 'next', - main: false, - }, { type: 'release', - name: 'next-major', + name: 'master', range: '>=3.0.0', accept: ['patch', 'minor', 'major'], - channel: 'next-major', - main: false, + channel: undefined, + main: true, }, - ] - ); -}); - -test('Release branches - limit releases on 2nd and 3rd branche based on 1st branch last release', t => { - const release = [ - {name: 'master', tags: toTags(['1.0.0', '1.1.0', '2.0.0', '3.0.0'])}, - {name: 'next', tags: toTags(['1.0.0', '1.1.0'])}, - {name: 'next-major', tags: toTags(['1.0.0', '1.1.0', '2.0.0'])}, - ]; - - t.deepEqual( - normalize - .release({release}) - .map(({type, name, range, accept, channel, main}) => ({type, name, range, accept, channel, main})), - [ - {type: 'release', name: 'master', range: '>=3.0.0 <3.1.0', accept: ['patch'], channel: undefined, main: true}, { type: 'release', name: 'next', - range: '>=3.1.0 <4.0.0', - accept: ['patch', 'minor'], + range: '>=3.0.0', + accept: ['patch', 'minor', 'major'], channel: 'next', main: false, }, { type: 'release', name: 'next-major', - range: '>=4.0.0', + range: '>=3.0.0', accept: ['patch', 'minor', 'major'], channel: 'next-major', main: false, diff --git a/test/index.test.js b/test/index.test.js index 0c3d867de3..586b0799eb 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -97,8 +97,8 @@ test('Plugins are called with expected values', async t => { { channel: undefined, name: 'master', - range: '>=1.0.0 <2.0.0', - accept: ['patch', 'minor'], + range: '>=1.0.0', + accept: ['patch', 'minor', 'major'], tags: [{channels: ['next'], gitTag: 'v1.0.0', version: '1.0.0'}], type: 'release', main: true, @@ -106,7 +106,7 @@ test('Plugins are called with expected values', async t => { { channel: 'next', name: 'next', - range: '>=2.0.0', + range: '>=1.0.0', accept: ['patch', 'minor', 'major'], tags: [{channels: ['next'], gitTag: 'v1.0.0', version: '1.0.0'}], type: 'release',