Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: versionCompatibility #24717

Merged
merged 3 commits into from
Oct 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/usage/configuration-options.md
Original file line number Diff line number Diff line change
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"],
rarkins marked this conversation as resolved.
Show resolved Hide resolved
"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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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