diff --git a/__snapshots__/cli.js b/__snapshots__/cli.js index 751d6e244..1a86435c9 100644 --- a/__snapshots__/cli.js +++ b/__snapshots__/cli.js @@ -196,7 +196,7 @@ Options: generated? [boolean] [default: false] --versioning-strategy strategy used for bumping versions [choices: "always-bump-major", "always-bump-minor", "always-bump-patch", - "default", "service-pack"] [default: "default"] + "default", "prerelease", "service-pack"] [default: "default"] --changelog-path where can the CHANGELOG be found in the project? [string] [default: "CHANGELOG.md"] --changelog-type type of changelog to build diff --git a/src/factories/versioning-strategy-factory.ts b/src/factories/versioning-strategy-factory.ts index 3a12b2b18..0c81adea7 100644 --- a/src/factories/versioning-strategy-factory.ts +++ b/src/factories/versioning-strategy-factory.ts @@ -20,6 +20,7 @@ import {AlwaysBumpMajor} from '../versioning-strategies/always-bump-major'; import {ServicePackVersioningStrategy} from '../versioning-strategies/service-pack'; import {GitHub} from '../github'; import {ConfigurationError} from '../errors'; +import {PrereleaseVersioningStrategy} from '../versioning-strategies/prerelease'; export type VersioningStrategyType = string; @@ -40,6 +41,7 @@ const versioningTypes: Record = { 'always-bump-minor': options => new AlwaysBumpMinor(options), 'always-bump-major': options => new AlwaysBumpMajor(options), 'service-pack': options => new ServicePackVersioningStrategy(options), + prerelease: options => new PrereleaseVersioningStrategy(options), }; export function buildVersioningStrategy( diff --git a/src/versioning-strategies/prerelease.ts b/src/versioning-strategies/prerelease.ts new file mode 100644 index 000000000..463f79dd6 --- /dev/null +++ b/src/versioning-strategies/prerelease.ts @@ -0,0 +1,203 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {DefaultVersioningStrategy} from './default'; +import {Version} from '../version'; +import {ConventionalCommit} from '..'; +import {VersionUpdater, CustomVersionUpdate} from '../versioning-strategy'; + +const PRERELEASE_PATTERN = /^(?[a-z]+)(?\d+)$/; + +class PrereleasePatchVersionUpdate implements VersionUpdater { + /** + * Returns the new bumped version + * + * @param {Version} version The current version + * @returns {Version} The bumped version + */ + bump(version: Version): Version { + if (version.preRelease) { + const match = version.preRelease.match(PRERELEASE_PATTERN); + if (match?.groups) { + const numberLength = match.groups.number.length; + const nextPrereleaseNumber = Number(match.groups.number) + 1; + const paddedNextPrereleaseNumber = `${nextPrereleaseNumber}`.padStart( + numberLength, + '0' + ); + const nextPrerelease = `${match.groups.type}${paddedNextPrereleaseNumber}`; + return new Version( + version.major, + version.minor, + version.patch, + nextPrerelease, + version.build + ); + } + } + return new Version( + version.major, + version.minor, + version.patch + 1, + version.preRelease, + version.build + ); + } +} + +class PrereleaseMinorVersionUpdate implements VersionUpdater { + /** + * Returns the new bumped version + * + * @param {Version} version The current version + * @returns {Version} The bumped version + */ + bump(version: Version): Version { + if (version.preRelease) { + const match = version.preRelease.match(PRERELEASE_PATTERN); + if (match?.groups) { + const numberLength = match.groups.number.length; + const prereleaseNumber = Number(match.groups.number); + + let nextPrereleaseNumber = 1; + let nextMinorNumber = version.minor + 1; + let nextPatchNumber = 0; + if (version.patch === 0) { + // this is already the next minor candidate, then bump the pre-release number + nextPrereleaseNumber = prereleaseNumber + 1; + nextMinorNumber = version.minor; + nextPatchNumber = version.patch; + } + + const paddedNextPrereleaseNumber = `${nextPrereleaseNumber}`.padStart( + numberLength, + '0' + ); + const nextPrerelease = `${match.groups.type}${paddedNextPrereleaseNumber}`; + return new Version( + version.major, + nextMinorNumber, + nextPatchNumber, + nextPrerelease, + version.build + ); + } + } + return new Version( + version.major, + version.minor + 1, + 0, + version.preRelease, + version.build + ); + } +} + +class PrereleaseMajorVersionUpdate implements VersionUpdater { + /** + * Returns the new bumped version + * + * @param {Version} version The current version + * @returns {Version} The bumped version + */ + bump(version: Version): Version { + if (version.preRelease) { + const match = version.preRelease.match(PRERELEASE_PATTERN); + if (match?.groups) { + const numberLength = match.groups.number.length; + const prereleaseNumber = Number(match.groups.number); + + let nextPrereleaseNumber = 1; + let nextMajorNumber = version.major + 1; + let nextMinorNumber = 0; + let nextPatchNumber = 0; + if (version.patch === 0 && version.minor === 0) { + // this is already the next major candidate, then bump the pre-release number + nextPrereleaseNumber = prereleaseNumber + 1; + nextMajorNumber = version.major; + nextMinorNumber = version.minor; + nextPatchNumber = version.patch; + } + + const paddedNextPrereleaseNumber = `${nextPrereleaseNumber}`.padStart( + numberLength, + '0' + ); + const nextPrerelease = `${match.groups.type}${paddedNextPrereleaseNumber}`; + return new Version( + nextMajorNumber, + nextMinorNumber, + nextPatchNumber, + nextPrerelease, + version.build + ); + } + } + return new Version( + version.major + 1, + 0, + 0, + version.preRelease, + version.build + ); + } +} + +/** + * This versioning strategy will increment the pre-release number for patch + * bumps if there is a pre-release number (preserving any leading 0s). + * Example: 1.2.3-beta01 -> 1.2.3-beta02. + */ +export class PrereleaseVersioningStrategy extends DefaultVersioningStrategy { + determineReleaseType( + version: Version, + commits: ConventionalCommit[] + ): VersionUpdater { + // iterate through list of commits and find biggest commit type + let breaking = 0; + let features = 0; + for (const commit of commits) { + const releaseAs = commit.notes.find(note => note.title === 'RELEASE AS'); + if (releaseAs) { + // commits are handled newest to oldest, so take the first one (newest) found + this.logger.debug( + `found Release-As: ${releaseAs.text}, forcing version` + ); + return new CustomVersionUpdate( + Version.parse(releaseAs.text).toString() + ); + } + if (commit.breaking) { + breaking++; + } else if (commit.type === 'feat' || commit.type === 'feature') { + features++; + } + } + + if (breaking > 0) { + if (version.major < 1 && this.bumpMinorPreMajor) { + return new PrereleaseMinorVersionUpdate(); + } else { + return new PrereleaseMajorVersionUpdate(); + } + } else if (features > 0) { + if (version.major < 1 && this.bumpPatchForMinorPreMajor) { + return new PrereleasePatchVersionUpdate(); + } else { + return new PrereleaseMinorVersionUpdate(); + } + } + return new PrereleasePatchVersionUpdate(); + } +} diff --git a/test/versioning-strategies/prerelease.ts b/test/versioning-strategies/prerelease.ts new file mode 100644 index 000000000..45b38b241 --- /dev/null +++ b/test/versioning-strategies/prerelease.ts @@ -0,0 +1,292 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {describe, it} from 'mocha'; + +import {expect} from 'chai'; +import {PrereleaseVersioningStrategy} from '../../src/versioning-strategies/prerelease'; +import {Version} from '../../src/version'; + +describe('PrereleaseVersioningStrategy', () => { + describe('with breaking change', () => { + const commits = [ + { + sha: 'sha1', + message: 'feat: some feature', + files: ['path1/file1.txt'], + type: 'feat', + scope: null, + bareMessage: 'some feature', + notes: [], + references: [], + breaking: false, + }, + { + sha: 'sha2', + message: 'fix!: some bugfix', + files: ['path1/file1.rb'], + type: 'fix', + scope: null, + bareMessage: 'some bugfix', + notes: [{title: 'BREAKING CHANGE', text: 'some bugfix'}], + references: [], + breaking: true, + }, + { + sha: 'sha3', + message: 'docs: some documentation', + files: ['path1/file1.java'], + type: 'docs', + scope: null, + bareMessage: 'some documentation', + notes: [], + references: [], + breaking: false, + }, + ]; + const expectedBumps: Record = { + '1.2.3': '2.0.0', + '0.1.2': '1.0.0', + '1.0.0-beta01': '1.0.0-beta02', + '2.0.0-beta01': '2.0.0-beta02', + '1.0.1-beta01': '2.0.0-beta01', + '1.1.0-beta01': '2.0.0-beta01', + '1.1.1-beta01': '2.0.0-beta01', + }; + for (const old in expectedBumps) { + const expected = expectedBumps[old]; + it(`can bump ${old} to ${expected}`, async () => { + const strategy = new PrereleaseVersioningStrategy(); + const oldVersion = Version.parse(old); + const newVersion = await strategy.bump(oldVersion, commits); + expect(newVersion.toString()).to.equal(expected); + }); + } + it('can bump a minor pre major for breaking change', async () => { + const strategy = new PrereleaseVersioningStrategy({ + bumpMinorPreMajor: true, + }); + const oldVersion = Version.parse('0.1.2'); + const newVersion = await strategy.bump(oldVersion, commits); + expect(newVersion.toString()).to.equal('0.2.0'); + }); + }); + + describe('with a feature', () => { + const commits = [ + { + sha: 'sha1', + message: 'feat: some feature', + files: ['path1/file1.txt'], + type: 'feat', + scope: null, + bareMessage: 'some feature', + notes: [], + references: [], + breaking: false, + }, + { + sha: 'sha2', + message: 'fix: some bugfix', + files: ['path1/file1.rb'], + type: 'fix', + scope: null, + bareMessage: 'some bugfix', + notes: [], + references: [], + breaking: false, + }, + { + sha: 'sha3', + message: 'docs: some documentation', + files: ['path1/file1.java'], + type: 'docs', + scope: null, + bareMessage: 'some documentation', + notes: [], + references: [], + breaking: false, + }, + ]; + const expectedBumps: Record = { + '1.2.3': '1.3.0', + '0.1.2': '0.2.0', + '1.0.0-beta01': '1.0.0-beta02', + '2.0.0-beta01': '2.0.0-beta02', + '1.0.1-beta01': '1.1.0-beta01', + '1.1.0-beta01': '1.1.0-beta02', + '1.1.1-beta01': '1.2.0-beta01', + }; + for (const old in expectedBumps) { + const expected = expectedBumps[old]; + it(`can bump ${old} to ${expected}`, async () => { + const strategy = new PrereleaseVersioningStrategy(); + const oldVersion = Version.parse(old); + const newVersion = await strategy.bump(oldVersion, commits); + expect(newVersion.toString()).to.equal(expected); + }); + } + it('can bump a patch pre-major', async () => { + const strategy = new PrereleaseVersioningStrategy({ + bumpPatchForMinorPreMajor: true, + }); + const oldVersion = Version.parse('0.1.2'); + const newVersion = await strategy.bump(oldVersion, commits); + expect(newVersion.toString()).to.equal('0.1.3'); + }); + }); + + describe('with a fix', () => { + const commits = [ + { + sha: 'sha2', + message: 'fix: some bugfix', + files: ['path1/file1.rb'], + type: 'fix', + scope: null, + bareMessage: 'some bugfix', + notes: [], + references: [], + breaking: false, + }, + { + sha: 'sha3', + message: 'docs: some documentation', + files: ['path1/file1.java'], + type: 'docs', + scope: null, + bareMessage: 'some documentation', + notes: [], + references: [], + breaking: false, + }, + ]; + const expectedBumps: Record = { + '1.2.3': '1.2.4', + '1.0.0-beta01': '1.0.0-beta02', + '2.0.0-beta01': '2.0.0-beta02', + '1.0.1-beta01': '1.0.1-beta02', + '1.1.0-beta01': '1.1.0-beta02', + '1.1.1-beta01': '1.1.1-beta02', + '1.0.0-beta1': '1.0.0-beta2', + '1.0.0-beta9': '1.0.0-beta10', // (although that would be unfortunate) + '1.0.0-beta09': '1.0.0-beta10', + }; + for (const old in expectedBumps) { + const expected = expectedBumps[old]; + it(`can bump ${old} to ${expected}`, async () => { + const strategy = new PrereleaseVersioningStrategy(); + const oldVersion = Version.parse(old); + const newVersion = await strategy.bump(oldVersion, commits); + expect(newVersion.toString()).to.equal(expected); + }); + } + }); + + describe('with release-as', () => { + it('sets the version', async () => { + const commits = [ + { + sha: 'sha1', + message: 'feat: some feature', + files: ['path1/file1.txt'], + type: 'feat', + scope: null, + bareMessage: 'some feature', + notes: [], + references: [], + breaking: false, + }, + { + sha: 'sha2', + message: 'fix!: some bugfix', + files: ['path1/file1.rb'], + type: 'fix', + scope: null, + bareMessage: 'some bugfix', + notes: [{title: 'RELEASE AS', text: '3.1.2'}], + references: [], + breaking: true, + }, + { + sha: 'sha3', + message: 'docs: some documentation', + files: ['path1/file1.java'], + type: 'docs', + scope: null, + bareMessage: 'some documentation', + notes: [], + references: [], + breaking: false, + }, + ]; + const strategy = new PrereleaseVersioningStrategy(); + const oldVersion = Version.parse('1.2.3'); + const newVersion = await strategy.bump(oldVersion, commits); + expect(newVersion.toString()).to.equal('3.1.2'); + }); + it('handles multiple release-as commits', async () => { + const commits = [ + { + sha: 'sha1', + message: 'feat: some feature', + files: ['path1/file1.txt'], + type: 'feat', + scope: null, + bareMessage: 'some feature', + notes: [], + references: [], + breaking: false, + }, + { + sha: 'sha2', + message: 'fix!: some bugfix', + files: ['path1/file1.rb'], + type: 'fix', + scope: null, + bareMessage: 'some bugfix', + notes: [{title: 'RELEASE AS', text: '3.1.2'}], + references: [], + breaking: true, + }, + { + sha: 'sha3', + message: 'docs: some documentation', + files: ['path1/file1.java'], + type: 'docs', + scope: null, + bareMessage: 'some documentation', + notes: [], + references: [], + breaking: false, + }, + { + sha: 'sha4', + message: 'fix!: some bugfix', + files: ['path1/file1.rb'], + type: 'fix', + scope: null, + bareMessage: 'some bugfix', + notes: [{title: 'RELEASE AS', text: '2.0.0'}], + references: [], + breaking: true, + }, + ]; + const strategy = new PrereleaseVersioningStrategy(); + const oldVersion = Version.parse('1.2.3'); + const newVersion = await strategy.bump(oldVersion, commits); + expect(newVersion.toString()).to.equal('3.1.2'); + }); + }); +});