Files
renovate/lib/config/presets/util.ts
Sebastian Poxhofer 874f3e3953 feat(presets): support relative paths inside a preset repo (#45211)
* feat(presets): support relative `extends` references inside presets

Presets which are fetched from a repository can now reference sibling
presets relatively, instead of repeating their own source, repository and
tag:

- `./x` resolves against the directory of the preset file with the
  reference
- `../x` resolves against the parent directory of that file
- `/x` resolves against the root of the preset repository

The source, repository and tag of the preset which holds the reference are
inherited, so `./system/registries` inside `github>org/repo#v2.0.0`
resolves to `github>org/repo//system/registries#v2.0.0`. A reference which
resolves to the `default` preset at the repository root is emitted in the
plain repository form, so `github>org/repo#v2.0.0`, which keeps it
deduplicated against the way consumers spell the very same preset.

A reference must stay inside its repository, its final path segment must
name a preset file rather than a directory, and it can carry parameters
but neither its own `#tag` nor a `:subPreset`. Parameters may contain a
Handlebars template, including a sub-expression such as
`./group({{ lower (env.TEAM) }})`, while a templated path cannot be
resolved and is left alone. A reference must also not carry a source
prefix, so strings such as `local>./x` or `github>../x` are rejected as
invalid.

References are canonicalized lazily, while the preset which holds them is
fetched in `getPreset()`, after migration and massaging. Only presets from
a repository hosted source, so `github`, `gitlab`, `gitea`, `forgejo` and
`local`, are walked. A relative reference which has no parent preset, for
example in a repository config, an inherited config or in `globalExtends`,
is reported as a validation error for that entry.

Relative entries of a preset's `ignorePresets` are resolved in the same
way, so a preset can neutralize a preset which it references relatively,
as long as the consuming repository does not set `ignorePresets` itself.
A reference which cannot be resolved is kept raw and logged, which lets
consumers neutralize it with `ignorePresets`, and it only fails per entry.

Because relative references cannot be resolved while a config is
validated, they are stripped from a `packageRules` entry before its
presets are resolved. A rule which is then left without any selector is
reported as a warning rather than as an error, since the stripped preset
may still provide the missing selectors. Rules without relative references
keep failing hard as before.

The `renovate-config` manager reports relative and unparsable `extends`
entries with a skipReason, instead of dropping them, and ignores templated
entries which can only be resolved at runtime.

Behavior change: an `extends` string which starts with `./`, `../` or `/`,
such as `./x`, `../x` or `/x`, is now read as a relative reference. Before
it was parsed as a local repository name, which in rare cases could
accidentally resolve through the dot segment normalization of the request
URL. For the same reason a source prefixed relative looking string, such
as `local>/org/repo`, `github>./x` or `npm>./x`, is now rejected as an
invalid preset.

* fix(presets): parse scoped npm presets with explicit `npm>` prefix

`npm>@scope/name` was parsed as a local preset, because the heuristic for
local presets matched before the `npm>` prefix was stripped.

Every other use of `npm>` keeps its current behavior, so `npm>foo` still
falls back to the npm package `renovate-config-foo`, `npm>owner/repo` is
still resolved as a local preset and nested prefixes such as
`local>npm>foo` keep working.

* refactor: remove duplicated preset source

* docs: streamline new docs

* add more docs for skipReason and add requested changes

* Apply suggestions from code review

Co-authored-by: Jamie Tanna <github@jamietanna.co.uk>

* test(presets): drop object literal type casts in relative preset spec

---------

Co-authored-by: Jamie Tanna <github@jamietanna.co.uk>
2026-08-13 13:54:50 +00:00

105 lines
2.8 KiB
TypeScript

import { logger } from '../../logger/index.ts';
import type { Nullish } from '../../types/index.ts';
import { parseJson } from '../../util/common.ts';
import { regEx } from '../../util/regex.ts';
import { ensureTrailingSlash } from '../../util/url.ts';
import type { FetchPresetConfig, Preset } from './types.ts';
export const PRESET_DEP_NOT_FOUND = 'dep not found';
export const PRESET_INVALID = 'invalid preset';
export const PRESET_INVALID_JSON = 'invalid preset JSON';
export const PRESET_NOT_FOUND = 'preset not found';
export const PRESET_PROHIBITED_SUBPRESET = 'prohibited sub-preset';
export const PRESET_RELATIVE_NO_PARENT = 'relative preset has no parent';
export const PRESET_RELATIVE_OUTSIDE_REPO = 'relative preset outside repo';
export const PRESET_RENOVATE_CONFIG_NOT_FOUND =
'preset renovate-config not found';
export async function fetchPreset({
repo,
filePreset,
presetPath,
endpoint: _endpoint,
tag,
fetch,
}: FetchPresetConfig): Promise<Nullish<Preset>> {
// TODO: fix me, can be undefiend #22198
// oxlint-disable-next-line typescript/no-unnecessary-type-assertion
const endpoint = ensureTrailingSlash(_endpoint!);
const [fileName, presetName, subPresetName] = filePreset.split('/');
const pathPrefix = presetPath ? `${presetPath}/` : '';
function buildFilePath(name: string): string {
return `${pathPrefix}${name}`;
}
let jsonContent: any;
if (fileName === 'default') {
try {
jsonContent = await fetch(
repo,
buildFilePath('default.json'),
endpoint,
tag,
);
} catch (err) {
if (err.message !== PRESET_DEP_NOT_FOUND) {
throw err;
}
jsonContent = await fetch(
repo,
buildFilePath('renovate.json'),
endpoint,
tag,
);
logger.warn(
{
repo,
filePreset,
presetPath,
endpoint,
tag,
},
'Fallback to renovate.json file as a preset is deprecated, please use a default.json file instead.',
);
}
} else {
jsonContent = await fetch(
repo,
buildFilePath(
regEx(/\.json[5c]?$/).test(fileName) ? fileName : `${fileName}.json`,
),
endpoint,
tag,
);
}
if (!jsonContent) {
throw new Error(PRESET_DEP_NOT_FOUND);
}
if (presetName) {
const preset = jsonContent[presetName];
if (!preset) {
throw new Error(PRESET_NOT_FOUND);
}
if (subPresetName) {
const subPreset = preset[subPresetName];
if (!subPreset) {
throw new Error(PRESET_NOT_FOUND);
}
return subPreset;
}
return preset;
}
return jsonContent;
}
export function parsePreset(
content: Nullish<string>,
fileName: string,
): Nullish<Preset> {
try {
return parseJson(content, fileName) as Nullish<Preset>;
} catch {
throw new Error(PRESET_INVALID_JSON);
}
}