mirror of
https://github.com/renovatebot/renovate.git
synced 2026-10-31 09:41:46 +00:00
874f3e3953
* 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>
108 lines
3.4 KiB
TypeScript
108 lines
3.4 KiB
TypeScript
import { isArray, isPlainObject, isString } from '@sindresorhus/is';
|
|
import upath from 'upath';
|
|
import { logger } from '../../logger/index.ts';
|
|
import { coerceString } from '../../util/string.ts';
|
|
import { isRelativePresetReference, parsePreset } from './parse.ts';
|
|
import type { ParsedPreset } from './types.ts';
|
|
import { PRESET_RELATIVE_OUTSIDE_REPO } from './util.ts';
|
|
|
|
/**
|
|
* Keys whose array entries may contain relative preset references.
|
|
*/
|
|
const presetReferenceKeys = ['extends', 'ignorePresets'];
|
|
|
|
/**
|
|
* Converts a relative preset reference into an absolute preset string, by
|
|
* inheriting the source, repository and tag of the preset which references it.
|
|
*
|
|
* For example `./system/registries` found inside `github>some/repo#v2.0.0`
|
|
* resolves to `github>some/repo//system/registries#v2.0.0`.
|
|
*/
|
|
function resolveRelativePreset(input: string, parent: ParsedPreset): string {
|
|
const parsed = parsePreset(input);
|
|
|
|
let resolved: string;
|
|
if (parsed.presetName.startsWith('/')) {
|
|
// relative to the root of the repository
|
|
resolved = upath.normalize(parsed.presetName.slice(1));
|
|
} else {
|
|
// relative to the directory of the preset file which references it
|
|
resolved = upath.normalize(
|
|
upath.join(coerceString(parent.presetPath), parsed.presetName),
|
|
);
|
|
}
|
|
|
|
if (resolved === '..' || resolved.startsWith('../')) {
|
|
throw new Error(PRESET_RELATIVE_OUTSIDE_REPO);
|
|
}
|
|
|
|
const tag = parent.tag ? `#${parent.tag}` : '';
|
|
const params = isString(parsed.rawParams) ? `(${parsed.rawParams})` : '';
|
|
|
|
if (resolved === 'default') {
|
|
// the default preset at the root of the repository is spelled without a
|
|
// path, so emitting `//default` here would break deduplication against the
|
|
// way consumers reference the very same preset
|
|
return `${parent.presetSource}>${parent.repo}${tag}${params}`;
|
|
}
|
|
|
|
return `${parent.presetSource}>${parent.repo}//${resolved}${tag}${params}`;
|
|
}
|
|
|
|
/**
|
|
* Rewrites every relative preset reference found in any `extends` or
|
|
* `ignorePresets` array of the given value into an absolute preset string.
|
|
* The value is mutated in place.
|
|
*/
|
|
export function canonicalizeRelativePresets(
|
|
value: unknown,
|
|
parent: ParsedPreset,
|
|
): void {
|
|
if (isArray(value)) {
|
|
for (const element of value) {
|
|
canonicalizeRelativePresets(element, parent);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!isPlainObject(value)) {
|
|
return;
|
|
}
|
|
|
|
for (const [key, val] of Object.entries(value)) {
|
|
if (presetReferenceKeys.includes(key) && isArray(val)) {
|
|
value[key] = val.map((entry) => rewritePresetEntry(entry, parent));
|
|
continue;
|
|
}
|
|
|
|
canonicalizeRelativePresets(val, parent);
|
|
}
|
|
}
|
|
|
|
function rewritePresetEntry(entry: unknown, parent: ParsedPreset): unknown {
|
|
if (!isString(entry) || !isRelativePresetReference(entry)) {
|
|
return entry;
|
|
}
|
|
|
|
// only the path may not be templated, because it has to be resolved here,
|
|
// while the parameters are compiled later on
|
|
const pathPortion = entry.includes('(')
|
|
? entry.slice(0, entry.indexOf('('))
|
|
: entry;
|
|
if (pathPortion.includes('{{')) {
|
|
return entry;
|
|
}
|
|
|
|
try {
|
|
return resolveRelativePreset(entry, parent);
|
|
} catch (err) {
|
|
// the unchanged entry is reported when it is resolved as a preset, which
|
|
// allows consumers to neutralize it using `ignorePresets`
|
|
logger.warn(
|
|
{ preset: entry, parentPreset: parent, err },
|
|
'Could not resolve relative preset reference',
|
|
);
|
|
return entry;
|
|
}
|
|
}
|