diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md index 7658749dac56e866781b0a780152d42cd1bc7895..7f8ad491624a8550920717a50511daad2aa2d699 100644 --- a/docs/usage/configuration-options.md +++ b/docs/usage/configuration-options.md @@ -3636,6 +3636,43 @@ Example: } ``` +## versionCompatibility + +This option is used for advanced use cases where the version string embeds more data than just the version. +It's typically used with docker and tags datasources. + +Here are two examples: + +- The image tag `ghcr.io/umami-software/umami:postgresql-v1.37.0` embeds text like `postgresql-` as a prefix to the actual version to differentiate different DB types. +- Docker image tags like `node:18.10.0-alpine` embed the base image as a suffix to the version. + +Here is an example of solving these types of cases: + +```json +{ + "packageRules": [ + { + "matchDatasources": ["docker"], + "matchPackageNames": ["ghcr.io/umami-software/umami"], + "versionCompatibility": "^(?<compatibility>.*)-(?<version>.*)$", + "versioning": "semver" + }, + { + "matchDatasources": ["docker"], + "matchPackageNames": ["node"], + "versionCompatibility": "^(?<version>.*)(?<compatibility>-.*)?$", + "versioning": "node" + } + ] +} +``` + +This feature is most useful when the `currentValue` is a version and not a range/constraint. + +This feature _can_ be used in combination with `extractVersion` although that's likely only a rare edge case. +When combined, `extractVersion` is applied to datasource results first, and then `versionCompatibility`. +`extractVersion` should be used when the raw version string returned by the `datasource` contains extra details (such as a `v` prefix) when compared to the value/version used within the repository. + ## versioning Usually, each language or package manager has a specific type of "versioning": diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts index 4a232fef74075f690f1e77c4d64adfbbd5127f72..f540e3fb88c7d545732366eca9236d3164522ab4 100644 --- a/lib/config/options/index.ts +++ b/lib/config/options/index.ts @@ -990,6 +990,15 @@ const options: RenovateOptions[] = [ cli: false, env: false, }, + { + name: 'versionCompatibility', + description: + 'A regex (`re2`) with named capture groups to show how version and compatibility are split from a raw version string.', + type: 'string', + format: 'regex', + cli: false, + env: false, + }, { name: 'versioning', description: 'Versioning to use for filtering and comparisons.', diff --git a/lib/modules/datasource/common.spec.ts b/lib/modules/datasource/common.spec.ts index 7a57cd455a613d365b67a2c33da2b822d2dcc030..fcf249fa14f0a4e8353da4245438960bfb1fb499 100644 --- a/lib/modules/datasource/common.spec.ts +++ b/lib/modules/datasource/common.spec.ts @@ -3,6 +3,7 @@ import { defaultVersioning } from '../versioning'; import { applyConstraintsFiltering, applyExtractVersion, + applyVersionCompatibility, filterValidVersions, getDatasourceFor, getDefaultVersioning, @@ -226,4 +227,41 @@ describe('modules/datasource/common', () => { }); }); }); + + describe('applyVersionCompatibility', () => { + let input: ReleaseResult; + + beforeEach(() => { + input = { + releases: [ + { version: '1.0.0' }, + { version: '2.0.0' }, + { version: '2.0.0-alpine' }, + ], + }; + }); + + it('returns immediately if no versionCompatibility', () => { + const result = applyVersionCompatibility(input, undefined, undefined); + expect(result).toBe(input); + }); + + it('filters out non-matching', () => { + const versionCompatibility = '^(?<version>[^-]+)$'; + expect( + applyVersionCompatibility(input, versionCompatibility, undefined) + ).toMatchObject({ + releases: [{ version: '1.0.0' }, { version: '2.0.0' }], + }); + }); + + it('filters out incompatible', () => { + const versionCompatibility = '^(?<version>[^-]+)(?<compatibility>.*)?$'; + expect( + applyVersionCompatibility(input, versionCompatibility, '-alpine') + ).toMatchObject({ + releases: [{ version: '2.0.0' }], + }); + }); + }); }); diff --git a/lib/modules/datasource/common.ts b/lib/modules/datasource/common.ts index c839724b7f8d5b704b65def8eba4743b6f615e05..335effc116945e45feadf7e32b127920b0c6908d 100644 --- a/lib/modules/datasource/common.ts +++ b/lib/modules/datasource/common.ts @@ -53,6 +53,31 @@ export function isGetPkgReleasesConfig( ); } +export function applyVersionCompatibility( + releaseResult: ReleaseResult, + versionCompatibility: string | undefined, + currentCompatibility: string | undefined +): ReleaseResult { + if (!versionCompatibility) { + return releaseResult; + } + + const versionCompatibilityRegEx = regEx(versionCompatibility); + releaseResult.releases = filterMap(releaseResult.releases, (release) => { + const regexResult = versionCompatibilityRegEx.exec(release.version); + if (!regexResult?.groups?.version) { + return null; + } + if (regexResult?.groups?.compatibility !== currentCompatibility) { + return null; + } + release.version = regexResult.groups.version; + return release; + }); + + return releaseResult; +} + export function applyExtractVersion( releaseResult: ReleaseResult, extractVersion: string | undefined diff --git a/lib/modules/datasource/index.ts b/lib/modules/datasource/index.ts index cd6b3b155f50f562db7a4af47a194712af946240..29fc0a39a06d92d316b263ec500d30ecfb0e3aa9 100644 --- a/lib/modules/datasource/index.ts +++ b/lib/modules/datasource/index.ts @@ -13,6 +13,7 @@ import datasources from './api'; import { applyConstraintsFiltering, applyExtractVersion, + applyVersionCompatibility, filterValidVersions, getDatasourceFor, sortAndRemoveDuplicates, @@ -363,6 +364,11 @@ export function applyDatasourceFilters( ): ReleaseResult { let res = releaseResult; res = applyExtractVersion(res, config.extractVersion); + res = applyVersionCompatibility( + res, + config.versionCompatibility, + config.currentCompatibility + ); res = filterValidVersions(res, config); res = sortAndRemoveDuplicates(res, config); res = applyConstraintsFiltering(res, config); diff --git a/lib/modules/datasource/types.ts b/lib/modules/datasource/types.ts index 4d47e3fbc46b866fddd0c2ada5ecf2a29e9d6e36..7cd06d0697b06b741a26331455539d3baf8d16ab 100644 --- a/lib/modules/datasource/types.ts +++ b/lib/modules/datasource/types.ts @@ -39,6 +39,8 @@ export interface GetPkgReleasesConfig { packageName: string; versioning?: string; extractVersion?: string; + versionCompatibility?: string; + currentCompatibility?: string; constraints?: Record<string, string>; replacementName?: string; replacementVersion?: string; diff --git a/lib/workers/repository/process/lookup/index.spec.ts b/lib/workers/repository/process/lookup/index.spec.ts index 3affbb7634c1d66342016103ee45093c94c8ccac..3b0ea3fa6b33c49ad4f9d3c40bc989fbf8ca32c3 100644 --- a/lib/workers/repository/process/lookup/index.spec.ts +++ b/lib/workers/repository/process/lookup/index.spec.ts @@ -1744,6 +1744,44 @@ describe('workers/repository/process/lookup/index', () => { }); }); + it('applies versionCompatibility for 18.10.0', async () => { + config.currentValue = '18.10.0-alpine'; + config.packageName = 'node'; + config.versioning = nodeVersioningId; + config.versionCompatibility = '^(?<version>[^-]+)(?<compatibility>-.*)?$'; + config.datasource = DockerDatasource.id; + getDockerReleases.mockResolvedValueOnce({ + releases: [ + { version: '18.18.0' }, + { version: '18.19.0-alpine' }, + { version: '18.20.0' }, + ], + }); + const res = await lookup.lookupUpdates(config); + expect(res).toMatchObject({ + updates: [{ newValue: '18.19.0-alpine', updateType: 'minor' }], + }); + }); + + it('handles versionCompatibility mismatch', async () => { + config.currentValue = '18.10.0-alpine'; + config.packageName = 'node'; + config.versioning = nodeVersioningId; + config.versionCompatibility = '^(?<version>[^-]+)-slim$'; + config.datasource = DockerDatasource.id; + getDockerReleases.mockResolvedValueOnce({ + releases: [ + { version: '18.18.0' }, + { version: '18.19.0-alpine' }, + { version: '18.20.0' }, + ], + }); + const res = await lookup.lookupUpdates(config); + expect(res).toMatchObject({ + updates: [], + }); + }); + it('handles digest pin for up to date version', async () => { config.currentValue = '8.1.0'; config.packageName = 'node'; diff --git a/lib/workers/repository/process/lookup/index.ts b/lib/workers/repository/process/lookup/index.ts index d24af3939af89867bd43738a7f33217801528f96..f8f9b4426bdc676c6b94cff268d5709b6355612a 100644 --- a/lib/workers/repository/process/lookup/index.ts +++ b/lib/workers/repository/process/lookup/index.ts @@ -70,14 +70,43 @@ export async function lookupUpdates( res.skipReason = 'invalid-config'; return res; } - const isValid = - is.string(config.currentValue) && versioning.isValid(config.currentValue); + let compareValue = config.currentValue; + if ( + is.string(config.currentValue) && + is.string(config.versionCompatibility) + ) { + const versionCompatbilityRegEx = regEx(config.versionCompatibility); + const regexMatch = versionCompatbilityRegEx.exec(config.currentValue); + if (regexMatch?.groups) { + logger.debug( + { + versionCompatibility: config.versionCompatibility, + currentValue: config.currentValue, + packageName: config.packageName, + groups: regexMatch.groups, + }, + 'version compatibility regex match' + ); + config.currentCompatibility = regexMatch.groups.compatibility; + compareValue = regexMatch.groups.version; + } else { + logger.debug( + { + versionCompatibility: config.versionCompatibility, + currentValue: config.currentValue, + packageName: config.packageName, + }, + 'version compatibility regex mismatch' + ); + } + } + const isValid = is.string(compareValue) && versioning.isValid(compareValue); if (unconstrainedValue || isValid) { if ( !config.updatePinnedDependencies && // TODO #22198 - versioning.isSingleVersion(config.currentValue!) + versioning.isSingleVersion(compareValue!) ) { res.skipReason = 'is-pinned'; return res; @@ -163,16 +192,15 @@ export async function lookupUpdates( allVersions = allVersions.filter( (v) => v.version === taggedVersion || - (v.version === config.currentValue && - versioning.isGreaterThan(taggedVersion, config.currentValue)) + (v.version === compareValue && + versioning.isGreaterThan(taggedVersion, compareValue)) ); } // Check that existing constraint can be satisfied const allSatisfyingVersions = allVersions.filter( (v) => // TODO #22198 - unconstrainedValue || - versioning.matches(v.version, config.currentValue!) + unconstrainedValue || versioning.matches(v.version, compareValue!) ); if (!allSatisfyingVersions.length) { logger.debug( @@ -187,7 +215,7 @@ export async function lookupUpdates( res.warnings.push({ topic: config.packageName, // TODO: types (#22198) - message: `Can't find version matching ${config.currentValue!} for ${ + message: `Can't find version matching ${compareValue!} for ${ config.datasource } package ${config.packageName}`, }); @@ -215,7 +243,7 @@ export async function lookupUpdates( // TODO #22198 currentVersion ??= getCurrentVersion( - config.currentValue!, + compareValue!, config.lockedVersion!, versioning, rangeStrategy!, @@ -223,7 +251,7 @@ export async function lookupUpdates( nonDeprecatedVersions ) ?? getCurrentVersion( - config.currentValue!, + compareValue!, config.lockedVersion!, versioning, rangeStrategy!, @@ -236,17 +264,17 @@ export async function lookupUpdates( } res.currentVersion = currentVersion!; if ( - config.currentValue && + compareValue && currentVersion && rangeStrategy === 'pin' && - !versioning.isSingleVersion(config.currentValue) + !versioning.isSingleVersion(compareValue) ) { res.updates.push({ updateType: 'pin', isPin: true, // TODO: newValue can be null! (#22198) newValue: versioning.getNewValue({ - currentValue: config.currentValue, + currentValue: compareValue, rangeStrategy, currentVersion, newVersion: currentVersion, @@ -277,8 +305,7 @@ export async function lookupUpdates( ).filter( (v) => // Leave only compatible versions - unconstrainedValue || - versioning.isCompatible(v.version, config.currentValue) + unconstrainedValue || versioning.isCompatible(v.version, compareValue) ); if (config.isVulnerabilityAlert && !config.osvVulnerabilityAlerts) { filteredReleases = filteredReleases.slice(0, 1); @@ -335,7 +362,7 @@ export async function lookupUpdates( if (pendingReleases!.length) { update.pendingVersions = pendingReleases!.map((r) => r.version); } - if (!update.newValue || update.newValue === config.currentValue) { + if (!update.newValue || update.newValue === compareValue) { if (!config.lockedVersion) { continue; } @@ -360,9 +387,9 @@ export async function lookupUpdates( res.updates.push(update); } - } else if (config.currentValue) { + } else if (compareValue) { logger.debug( - `Dependency ${config.packageName} has unsupported/unversioned value ${config.currentValue} (versioning=${config.versioning})` + `Dependency ${config.packageName} has unsupported/unversioned value ${compareValue} (versioning=${config.versioning})` ); if (!config.pinDigests && !config.currentDigest) { @@ -382,11 +409,8 @@ export async function lookupUpdates( if (config.lockedVersion) { res.currentVersion = config.lockedVersion; res.fixedVersion = config.lockedVersion; - } else if ( - config.currentValue && - versioning.isSingleVersion(config.currentValue) - ) { - res.fixedVersion = config.currentValue.replace(regEx(/^=+/), ''); + } else if (compareValue && versioning.isSingleVersion(compareValue)) { + res.fixedVersion = compareValue.replace(regEx(/^=+/), ''); } // Add digests if necessary if (supportsDigests(config.datasource)) { @@ -423,6 +447,23 @@ export async function lookupUpdates( config.registryUrls = [res.registryUrl]; } + // massage versionCompatibility + if ( + is.string(config.currentValue) && + is.string(compareValue) && + is.string(config.versionCompatibility) + ) { + for (const update of res.updates) { + logger.debug({ update }); + if (is.string(config.currentValue)) { + update.newValue = config.currentValue.replace( + compareValue, + update.newValue + ); + } + } + } + // update digest for all for (const update of res.updates) { if (config.pinDigests === true || config.currentDigest) {