Skip to content

Commit

Permalink
feat(versioning/semver): add semver-coerced versioning (renovatebot#1…
Browse files Browse the repository at this point in the history
…1995)

Co-authored-by: Rhys Arkins <rhys@arkins.net>
  • Loading branch information
olegkrivtsov and Rhys Arkins authored Oct 20, 2021
1 parent 0e7c50b commit 7cc72c7
Show file tree
Hide file tree
Showing 4 changed files with 348 additions and 0 deletions.
2 changes: 2 additions & 0 deletions lib/versioning/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import * as regex from './regex';
import * as rez from './rez';
import * as ruby from './ruby';
import * as semver from './semver';
import * as semverCoerced from './semver-coerced';
import * as swift from './swift';
import type { VersioningApi, VersioningApiConstructor } from './types';
import * as ubuntu from './ubuntu';
Expand All @@ -43,5 +44,6 @@ api.set('regex', regex.api);
api.set('rez', rez.api);
api.set('ruby', ruby.api);
api.set('semver', semver.api);
api.set('semver-coerced', semverCoerced.api);
api.set('swift', swift.api);
api.set('ubuntu', ubuntu.api);
231 changes: 231 additions & 0 deletions lib/versioning/semver-coerced/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import semverCoerced from '.';

describe('versioning/semver-coerced/index', () => {
describe('.equals(a, b)', () => {
it('should return true for strictly equal versions', () => {
expect(semverCoerced.equals('1.0.0', '1.0.0')).toBeTrue();
});

it('should return true for non-strictly equal versions', () => {
expect(semverCoerced.equals('v1.0', '1.0.0')).toBeTrue();
expect(semverCoerced.equals('v1.0', 'v1.x')).toBeTrue();
});

it('should return false for non-equal versions', () => {
expect(semverCoerced.equals('2.0.1', '2.3.0')).toBeFalse();
});
});

describe('.getMajor(input)', () => {
it('should return major version number for strict semver', () => {
expect(semverCoerced.getMajor('1.0.2')).toEqual(1);
});

it('should return major version number for non-strict semver', () => {
expect(semverCoerced.getMajor('v3.1')).toEqual(3);
});
});

describe('.getMinor(input)', () => {
it('should return minor version number for strict semver', () => {
expect(semverCoerced.getMinor('1.0.2')).toEqual(0);
});

it('should return minor version number for non-strict semver', () => {
expect(semverCoerced.getMinor('v3.1')).toEqual(1);
});
});

describe('.getPatch(input)', () => {
it('should return patch version number for strict semver', () => {
expect(semverCoerced.getPatch('1.0.2')).toEqual(2);
});

it('should return patch version number for non-strict semver', () => {
expect(semverCoerced.getPatch('v3.1.2-foo')).toEqual(2);
});
});

describe('.isCompatible(input)', () => {
it('should return true for strict semver', () => {
expect(semverCoerced.isCompatible('1.0.2')).toBeTruthy();
});

it('should return true for non-strict semver', () => {
expect(semverCoerced.isCompatible('v3.1.2-foo')).toBeTruthy();
});

it('should return false for non-semver', () => {
expect(semverCoerced.isCompatible('foo')).toBeFalsy();
});
});

describe('.isGreaterThan(a, b)', () => {
it('should return true for a greater version in strict semver', () => {
expect(semverCoerced.isGreaterThan('1.0.2', '1.0.0')).toBeTruthy();
});

it('should return false for lower version in strict semver', () => {
expect(semverCoerced.isGreaterThan('3.1.2', '4.1.0')).toBeFalsy();
});
});

describe('.isLessThanRange(version, range)', () => {
it('should return true for a lower version in strict semver', () => {
expect(semverCoerced.isLessThanRange('1.0.2', '~2.0')).toBeTruthy();
});

it('should return false for in-range version in strict semver', () => {
expect(semverCoerced.isLessThanRange('3.0.2', '~3.0')).toBeFalsy();
});
});

describe('.isSingleVersion()', () => {
it('returns true if naked version', () => {
expect(semverCoerced.isSingleVersion('1.2.3')).toBeTruthy();
expect(semverCoerced.isSingleVersion('1.2.3-alpha.1')).toBeTruthy();
});

it('returns false if equals', () => {
expect(semverCoerced.isSingleVersion('=1.2.3')).toBeFalsy();
expect(semverCoerced.isSingleVersion('= 1.2.3')).toBeFalsy();
});

it('returns false when not version', () => {
expect(semverCoerced.isSingleVersion('~1.0')).toBeFalsy();
});
});

describe('.isStable(input)', () => {
it('should return true for a stable version', () => {
expect(semverCoerced.isStable('1.0.0')).toBeTruthy();
});

it('should return false for an prerelease version', () => {
expect(semverCoerced.isStable('v1.0-alpha')).toBeFalsy();
});
});

describe('.isValid(input)', () => {
it('should return null for non-digit version strings', () => {
expect(semverCoerced.isValid('version two')).toBeFalsy();
});

it('should return null for irregular version strings', () => {
expect(semverCoerced.isValid('17.04.0')).toBeFalsy();
});

it('should support strict semver', () => {
expect(semverCoerced.isValid('1.2.3')).toBeTruthy();
});

it('should treat semver with dash as a valid version', () => {
expect(semverCoerced.isValid('1.2.3-foo')).toBeTruthy();
});

it('should treat semver without dash as a valid version', () => {
expect(semverCoerced.isValid('1.2.3foo')).toBeTruthy();
});

it('should treat ranges as valid versions', () => {
expect(semverCoerced.isValid('~1.2.3')).toBeTruthy();
expect(semverCoerced.isValid('^1.2.3')).toBeTruthy();
expect(semverCoerced.isValid('>1.2.3')).toBeTruthy();
});

it('should reject github repositories', () => {
expect(semverCoerced.isValid('renovatebot/renovate')).toBeFalsy();
expect(semverCoerced.isValid('renovatebot/renovate#master')).toBeFalsy();
expect(
semverCoerced.isValid('https://github.com/renovatebot/renovate.git')
).toBeFalsy();
});
});

describe('.isVersion(input)', () => {
it('should return null for non-digit versions', () => {
expect(semverCoerced.isValid('version one')).toBeFalsy();
});

it('should support strict semver versions', () => {
expect(semverCoerced.isValid('1.2.3')).toBeTruthy();
});

it('should support non-strict versions', () => {
expect(semverCoerced.isValid('v1.2')).toBeTruthy();
});
});

describe('.matches(version, range)', () => {
it('should return true when version is in range', () => {
expect(semverCoerced.matches('1.0.0', '1.0.0 || 1.0.1')).toBeTruthy();
});

it('should return true with non-strict version in range', () => {
expect(semverCoerced.matches('v1.0', '1.0.0 || 1.0.1')).toBeTruthy();
});

it('should return false when version is not in range', () => {
expect(semverCoerced.matches('1.2.3', '1.4.1 || 1.4.2')).toBeFalsy();
});
});

describe('.getSatisfyingVersion(versions, range)', () => {
it('should return max satisfying version in range', () => {
expect(
semverCoerced.getSatisfyingVersion(['1.0.0', '1.0.4'], '^1.0')
).toEqual('1.0.4');
});

it('should support coercion', () => {
expect(
semverCoerced.getSatisfyingVersion(['v1.0', '1.0.4-foo'], '^1.0')
).toEqual('1.0.4');
});
});

describe('.minSatisfyingVersion(versions, range)', () => {
it('should return min satisfying version in range', () => {
expect(
semverCoerced.minSatisfyingVersion(['1.0.0', '1.0.4'], '^1.0')
).toEqual('1.0.0');
});

it('should support coercion', () => {
expect(
semverCoerced.minSatisfyingVersion(['v1.0', '1.0.4-foo'], '^1.0')
).toEqual('1.0.0');
});
});

describe('getNewValue()', () => {
it('uses newVersion', () => {
expect(
semverCoerced.getNewValue({
currentValue: '=1.0.0',
rangeStrategy: 'bump',
currentVersion: '1.0.0',
newVersion: '1.1.0',
})
).toEqual('1.1.0');
});
});

describe('.sortVersions(a, b)', () => {
it('should return zero for equal versions', () => {
expect(semverCoerced.sortVersions('1.0.0', '1.0.0')).toEqual(0);
});

it('should return -1 for a < b', () => {
expect(semverCoerced.sortVersions('1.0.0', '1.0.1')).toEqual(-1);
});

it('should return 1 for a > b', () => {
expect(semverCoerced.sortVersions('1.0.1', '1.0.0')).toEqual(1);
});

it('should return zero for equal non-strict versions', () => {
expect(semverCoerced.sortVersions('v1.0', '1.x')).toEqual(0);
});
});
});
110 changes: 110 additions & 0 deletions lib/versioning/semver-coerced/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import semver, { SemVer } from 'semver';
import stable from 'semver-stable';
import { regEx } from '../../util/regex';
import type { NewValueConfig, VersioningApi } from '../types';

export const id = 'semver-coerced';
export const displayName = 'Coerced Semantic Versioning';
export const urls = ['https://semver.org/'];
export const supportsRanges = false;

const { is: isStable } = stable;

function sortVersions(a: string, b: string): number {
return semver.compare(semver.coerce(a), semver.coerce(b));
}

function getMajor(a: string | SemVer): number | null {
return semver.major(semver.coerce(a));
}

function getMinor(a: string | SemVer): number | null {
return semver.minor(semver.coerce(a));
}

function getPatch(a: string | SemVer): number | null {
return semver.patch(a);
}

function matches(version: string, range: string): boolean {
return semver.satisfies(semver.coerce(version), range);
}

function equals(a: string, b: string): boolean {
return semver.eq(semver.coerce(a), semver.coerce(b));
}

function isValid(version: string): string | boolean | null {
return semver.valid(semver.coerce(version));
}

function getSatisfyingVersion(
versions: string[],
range: string
): string | null {
const coercedVersions = versions.map((version) => {
const coercedVersion = semver.coerce(version);
return coercedVersion ? coercedVersion.version : null;
});
return semver.maxSatisfying(coercedVersions, range);
}

function minSatisfyingVersion(
versions: string[],
range: string
): string | null {
const coercedVersions = versions.map((version) => {
const coercedVersion = semver.coerce(version);
return coercedVersion ? coercedVersion.version : null;
});
return semver.minSatisfying(coercedVersions, range);
}

function isLessThanRange(version: string, range: string): boolean {
return semver.ltr(semver.coerce(version), range);
}

function isGreaterThan(version: string, other: string): boolean {
return semver.gt(semver.coerce(version), semver.coerce(other));
}

const startsWithNumberRegex = regEx(`^\\d`);

function isSingleVersion(version: string): string | boolean | null {
// Since coercion accepts ranges as well as versions, we have to manually
// check that the version string starts with either 'v' or a digit.
if (!version.startsWith('v') && !startsWithNumberRegex.exec(version)) {
return null;
}

return semver.valid(semver.coerce(version));
}

// If this is left as an alias, inputs like "17.04.0" throw errors
export const isVersion = (input: string): string | boolean => isValid(input);

export { isVersion as isValid, getSatisfyingVersion };

function getNewValue({ newVersion }: NewValueConfig): string {
return newVersion;
}

export const api: VersioningApi = {
equals,
getMajor,
getMinor,
getPatch,
isCompatible: isVersion,
isGreaterThan,
isLessThanRange,
isSingleVersion,
isStable,
isValid,
isVersion,
matches,
getSatisfyingVersion,
minSatisfyingVersion,
getNewValue,
sortVersions,
};
export default api;
5 changes: 5 additions & 0 deletions lib/versioning/semver-coerced/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Renovate's Coerced Semantic Versioning is a forgiving variant of [Semantic Versioning 2.0](https://semver.org) with coercion enabled for versions.

This versioning provides a very forgiving translation of inputs in non-strict-SemVer format into strict SemVer. For example, "v1" is coerced into "1.0.0", "2.1" => "2.1.0", "~3.1" => "3.1.0", "1.1-foo" => "1.1.0". Look at the Coercion section of [this page](https://www.npmjs.com/package/semver) for more info on input coercion.

Since this versioning is very forgiving, it doesn't actually provide the coercion for version ranges. The range functions only accept strict SemVer as input and equivalent to those provided by the Renovate's semver versioning.

0 comments on commit 7cc72c7

Please sign in to comment.