Skip to content

Commit

Permalink
feat: versionCompatibility (#24717)
Browse files Browse the repository at this point in the history
  • Loading branch information
rarkins committed Oct 2, 2023
1 parent d847715 commit 42b3a7c
Show file tree
Hide file tree
Showing 8 changed files with 219 additions and 23 deletions.
37 changes: 37 additions & 0 deletions docs/usage/configuration-options.md
Expand Up @@ -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":
Expand Down
9 changes: 9 additions & 0 deletions lib/config/options/index.ts
Expand Up @@ -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.',
Expand Down
38 changes: 38 additions & 0 deletions lib/modules/datasource/common.spec.ts
Expand Up @@ -3,6 +3,7 @@ import { defaultVersioning } from '../versioning';
import {
applyConstraintsFiltering,
applyExtractVersion,
applyVersionCompatibility,
filterValidVersions,
getDatasourceFor,
getDefaultVersioning,
Expand Down Expand Up @@ -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' }],
});
});
});
});
25 changes: 25 additions & 0 deletions lib/modules/datasource/common.ts
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions lib/modules/datasource/index.ts
Expand Up @@ -13,6 +13,7 @@ import datasources from './api';
import {
applyConstraintsFiltering,
applyExtractVersion,
applyVersionCompatibility,
filterValidVersions,
getDatasourceFor,
sortAndRemoveDuplicates,
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions lib/modules/datasource/types.ts
Expand Up @@ -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;
Expand Down
38 changes: 38 additions & 0 deletions lib/workers/repository/process/lookup/index.spec.ts
Expand Up @@ -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';
Expand Down
87 changes: 64 additions & 23 deletions lib/workers/repository/process/lookup/index.ts
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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}`,
});
Expand Down Expand Up @@ -215,15 +243,15 @@ export async function lookupUpdates(
// TODO #22198
currentVersion ??=
getCurrentVersion(
config.currentValue!,
compareValue!,
config.lockedVersion!,
versioning,
rangeStrategy!,
latestVersion!,
nonDeprecatedVersions
) ??
getCurrentVersion(
config.currentValue!,
compareValue!,
config.lockedVersion!,
versioning,
rangeStrategy!,
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand All @@ -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) {
Expand All @@ -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)) {
Expand Down Expand Up @@ -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) {
Expand Down

0 comments on commit 42b3a7c

Please sign in to comment.