mirror of
https://github.com/renovatebot/renovate.git
synced 2026-10-31 17:51:49 +00:00
b22f58045e
* fix(manager): handle registryAliases with shell variable defaults
Allows `registryAliases` keys to contain shell-style variables with
empty (or other) defaults, e.g. `${DEPENDENCY_PROXY:-}` or
`${CI_REGISTRY:-}`. The same image string is then resolved both
online (variable expanded) and offline (variable empty).
Changes in `dockerfile/extract.ts` (used by `gitlabci` too):
* Match the alias key as either `${name}/...` (existing) or `${name}...`
when `name` already ends in a non-identifier boundary character such
as `}`. Bare-identifier keys like `$CI_REGISTRY` continue to require
the slash boundary so they no longer overmatch keys that begin with
the same prefix (e.g. `$CI_REGISTRY` vs `$CI_REGISTRY_IMAGE`).
* Use `ensureTrailingSlash` to handle alias values that already include
a trailing slash.
* Preserve the original `name` and path structure in `dep.depName`
instead of substringing only after the alias `value`. This keeps the
variable in the depName so it survives a `splitImageParts` round-trip
and the resulting branch name comes out clean.
Adds `sanitizeDepName` regex passes in
`lib/workers/repository/updates/flatten.ts` so any leftover
`${VAR}`/`${VAR:-...}` placeholder is stripped from the depName before
it becomes a branch name (otherwise we'd get
`renovate/${dependency_proxy}python-3.x` instead of
`renovate/python-3.x`).
Tests cover:
* `${VAR:-}/image` (with slash) and `${VAR:-}image` (no slash, requires
the boundary form).
* Overlapping prefixes: `$CI_REGISTRY` vs `$CI_REGISTRY_IMAGE`.
* Registry alias with port (`localhost:5000/repo`).
* `sanitizeDepName` strips `${VAR}` / `${VAR:-}` / bare `$VAR`.
Reproducer: https://github.com/Wayneoween/renovate-36293-repro
* revert(manager/gitlabci): keep regex group name as 'prefix'
Reverting the unrelated `prefix` -> `rawPrefix` rename in `depProxyRe`.
Per review on #36293, this rename is out of scope for the registry-alias
fix and just adds review surface.
* refactor(manager/dockerfile): narrow alias boundary check to '}'
Per review on #36293. The previous regex `/[^A-Za-z0-9_]$/` matched any
non-word ending, which is broader than the documented case (alias keys
ending in `}` from `${VAR}` / `${VAR:-...}`). Replace with a plain
`name.endsWith('}')` check, which also avoids re-building a regex on
every iteration of the alias loop.
Existing tests (`'variables with default and slash'`,
`'variables with default no slash'`, `'alias with digest only'`)
continue to exercise the `}`-boundary path; the `'overlapping prefixes'`
case continues to exercise the slash-only path.
* chore(manager/dockerfile): trim comment and TODO library/ strip edge
Per review on #36293:
* Drop the verbose explanation block above the depName-restore branch;
the spec rows already document the expected shape.
* Add a TODO above the `dep.depName?.startsWith(valueWithSlash)` guard
noting a pre-existing edge case: when the inner `getDep` strips a
`library/` prefix (or similar) the depName no longer starts with
`valueWithSlash`, so the alias-rooted depName is not restored. Out of
scope for this PR.
* fix(manager/dockerfile): handle empty registry alias value
- strip trailing slash after ${VAR} placeholders in sanitizeDepName
- treat an empty registry alias value as Docker Hub instead of '/'
- clarify registry alias docs wording
* refactor(manager/dockerfile): drop redundant alias boundary guard
- `matchedAtBoundary` is only read behind `!matchedWithSlash`
- add docker-compose test for `${VAR:-}` registry aliases
* test(manager/dockerfile): pin special-prefix registry alias behavior
`getDep` strips `library/`, `amd64/` and `arm64/` as special prefixes, so
an alias whose value is one of those loses its alias-rooted `depName`.
Both the slash form and the variable form behave this way on `main`.
478 lines
14 KiB
TypeScript
478 lines
14 KiB
TypeScript
import { isNumber } from '@sindresorhus/is';
|
|
import type { RenovateConfig } from '~test/util.ts';
|
|
import { getConfig } from '../../../config/defaults.ts';
|
|
import type { PackageFile } from '../../../modules/manager/types.ts';
|
|
import type { SkipReason } from '../../../types/skip-reason.ts';
|
|
import { flattenUpdates, sanitizeDepName } from './flatten.ts';
|
|
|
|
vi.mock('../../../util/git/semantic.ts');
|
|
|
|
let config: RenovateConfig;
|
|
|
|
beforeEach(() => {
|
|
config = getConfig();
|
|
config.errors = [];
|
|
config.warnings = [];
|
|
});
|
|
|
|
describe('workers/repository/updates/flatten', () => {
|
|
describe('sanitizeDepName()', () => {
|
|
it('sanitizes urls', () => {
|
|
expect(sanitizeDepName('https://some.host.name/a/path/to.git')).toBe(
|
|
'https-some.host.name-a-path-to.git',
|
|
);
|
|
});
|
|
|
|
it('strips shell-style variable placeholders', () => {
|
|
expect(sanitizeDepName('${DEPENDENCY_PROXY}python')).toBe('python');
|
|
expect(sanitizeDepName('${DEPENDENCY_PROXY:-}python')).toBe('python');
|
|
expect(sanitizeDepName('${DEPENDENCY_PROXY}/python')).toBe('python');
|
|
expect(sanitizeDepName('${DEPENDENCY_PROXY:-}/python')).toBe('python');
|
|
expect(sanitizeDepName('$CI_REGISTRY/image')).toBe('ci_registry-image');
|
|
});
|
|
});
|
|
|
|
describe('flattenUpdates()', () => {
|
|
it('flattens', async () => {
|
|
// TODO #22198
|
|
config.lockFileMaintenance!.enabled = true;
|
|
config.packageRules = [
|
|
{
|
|
matchUpdateTypes: ['minor'],
|
|
automerge: true,
|
|
},
|
|
{
|
|
matchFileNames: ['frontend/package.json'],
|
|
lockFileMaintenance: {
|
|
enabled: false,
|
|
},
|
|
},
|
|
{
|
|
matchPackageNames: ['@monorepo/package'],
|
|
sourceUrl: 'https://github.com/some/monorepo',
|
|
sourceDirectory: "subfolder/{{ lookup (split packageName '/') 1 }}",
|
|
},
|
|
];
|
|
config.remediations = {
|
|
'package-lock.json': [
|
|
{
|
|
datasoource: 'npm',
|
|
depName: 'foo',
|
|
currentVersion: '1.2.0',
|
|
newVersion: '1.3.0',
|
|
prBodyNotes: '',
|
|
},
|
|
],
|
|
};
|
|
const packageFiles = {
|
|
npm: [
|
|
{
|
|
packageFile: 'package.json',
|
|
lockFiles: ['package-lock.json'],
|
|
deps: [
|
|
{
|
|
depName: '@org/a',
|
|
updates: [
|
|
{
|
|
newValue: '1.0.0',
|
|
sourceUrl: 'https://github.com/org/repo',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
depName: 'foo',
|
|
updates: [
|
|
{
|
|
newValue: '2.0.0',
|
|
sourceUrl: 'https://github.com/org/repo',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
depName: '@monorepo/package',
|
|
updates: [
|
|
{
|
|
newValue: '2.0.0',
|
|
sourceUrl: 'https://github.com/some/monorepo',
|
|
sourceDirectory:
|
|
"subfolder/{{ lookup (split depName '/') 1 }}",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
updateTypes: ['pin'],
|
|
updates: [{ newValue: '2.0.0' }],
|
|
},
|
|
{
|
|
depName: 'abc',
|
|
updates: [
|
|
{
|
|
newName: 'def',
|
|
newValue: '2.0.0',
|
|
updateType: 'replacement',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
packageFile: 'backend/package.json',
|
|
deps: [
|
|
{
|
|
depName: 'bar',
|
|
updates: [{ newValue: '3.0.0', sourceUrl: 3 }],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
packageFile: 'frontend/package.json',
|
|
deps: [{ depName: 'baz', updates: [{ newValue: '3.0.1' }] }],
|
|
},
|
|
],
|
|
dockerfile: [
|
|
{
|
|
packageFile: 'Dockerfile',
|
|
deps: [
|
|
{
|
|
depName: 'amd64/node',
|
|
language: 'docker',
|
|
sourceUrl: 'https://github.com/nodejs/node',
|
|
updates: [{ newValue: '10.0.1' }],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
packageFile: 'Dockerfile',
|
|
deps: [
|
|
{
|
|
depName: 'calico/node',
|
|
language: 'docker',
|
|
sourceUrl: 'https://calico.com',
|
|
updates: [{ newValue: '3.2.0', updateType: 'minor' }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
gomod: [
|
|
{
|
|
packageFile: 'go.mod',
|
|
deps: [
|
|
{
|
|
depName: 'github.com/Parallels/docker-machine-parallels',
|
|
updates: [{ newValue: '1.3.0' }],
|
|
},
|
|
{
|
|
depName: 'gopkg.in/yaml.v2',
|
|
updates: [{ newValue: '2.2.8', updateType: 'minor' }],
|
|
},
|
|
{
|
|
depName: 'gopkg.in/warnings.v0',
|
|
updates: [{ newValue: '0.1.3' }],
|
|
},
|
|
{
|
|
depName: 'github.com/blang/semver',
|
|
updates: [],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
// @ts-expect-error -- TODO: fix types
|
|
const res = await flattenUpdates(config, packageFiles);
|
|
expect(res).toHaveLength(15);
|
|
expect(
|
|
res.every(
|
|
(upgrade) =>
|
|
upgrade.isLockFileMaintenance ??
|
|
upgrade.isRemediation ??
|
|
isNumber(upgrade.depIndex),
|
|
),
|
|
).toBeTrue();
|
|
expect(res.find((update) => update.sourceRepoSlug)!.sourceRepoSlug).toBe(
|
|
'org-repo',
|
|
);
|
|
expect(res.find((update) => update.sourceRepo)!.sourceRepo).toBe(
|
|
'org/repo',
|
|
);
|
|
expect(res.find((update) => update.sourceRepoOrg)!.sourceRepoOrg).toBe(
|
|
'org',
|
|
);
|
|
expect(res.find((update) => update.sourceRepoName)!.sourceRepoName).toBe(
|
|
'repo',
|
|
);
|
|
expect(
|
|
res.filter((update) => update.sourceRepoSlug)[1].sourceRepoSlug,
|
|
).toBe('org-repo');
|
|
expect(res.filter((update) => update.sourceRepo)[1].sourceRepo).toBe(
|
|
'org/repo',
|
|
);
|
|
expect(
|
|
res.filter((update) => update.sourceRepoOrg)[1].sourceRepoOrg,
|
|
).toBe('org');
|
|
expect(
|
|
res.filter((update) => update.sourceRepoName)[1].sourceRepoName,
|
|
).toBe('repo');
|
|
expect(
|
|
res.find((update) => update.depName === '@monorepo/package'),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
depName: '@monorepo/package',
|
|
sourceRepoOrg: 'some',
|
|
sourceRepoName: 'monorepo',
|
|
sourceRepo: 'some/monorepo',
|
|
sourceRepoSlug: 'some-monorepo',
|
|
sourceUrl: 'https://github.com/some/monorepo',
|
|
sourceDirectory: 'subfolder/package',
|
|
}),
|
|
);
|
|
expect(
|
|
res.filter((update) => update.sourceRepoSlug)[3].sourceRepoSlug,
|
|
).toBe('nodejs-node');
|
|
expect(res.filter((update) => update.sourceRepo)[3].sourceRepo).toBe(
|
|
'nodejs/node',
|
|
);
|
|
expect(
|
|
res.filter((update) => update.sourceRepoOrg)[3].sourceRepoOrg,
|
|
).toBe('nodejs');
|
|
expect(
|
|
res.filter((update) => update.sourceRepoName)[3].sourceRepoName,
|
|
).toBe('node');
|
|
expect(
|
|
res.filter(
|
|
(r) =>
|
|
r.updateType === 'lockFileMaintenance' && r.isLockFileMaintenance,
|
|
),
|
|
).toHaveLength(2);
|
|
expect(res.filter((r) => r.isVulnerabilityAlert)).toHaveLength(1);
|
|
});
|
|
|
|
it('when a dependency is enabled=false, it is filtered', async () => {
|
|
const packageFiles = {
|
|
npm: [
|
|
{
|
|
packageFile: 'package.json',
|
|
lockFiles: ['package-lock.json'],
|
|
deps: [
|
|
{
|
|
depName: '@org/a',
|
|
updates: [
|
|
{
|
|
newValue: '1.0.0',
|
|
sourceUrl: 'https://github.com/org/repo',
|
|
},
|
|
],
|
|
enabled: false,
|
|
},
|
|
{
|
|
depName: 'foo',
|
|
updates: [
|
|
{
|
|
newValue: '2.0.0',
|
|
sourceUrl: 'https://github.com/org/repo',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
const res = await flattenUpdates(config, packageFiles);
|
|
expect(res).toHaveLength(1);
|
|
expect(
|
|
res.find((update) => update.depName === '@org/a'),
|
|
).not.toBeDefined();
|
|
expect(res.find((update) => update.depName === 'foo')).toBeDefined();
|
|
});
|
|
|
|
it('when a skipReason is found on a dependency, it is filtered', async () => {
|
|
const packageFiles = {
|
|
npm: [
|
|
{
|
|
packageFile: 'package.json',
|
|
lockFiles: ['package-lock.json'],
|
|
deps: [
|
|
{
|
|
depName: '@org/a',
|
|
updates: [
|
|
{
|
|
newValue: '1.0.0',
|
|
sourceUrl: 'https://github.com/org/repo',
|
|
},
|
|
],
|
|
skipReason: 'disabled' as SkipReason,
|
|
},
|
|
{
|
|
depName: 'foo',
|
|
updates: [
|
|
{
|
|
newValue: '2.0.0',
|
|
sourceUrl: 'https://github.com/org/repo',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
const res = await flattenUpdates(config, packageFiles);
|
|
expect(res).toHaveLength(1);
|
|
expect(
|
|
res.find((update) => update.depName === '@org/a'),
|
|
).not.toBeDefined();
|
|
expect(res.find((update) => update.depName === 'foo')).toBeDefined();
|
|
});
|
|
|
|
it('separates lockfile maintenance updates from other update types if grouping is applied', async () => {
|
|
// TODO #22198
|
|
config.lockFileMaintenance!.enabled = true;
|
|
config.packageRules = [
|
|
{
|
|
matchUpdateTypes: ['major', 'lockFileMaintenance'],
|
|
groupName: 'group-lock-major',
|
|
},
|
|
];
|
|
const packageFiles = {
|
|
npm: [
|
|
{
|
|
packageFile: 'package.json',
|
|
lockFiles: ['package-lock.json'],
|
|
deps: [
|
|
{
|
|
depName: 'foo',
|
|
updates: [
|
|
{
|
|
newValue: '2.0.0',
|
|
sourceUrl: 'https://github.com/org/repo',
|
|
},
|
|
],
|
|
currentValue: '1.0.0',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const res = await flattenUpdates(config, packageFiles);
|
|
expect(res).toHaveLength(2);
|
|
const lockFileUpdate = res.find(
|
|
(u) => u.updateType === 'lockFileMaintenance',
|
|
);
|
|
const regularUpdate = res.find(
|
|
(u) => u.updateType !== 'lockFileMaintenance',
|
|
);
|
|
expect(lockFileUpdate!.branchName).toContain('lock-file-maintenance');
|
|
expect(regularUpdate!.branchName).not.toContain('lock-file-maintenance');
|
|
expect(lockFileUpdate!.branchName).not.toBe(regularUpdate!.branchName);
|
|
});
|
|
|
|
describe('hasAttestation is taken from the current value', () => {
|
|
it.each([[true], [false], [undefined]])(
|
|
'current attestation %s, new attestation %s',
|
|
async (currentAttestation) => {
|
|
config = getConfig(); // HACK
|
|
const packageFiles: Record<string, PackageFile[]> = {
|
|
npm: [
|
|
{
|
|
packageFile: 'package.json',
|
|
deps: [
|
|
{
|
|
depName: 'foo',
|
|
currentValue: '1.0.0',
|
|
hasAttestation: currentAttestation,
|
|
updates: [
|
|
{
|
|
newValue: '2.0.0',
|
|
// but the new update may have a different value
|
|
hasAttestation: false,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const res = await flattenUpdates(config, packageFiles);
|
|
expect(res).toHaveLength(1);
|
|
expect(res[0].hasAttestation).toEqual(currentAttestation);
|
|
},
|
|
);
|
|
});
|
|
|
|
it('skips replacement update when replacement already exists as sibling dep', async () => {
|
|
const packageFiles: Record<string, PackageFile[]> = {
|
|
npm: [
|
|
{
|
|
packageFile: 'package.json',
|
|
deps: [
|
|
{
|
|
depName: '@material-ui/core',
|
|
updates: [
|
|
{
|
|
newName: '@mui/material',
|
|
newValue: '^5.0.0',
|
|
updateType: 'replacement',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
depName: '@mui/material',
|
|
updates: [
|
|
{
|
|
newValue: '^6.2.0',
|
|
updateType: 'minor',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const res = await flattenUpdates(config, packageFiles);
|
|
expect(
|
|
res.find((update) => update.depName === '@material-ui/core'),
|
|
).toBeUndefined();
|
|
expect(
|
|
res.find((update) => update.depName === '@mui/material'),
|
|
).toBeDefined();
|
|
});
|
|
|
|
it('skips replacement update when sibling packageName matches newName', async () => {
|
|
const packageFiles: Record<string, PackageFile[]> = {
|
|
npm: [
|
|
{
|
|
packageFile: 'package.json',
|
|
deps: [
|
|
{
|
|
depName: 'old-pkg',
|
|
updates: [
|
|
{
|
|
newName: '@scope/new-pkg',
|
|
newValue: '^2.0.0',
|
|
updateType: 'replacement',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
depName: 'alias',
|
|
packageName: '@scope/new-pkg',
|
|
updates: [
|
|
{
|
|
newValue: '^3.0.0',
|
|
updateType: 'minor',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const res = await flattenUpdates(config, packageFiles);
|
|
expect(
|
|
res.find((update) => update.depName === 'old-pkg'),
|
|
).toBeUndefined();
|
|
expect(res.find((update) => update.depName === 'alias')).toBeDefined();
|
|
});
|
|
});
|
|
});
|