diff --git a/lib/modules/datasource/repology/readme.md b/lib/modules/datasource/repology/readme.md index c6589c338637349be8ccdcbc8151b8f6683d9ddd..001015b3f7cfe5b68379467c1bbf4e4868ec1dae 100644 --- a/lib/modules/datasource/repology/readme.md +++ b/lib/modules/datasource/repology/readme.md @@ -49,5 +49,5 @@ When the operating system package for `gcc` of `Alpine Linux 3.12` is updated, R <!-- prettier-ignore --> !!! tip - We recommend you try `loose` versioning for distribution packages first. + We recommend you try `loose` or `deb` versioning for distribution packages first. This is because the version number usually doesn't match Renovate's default `semver` specification. diff --git a/lib/modules/versioning/api.ts b/lib/modules/versioning/api.ts index 089d31360de60c8d4b9e0e0178db26197440aa35..3fc867e4472a721a90dcd50b2d5072213e6bcc87 100644 --- a/lib/modules/versioning/api.ts +++ b/lib/modules/versioning/api.ts @@ -2,6 +2,7 @@ import * as amazonMachineImage from './aws-machine-image'; import * as cargo from './cargo'; import * as composer from './composer'; import * as conan from './conan'; +import * as deb from './deb'; import * as debian from './debian'; import * as docker from './docker'; import * as git from './git'; @@ -40,6 +41,7 @@ api.set(amazonMachineImage.id, amazonMachineImage.api); api.set(cargo.id, cargo.api); api.set(composer.id, composer.api); api.set(conan.id, conan.api); +api.set(deb.id, deb.api); api.set(debian.id, debian.api); api.set(docker.id, docker.api); api.set(git.id, git.api); diff --git a/lib/modules/versioning/deb/index.spec.ts b/lib/modules/versioning/deb/index.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..f571a9674b6f45b5ddeb9b0ea1e2b54b571aca02 --- /dev/null +++ b/lib/modules/versioning/deb/index.spec.ts @@ -0,0 +1,174 @@ +import deb from '.'; + +describe('modules/versioning/deb/index', () => { + test.each` + version | expected + ${'1.1'} | ${true} + ${'1.3.RC2'} | ${true} + ${'0:1.1-1'} | ${true} + ${'a:1.1-1'} | ${false} + ${'1.1:1.3-1'} | ${false} + ${'1.1a:1.3-1'} | ${false} + ${'1a:1.3-1'} | ${false} + ${'-1:1.3-1'} | ${false} + ${'1:1:1:2-1'} | ${true} + ${'1:a:b:c:2-1'} | ${true} + ${'1:3_3.2-1'} | ${false} + ${'1:3!3.2-1'} | ${false} + ${'1:3/3.2-1'} | ${false} + ${'1.0-3_2'} | ${false} + ${'1.0-3!3'} | ${false} + ${'1.0-3/3'} | ${false} + ${'1.0+ä1-1'} | ${false} + ${'1,0-1'} | ${false} + ${'2:1.1-1'} | ${true} + ${'1.1.1-0debian1'} | ${true} + ${'1.1.1+really1.1.2-0debian1'} | ${true} + ${'2.31-13+deb11u5'} | ${true} + ${'1:4.14+20190211-1ubuntu1'} | ${true} + ${'2.7.7+dfsg-12'} | ${true} + ${'9.5.0-1ubuntu1~22.04'} | ${true} + ${'5:20.10.17~3-0~ubuntu-focal'} | ${true} + ${'1:6.0.1r16-1.1build1'} | ${true} + ${'2:102.12+LibO7.3.7-0ubuntu0.22.04.1'} | ${true} + ${'1:2.20.1-1~bpo9+1'} | ${true} + ${'v1.4'} | ${true} + ${'3.5.0'} | ${true} + ${'4.2.21.Final'} | ${true} + ${'0.6.5.1'} | ${true} + ${'20100527'} | ${true} + ${'2.1.0-M3'} | ${true} + ${'4.3.20.RELEASE'} | ${true} + ${'1.1-groovy-2.4'} | ${true} + ${'0.8a'} | ${true} + ${'3.1.0.GA'} | ${true} + ${'3.0.0-beta.3'} | ${true} + ${'foo'} | ${true} + ${'1.2.3.4.5.6.7'} | ${true} + ${'0a1b2c3'} | ${true} + ${'0a1b2c3d'} | ${true} + ${'0a1b2c3d4e5f6a7b8c9d0a1b2c3d4e5f6a7b8c9d'} | ${true} + ${'0a1b2c3d4e5f6a7b8c9d0a1b2c3d4e5f6a7b8c9d0'} | ${true} + ${'0a1b2C3'} | ${true} + ${'0z1b2c3'} | ${true} + ${'0A1b2c3d4e5f6a7b8c9d0a1b2c3d4e5f6a7b8c9d'} | ${true} + ${'123098140293'} | ${true} + `('isValid("$version") === $expected', ({ version, expected }) => { + expect(deb.isValid(version)).toBe(expected); + }); + + test.each` + a | b | expected + ${'2.4'} | ${'2.4'} | ${true} + ${'2.4.0'} | ${'2.4.0'} | ${true} + ${'2.4.0'} | ${'2.4'} | ${false} + ${'2.4.1'} | ${'2.4'} | ${false} + ${'2.4.2'} | ${'2.4.1'} | ${false} + ${'0.8a'} | ${'0.8a'} | ${true} + ${'9.5.0-1ubuntu1~22.04'} | ${'9.5.0-1ubuntu1'} | ${false} + ${'9.5.0-1ubuntu1~22.04'} | ${'9.5.0-1ubuntu1~20.04'} | ${false} + ${'9.5.0-1ubuntu1~22.04'} | ${'9.5.0-1ubuntu1~22.04'} | ${true} + ${'2.31-13+deb11u5'} | ${'2.31-13+deb11u5'} | ${true} + ${'2.31-13+deb11u5'} | ${'2.31-13+deb11u4'} | ${false} + ${'1.4-'} | ${'1.4'} | ${false} + ${'v1.4'} | ${'1.4'} | ${false} + ${'0:1.4'} | ${'1.4'} | ${true} + ${'1:1.4'} | ${'1.4'} | ${false} + ${'1.4-1'} | ${'1.4-2'} | ${false} + ${'0:1.4'} | ${'a:1.4'} | ${false} + ${'a:1.4'} | ${'0:1.4'} | ${false} + `('equals("$a", "$b") === $expected', ({ a, b, expected }) => { + expect(deb.equals(a, b)).toBe(expected); + }); + + test.each` + a | b | expected + ${'2.4.0'} | ${'2.4'} | ${true} + ${'2.4.2'} | ${'2.4.1'} | ${true} + ${'2.4.beta'} | ${'2.4.alpha'} | ${true} + ${'1.9'} | ${'2'} | ${false} + ${'1.9'} | ${'1.9.1'} | ${false} + ${'2.4'} | ${'2.4.beta'} | ${false} + ${'2.4.0'} | ${'2.4.beta'} | ${false} + ${'2.4.beta'} | ${'2.4'} | ${true} + ${'2.4.beta'} | ${'2.4.0'} | ${true} + ${'2.4~'} | ${'2.4~~'} | ${true} + ${'2.4'} | ${'2.4~'} | ${true} + ${'2.4a'} | ${'2.4'} | ${true} + ${'2.31-13+deb11u5'} | ${'2.31-9'} | ${true} + ${'2.31-13+deb11u5'} | ${'2.31-13+deb10u5'} | ${true} + ${'2.31-13+deb11u5'} | ${'2.31-13+deb11u4'} | ${true} + ${'1.9'} | ${'1:1.7'} | ${false} + ${'1.9'} | ${'1.12'} | ${false} + ${'1.12'} | ${'1.9'} | ${true} + ${'1:1.9'} | ${'1:1.7'} | ${true} + ${'2.4.0.beta1'} | ${'2.4.0.Beta1'} | ${true} + ${'1:1.0'} | ${'1:1.0~'} | ${true} + ${'1:1.0Z0-0'} | ${'1:1.0'} | ${true} + ${'1:1.0Z0-0'} | ${'1:1.0A0-0'} | ${true} + ${'1:1.0a0-0'} | ${'1:1.0Z0-0'} | ${true} + ${'1:1.0z0-0'} | ${'1:1.0a0-0'} | ${true} + ${'1:1.0+0-0'} | ${'1:1.0z0-0'} | ${true} + ${'1:1.0-0-0'} | ${'1:1.0+0-0'} | ${true} + ${'1:1.0.0-0'} | ${'1:1.0-0-0'} | ${true} + ${'1:1.0:0-0'} | ${'1:1.0.0-0'} | ${true} + ${'a:1.4'} | ${'0:1.4'} | ${true} + ${'0:1.4'} | ${'a:1.4'} | ${true} + ${'a:1.4'} | ${'a:1.4'} | ${true} + ${'a1'} | ${'a~'} | ${true} + ${'a0'} | ${'a~'} | ${true} + ${'aa'} | ${'a1'} | ${true} + ${'ab'} | ${'a0'} | ${true} + ${'10'} | ${'1.'} | ${true} + ${'10'} | ${'1a'} | ${true} + `('isGreaterThan("$a", "$b") === $expected', ({ a, b, expected }) => { + expect(deb.isGreaterThan(a, b)).toBe(expected); + }); + + test.each` + version | expected + ${'1.2.0'} | ${true} + ${'^1.2.0'} | ${false} + `('isSingleVersion("$version") === $expected', ({ version, expected }) => { + expect(deb.isSingleVersion(version)).toBe(expected); + }); + + test.each` + version | expected + ${'v1.3.0'} | ${1} + ${'2-0-1'} | ${2} + ${'2.31-13+deb11u5'} | ${2} + ${'1:2.3.1'} | ${2} + ${'foo'} | ${null} + ${'8'} | ${8} + ${'1.0'} | ${1} + `('getMajor("$version") === $expected', ({ version, expected }) => { + expect(deb.getMajor(version)).toBe(expected); + }); + + test.each` + version | expected + ${'v1.3.0'} | ${3} + ${'2-0-1'} | ${0} + ${'2.31-13+deb11u5'} | ${31} + ${'1:2.3.1'} | ${3} + ${'foo'} | ${null} + ${'8'} | ${null} + ${'1.0'} | ${0} + `('getMinor("$version") === $expected', ({ version, expected }) => { + expect(deb.getMinor(version)).toBe(expected); + }); + + test.each` + version | expected + ${'v1.3.0'} | ${0} + ${'2-0-1'} | ${1} + ${'2.31-13+deb11u5'} | ${13} + ${'1:2.3.1'} | ${1} + ${'foo'} | ${null} + ${'8'} | ${null} + ${'1.0'} | ${null} + `('getPatch("$version") === $expected', ({ version, expected }) => { + expect(deb.getPatch(version)).toBe(expected); + }); +}); diff --git a/lib/modules/versioning/deb/index.ts b/lib/modules/versioning/deb/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..30e9471633bf6d03e25fb449cc853def99e1c810 --- /dev/null +++ b/lib/modules/versioning/deb/index.ts @@ -0,0 +1,148 @@ +import { regEx } from '../../../util/regex'; +import { GenericVersion, GenericVersioningApi } from '../generic'; +import type { VersioningApi } from '../types'; + +export const id = 'deb'; +export const displayName = 'Deb version'; +export const urls = [ + 'https://www.debian.org/doc/debian-policy/ch-controlfields.html#version', + 'https://manpages.debian.org/unstable/dpkg-dev/deb-version.7.en.html', +]; +export const supportsRanges = false; + +const epochPattern = regEx(/^\d+$/); +const upstreamVersionPattern = regEx(/^[-+.:~A-Za-z\d]+$/); +const debianRevisionPattern = regEx(/^[+.~A-Za-z\d]*$/); +const numericPattern = regEx(/\d+/g); +const characterOrder = + '~ ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz+-.:'; +const numericChars = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; + +export interface DebVersion extends GenericVersion { + /** + * epoch, defaults to 0 if not present, are used to leave version mistakes and previous + * versioning schemes behind. + */ + epoch: number; + /** + * upstreamVersion is the main version part: it defines the version of origin software + * that was packaged. + */ + upstreamVersion: string; + /** + * debianRevision is used to distinguish between different versions of packaging for the + * same upstream version. + */ + debianRevision: string; +} + +class DebVersioningApi extends GenericVersioningApi { + protected _parse(version: string): DebVersion | null { + /* Splitting version into "[epoch:]upstream-version[-debian-revision]" + All found numbers are exported as release info */ + + // split of first element by `:` as epoch: + const epochSplit = version.split(':'); + const epochStr = epochSplit.length > 1 ? epochSplit.shift()! : '0'; + const remainingVersion = epochSplit.join(':'); + + // split of last element by `-` + if (remainingVersion.endsWith('-')) { + // Forbid debian revision (it would result in `2.4.0-` == `2.4.0`) + return null; + } + const debianSplit = remainingVersion.split('-'); + const debianRevision = debianSplit.length > 1 ? debianSplit.pop()! : ''; + const upstreamVersion = debianSplit.join('-'); + + if ( + !epochPattern.test(epochStr) || + !upstreamVersionPattern.test(upstreamVersion) || + !debianRevisionPattern.test(debianRevision) + ) { + return null; + } + const release = [...remainingVersion.matchAll(numericPattern)].map((m) => + parseInt(m[0], 10) + ); + return { + epoch: parseInt(epochStr, 10), + upstreamVersion, + debianRevision, + release, + suffix: debianRevision, + }; + } + + protected _compare_string(a: string, b: string): number { + /* Special string sorting based on official specification: + * https://manpages.debian.org/unstable/dpkg-dev/deb-version.7.en.html#Sorting_algorithm + * The string is compared by continuous blocks of a) non-digit and b) digit characters. + * Non-digit blocks are compared lexicographically with a custom character order. + * Digit blocks are compared numerically. + * We are alternating between both modes until a difference is found. + */ + let charPos = 0; + while (charPos < a.length || charPos < b.length) { + const aChar = a.charAt(charPos); + const bChar = b.charAt(charPos); + if (numericChars.includes(aChar) && numericChars.includes(bChar)) { + // numeric comparison of the whole block + let aNumericEnd = charPos + 1; + while (numericChars.includes(a.charAt(aNumericEnd))) { + aNumericEnd += 1; + } + let bNumericEnd = charPos + 1; + while (numericChars.includes(b.charAt(bNumericEnd))) { + bNumericEnd += 1; + } + const numericCmp = a + .substring(charPos, aNumericEnd) + .localeCompare(b.substring(charPos, bNumericEnd), undefined, { + numeric: true, + }); + if (numericCmp !== 0) { + return numericCmp; + } + charPos = aNumericEnd; // the same as bNumericEnd as both are the same + continue; + } + if (aChar !== bChar) { + // Lexicographical comparison + // numeric character is treated like end of string (they are part of a new block) + const aPriority = characterOrder.indexOf( + numericChars.includes(aChar) || aChar === '' ? ' ' : aChar + ); + const bPriority = characterOrder.indexOf( + numericChars.includes(bChar) || bChar === '' ? ' ' : bChar + ); + return Math.sign(aPriority - bPriority); + } + charPos += 1; + } + return 0; + } + + protected override _compare(version: string, other: string): number { + const parsed1 = this._parse(version); + const parsed2 = this._parse(other); + if (!(parsed1 && parsed2)) { + return 1; + } + if (parsed1.epoch !== parsed2.epoch) { + return Math.sign(parsed1.epoch - parsed2.epoch); + } + const upstreamVersionDifference = this._compare_string( + parsed1.upstreamVersion, + parsed2.upstreamVersion + ); + if (upstreamVersionDifference !== 0) { + return upstreamVersionDifference; + } + return this._compare_string(parsed1.debianRevision, parsed2.debianRevision); + } +} + +export const api: VersioningApi = new DebVersioningApi(); + +export default api; diff --git a/lib/modules/versioning/deb/readme.md b/lib/modules/versioning/deb/readme.md new file mode 100644 index 0000000000000000000000000000000000000000..1cbfe967baba5a94a88f6c00083f996a502f0b44 --- /dev/null +++ b/lib/modules/versioning/deb/readme.md @@ -0,0 +1,3 @@ +Deb versioning compares versions like package managers on Debian-based Linux distributions compare packages (`dpkg`, `apt`). +Deb versioning supports complicated version numbers, including alphabetical characters almost everywhere. +Similar to our "loose" versioning, deb versioning is a "best effort" attempt to convert deb versions to SemVer fields like `major`, `minor`, `patch`.