Files
Marius b22f58045e fix(manager): Handle variables with default values in registry aliases (#36293)
* 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`.
2026-09-03 12:43:47 +00:00

264 lines
9.8 KiB
TypeScript

import { isUndefined } from '@sindresorhus/is';
import {
filterConfig,
getManagerConfig,
mergeChildConfig,
} from '../../../config/index.ts';
import type { RenovateConfig } from '../../../config/types.ts';
import { logger } from '../../../logger/index.ts';
import { getDefaultConfig } from '../../../modules/datasource/index.ts';
import { get } from '../../../modules/manager/index.ts';
import type { PackageFile } from '../../../modules/manager/types.ts';
import { coerceArray } from '../../../util/array.ts';
import { detectSemanticCommits } from '../../../util/git/semantic.ts';
import { applyPackageRules } from '../../../util/package-rules/index.ts';
import { regEx } from '../../../util/regex.ts';
import * as template from '../../../util/template/index.ts';
import { parseUrl } from '../../../util/url.ts';
import type { BranchUpgradeConfig } from '../../types.ts';
import { replacementAlreadyExists } from '../common.ts';
import { generateBranchName } from './branch-name.ts';
function upper(str: string): string {
return str.charAt(0).toUpperCase() + str.substring(1);
}
export function sanitizeDepName(depName: string): string {
return depName
.replace(regEx(/\$\{[^}]+\}\/?/g), '')
.replace(regEx(/[${}]/g), '')
.replace('@types/', '')
.replace('@', '')
.replace(regEx(/\//g), '-')
.replace(regEx(/\s+/g), '-')
.replace(regEx(/:/g), '-')
.replace(regEx(/-+/), '-')
.toLowerCase();
}
export function applyUpdateConfig(input: BranchUpgradeConfig): any {
const updateConfig = { ...input };
delete updateConfig.packageRules;
// TODO: Remove next line once #8075 is complete
updateConfig.depNameSanitized = updateConfig.depName
? sanitizeDepName(updateConfig.depName)
: undefined;
updateConfig.newNameSanitized = updateConfig.newName
? sanitizeDepName(updateConfig.newName)
: undefined;
if (updateConfig.sourceUrl) {
const parsedSourceUrl = parseUrl(updateConfig.sourceUrl);
if (parsedSourceUrl?.pathname) {
updateConfig.sourceRepoSlug = parsedSourceUrl.pathname
.replace(regEx(/^\//), '') // remove leading slash
.replace(regEx(/\//g), '-') // change slashes to hyphens
.replace(regEx(/-+/g), '-'); // remove multiple hyphens
updateConfig.sourceRepo = parsedSourceUrl.pathname.replace(
regEx(/^\//),
'',
); // remove leading slash
updateConfig.sourceRepoOrg = updateConfig.sourceRepo.replace(
regEx(/\/.*/g),
'',
); // remove everything after first slash
updateConfig.sourceRepoName = updateConfig.sourceRepo.replace(
regEx(/.*\//g),
'',
); // remove everything up to the last slash
}
}
if (updateConfig.sourceDirectory) {
updateConfig.sourceDirectory = template.compile(
updateConfig.sourceDirectory,
updateConfig,
);
}
generateBranchName(updateConfig);
return updateConfig;
}
export async function flattenUpdates(
config: RenovateConfig,
packageFiles: Record<string, PackageFile[]>,
): Promise<BranchUpgradeConfig[]> {
const updates = [];
const updateTypes = [
'major',
'minor',
'patch',
'pin',
'digest',
'lockFileMaintenance',
'replacement',
];
for (const [manager, files] of Object.entries(packageFiles)) {
const managerConfig = getManagerConfig(config, manager);
for (const packageFile of files) {
const packageFileConfig: BranchUpgradeConfig = mergeChildConfig(
managerConfig,
packageFile,
) as never;
const packagePath = packageFile.packageFile?.split('/');
if (packagePath.length > 0) {
packagePath.splice(-1, 1);
}
if (packagePath.length > 0) {
packageFileConfig.parentDir = packagePath.at(-1);
packageFileConfig.packageFileDir = packagePath.join('/');
} else {
packageFileConfig.parentDir = '';
packageFileConfig.packageFileDir = '';
}
let depIndex = 0;
for (const dep of packageFile.deps) {
if (dep.updates!.length) {
const depConfig = mergeChildConfig(packageFileConfig, dep);
// @ts-expect-error -- not easily typed
delete depConfig.deps;
depConfig.depIndex = depIndex; // used for autoreplace
for (const update of dep.updates!) {
if (
update.updateType === 'replacement' &&
update.newName &&
replacementAlreadyExists(packageFile.deps, dep, update.newName)
) {
logger.debug(
{ fileName: packageFile.packageFile },
`Skipping replacement of ${dep.depName} with ${update.newName}: replacement already exists in ${packageFile.packageFile}`,
);
continue;
}
let updateConfig = mergeChildConfig(depConfig, update);
delete updateConfig.updates;
if (updateConfig.updateType) {
// @ts-expect-error -- not easily typed
updateConfig[`is${upper(updateConfig.updateType)}`] = true;
}
if (updateConfig.updateTypes) {
updateConfig.updateTypes.forEach((updateType: string) => {
// @ts-expect-error -- not easily typed
updateConfig[`is${upper(updateType)}`] = true;
});
}
// apply config from datasource
const datasourceConfig = await getDefaultConfig(
depConfig.datasource!,
);
updateConfig = mergeChildConfig(updateConfig, datasourceConfig);
updateConfig = await applyPackageRules(
updateConfig,
'datasource-merge',
);
// apply major/minor/patch/pin/digest
updateConfig = mergeChildConfig(
updateConfig,
// @ts-expect-error -- not easily typed
updateConfig[updateConfig.updateType],
);
for (const updateType of updateTypes) {
// @ts-expect-error -- not easily typed
delete updateConfig[updateType];
}
// Apply again in case any were added by the updateType config
updateConfig = await applyPackageRules(
updateConfig,
'update-type-merge',
);
updateConfig = applyUpdateConfig(updateConfig);
updateConfig.baseDeps = packageFile.deps;
update.branchName = updateConfig.branchName;
// make sure that we use the dependency's current state of attestation, rather than using the new update's value
updateConfig.hasAttestation = depConfig.hasAttestation;
updates.push(updateConfig);
}
}
depIndex += 1;
}
if (
get(manager, 'supportsLockFileMaintenance') &&
packageFileConfig.lockFileMaintenance!.enabled
) {
// Apply lockFileMaintenance config before packageRules
let lockFileConfig = mergeChildConfig(
packageFileConfig,
packageFileConfig.lockFileMaintenance!,
);
lockFileConfig.updateType = 'lockFileMaintenance';
lockFileConfig.isLockFileMaintenance = true;
lockFileConfig = await applyPackageRules(
lockFileConfig,
'lock-file-maintenance-merge',
);
// Apply lockFileMaintenance and packageRules again
lockFileConfig = mergeChildConfig(
lockFileConfig,
lockFileConfig.lockFileMaintenance!,
);
lockFileConfig = await applyPackageRules(
lockFileConfig,
'lock-file-maintenance-merge-2',
);
// Remove unnecessary objects
for (const updateType of updateTypes) {
// @ts-expect-error -- not easily typed
delete lockFileConfig[updateType];
}
delete lockFileConfig.packageRules;
// @ts-expect-error -- not easily typed
delete lockFileConfig.deps;
generateBranchName(lockFileConfig);
updates.push(lockFileConfig);
}
if (get(manager, 'updateLockedDependency')) {
for (const lockFile of coerceArray(packageFileConfig.lockFiles)) {
const lockfileRemediations = config.remediations as Record<
string,
Record<string, any>[]
>;
const remediations = lockfileRemediations?.[lockFile];
if (remediations) {
for (const remediation of remediations) {
let updateConfig = mergeChildConfig(
packageFileConfig,
remediation,
);
updateConfig = mergeChildConfig(
updateConfig,
config.vulnerabilityAlerts,
);
delete updateConfig.vulnerabilityAlerts;
updateConfig.isVulnerabilityAlert = true;
updateConfig.isRemediation = true;
updateConfig.lockFile = lockFile;
updateConfig.currentValue = updateConfig.currentVersion;
updateConfig.newValue = updateConfig.newVersion;
updateConfig = applyUpdateConfig(updateConfig);
updateConfig.enabled = true;
updates.push(updateConfig);
}
}
}
}
}
}
if (config.semanticCommits === 'auto') {
const semanticCommits = await detectSemanticCommits();
for (const update of updates) {
update.semanticCommits = semanticCommits;
}
}
const filteredUpdates = updates
.filter((update) => update.enabled !== false)
.filter((update) => isUndefined(update.skipReason))
.map(({ vulnerabilityAlerts: _, ...update }) => update)
.map((update) => filterConfig(update, 'branch'));
if (filteredUpdates.length < updates.length) {
logger.debug(
`Filtered out ${updates.length - filteredUpdates.length} disabled update(s). ${filteredUpdates.length} update(s) remaining.`,
);
}
return filteredUpdates;
}