mirror of
https://github.com/renovatebot/renovate.git
synced 2026-11-01 18:19:51 +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>
233 lines
8.5 KiB
TypeScript
233 lines
8.5 KiB
TypeScript
import { logger } from '~test/util.ts';
|
|
import type { RenovateConfig } from '../types.ts';
|
|
import { parsePreset } from './parse.ts';
|
|
import { canonicalizeRelativePresets } from './relative.ts';
|
|
|
|
describe('config/presets/relative', () => {
|
|
describe('canonicalizeRelativePresets', () => {
|
|
it.each`
|
|
parent | input | expected
|
|
${'github>Stedi/renovate-config#v2.0.0'} | ${'./system/registries'} | ${'github>Stedi/renovate-config//system/registries#v2.0.0'}
|
|
${'github>some/repo'} | ${'./x'} | ${'github>some/repo//x'}
|
|
${'github>some/repo//system/registries#v2.0.0'} | ${'./cache'} | ${'github>some/repo//system/cache#v2.0.0'}
|
|
${'github>some/repo//a/b/c#v1'} | ${'../shared/x'} | ${'github>some/repo//a/shared/x#v1'}
|
|
${'github>some/repo//a/b/c#v1'} | ${'/x'} | ${'github>some/repo//x#v1'}
|
|
${'github>some/repo//a/c.json5#v1'} | ${'./sibling.json5'} | ${'github>some/repo//a/sibling.json5#v1'}
|
|
${'github>some/repo:file/sub#v1'} | ${'./x'} | ${'github>some/repo//x#v1'}
|
|
${'github>some/repo#v1'} | ${'./x(p1, p2)'} | ${'github>some/repo//x#v1(p1, p2)'}
|
|
${'github>some/repo#v1'} | ${'./x()'} | ${'github>some/repo//x#v1()'}
|
|
${'github>some/repo//sub/f#v1'} | ${'../default'} | ${'github>some/repo#v1'}
|
|
${'github>some/repo#v1'} | ${'/default'} | ${'github>some/repo#v1'}
|
|
${'github>some/repo#v1'} | ${'./default'} | ${'github>some/repo#v1'}
|
|
${'github>some/repo#v1'} | ${'./default(p1)'} | ${'github>some/repo#v1(p1)'}
|
|
${'github>some/repo//dir/f#v1'} | ${'./default'} | ${'github>some/repo//dir/default#v1'}
|
|
${'github>some/repo//sub/f'} | ${'./a/../b'} | ${'github>some/repo//sub/b'}
|
|
${'github>some/repo//a/b/c#v1'} | ${'/nested/dir/x'} | ${'github>some/repo//nested/dir/x#v1'}
|
|
${'github>o/r//dir/base#v1'} | ${'./g({{ env.T }})'} | ${'github>o/r//dir/g#v1({{ env.T }})'}
|
|
${'local>group/repo//presets/base'} | ${'./extra'} | ${'local>group/repo//presets/extra'}
|
|
${'some/repo'} | ${'./x'} | ${'local>some/repo//x'}
|
|
${'gitlab>some/repo#v1'} | ${'./x'} | ${'gitlab>some/repo//x#v1'}
|
|
${'gitea>some/repo#v1'} | ${'./x'} | ${'gitea>some/repo//x#v1'}
|
|
${'forgejo>some/repo#v1'} | ${'./x'} | ${'forgejo>some/repo//x#v1'}
|
|
`(
|
|
'resolves $input inside $parent',
|
|
({ parent, input, expected }: Record<string, string>) => {
|
|
const config: RenovateConfig = { extends: [input] };
|
|
|
|
canonicalizeRelativePresets(config, parsePreset(parent));
|
|
|
|
expect(config).toEqual({ extends: [expected] });
|
|
},
|
|
);
|
|
|
|
it.each`
|
|
parent | input | reason
|
|
${'github>some/repo#v1'} | ${'../x'} | ${'escapes the repository'}
|
|
${'github>some/repo//a/f#v1'} | ${'../../x'} | ${'escapes the repository'}
|
|
${'github>some/repo//a/f#v1'} | ${'./../../x'} | ${'escapes the repository'}
|
|
${'github>some/repo#v1'} | ${'./x#v3'} | ${'invalid tag'}
|
|
${'github>some/repo#v1'} | ${'./x:sub'} | ${'sub-preset'}
|
|
${'github>some/repo#v1'} | ${'./.'} | ${'names a directory'}
|
|
${'github>some/repo//a/f#v1'} | ${'./..'} | ${'names a directory'}
|
|
`(
|
|
'leaves $input unchanged inside $parent ($reason)',
|
|
({ parent, input }: Record<string, string>) => {
|
|
const parsedParent = parsePreset(parent);
|
|
const config: RenovateConfig = { extends: [input] };
|
|
|
|
canonicalizeRelativePresets(config, parsedParent);
|
|
|
|
expect(config).toEqual({ extends: [input] });
|
|
expect(logger.logger.warn).toHaveBeenCalledWith(
|
|
{
|
|
preset: input,
|
|
parentPreset: parsedParent,
|
|
err: expect.any(Error),
|
|
},
|
|
'Could not resolve relative preset reference',
|
|
);
|
|
},
|
|
);
|
|
|
|
const parent = parsePreset('github>some/repo//dir/base#v1.0.0');
|
|
|
|
it('rewrites top-level extends', () => {
|
|
const config: RenovateConfig = {
|
|
extends: ['./sibling', '/root/base', '../parent/base'],
|
|
};
|
|
|
|
canonicalizeRelativePresets(config, parent);
|
|
|
|
expect(config).toEqual({
|
|
extends: [
|
|
'github>some/repo//dir/sibling#v1.0.0',
|
|
'github>some/repo//root/base#v1.0.0',
|
|
'github>some/repo//parent/base#v1.0.0',
|
|
],
|
|
});
|
|
});
|
|
|
|
it('rewrites ignorePresets', () => {
|
|
const config: RenovateConfig = {
|
|
ignorePresets: ['./sibling', 'github>other/repo'],
|
|
};
|
|
|
|
canonicalizeRelativePresets(config, parent);
|
|
|
|
expect(config).toEqual({
|
|
ignorePresets: [
|
|
'github>some/repo//dir/sibling#v1.0.0',
|
|
'github>other/repo',
|
|
],
|
|
});
|
|
});
|
|
|
|
it('warns about broken ignorePresets entries', () => {
|
|
const parsedParent = parsePreset('github>some/repo#v1');
|
|
const config: RenovateConfig = { ignorePresets: ['../oops'] };
|
|
|
|
canonicalizeRelativePresets(config, parsedParent);
|
|
|
|
expect(config).toEqual({ ignorePresets: ['../oops'] });
|
|
expect(logger.logger.warn).toHaveBeenCalledWith(
|
|
{
|
|
preset: '../oops',
|
|
parentPreset: parsedParent,
|
|
err: expect.any(Error),
|
|
},
|
|
'Could not resolve relative preset reference',
|
|
);
|
|
});
|
|
|
|
it('rewrites extends and ignorePresets of the same object', () => {
|
|
const config: RenovateConfig = {
|
|
extends: ['./optional'],
|
|
ignorePresets: ['./optional'],
|
|
};
|
|
|
|
canonicalizeRelativePresets(config, parent);
|
|
|
|
expect(config).toEqual({
|
|
extends: ['github>some/repo//dir/optional#v1.0.0'],
|
|
ignorePresets: ['github>some/repo//dir/optional#v1.0.0'],
|
|
});
|
|
});
|
|
|
|
it('rewrites nested extends inside packageRules', () => {
|
|
const config: RenovateConfig = {
|
|
packageRules: [
|
|
{
|
|
matchManagers: ['npm'],
|
|
extends: ['./rules/npm'],
|
|
},
|
|
],
|
|
};
|
|
|
|
canonicalizeRelativePresets(config, parent);
|
|
|
|
expect(config).toEqual({
|
|
packageRules: [
|
|
{
|
|
matchManagers: ['npm'],
|
|
extends: ['github>some/repo//dir/rules/npm#v1.0.0'],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('rewrites deeply nested extends', () => {
|
|
const config = {
|
|
outer: {
|
|
list: [[{ extends: ['./deep'] }]],
|
|
inner: { extends: ['./inner'] },
|
|
},
|
|
};
|
|
|
|
canonicalizeRelativePresets(config, parent);
|
|
|
|
expect(config).toEqual({
|
|
outer: {
|
|
list: [[{ extends: ['github>some/repo//dir/deep#v1.0.0'] }]],
|
|
inner: { extends: ['github>some/repo//dir/inner#v1.0.0'] },
|
|
},
|
|
});
|
|
});
|
|
|
|
it('rewrites onboardingConfig extends', () => {
|
|
const config: RenovateConfig = {
|
|
onboardingConfig: {
|
|
extends: ['./onboarding'],
|
|
},
|
|
};
|
|
|
|
canonicalizeRelativePresets(config, parent);
|
|
|
|
expect(config).toEqual({
|
|
onboardingConfig: {
|
|
extends: ['github>some/repo//dir/onboarding#v1.0.0'],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('leaves other entries untouched', () => {
|
|
const config = {
|
|
labels: ['dependencies'],
|
|
enabled: true,
|
|
nothing: null,
|
|
extends: [
|
|
'config:recommended',
|
|
'github>other/repo',
|
|
'./{{arg0}}',
|
|
'./with/{{arg0}}',
|
|
'./{{ env.X }}/base',
|
|
42,
|
|
],
|
|
};
|
|
|
|
canonicalizeRelativePresets(config, parent);
|
|
|
|
expect(config).toEqual({
|
|
labels: ['dependencies'],
|
|
enabled: true,
|
|
nothing: null,
|
|
extends: [
|
|
'config:recommended',
|
|
'github>other/repo',
|
|
'./{{arg0}}',
|
|
'./with/{{arg0}}',
|
|
'./{{ env.X }}/base',
|
|
42,
|
|
],
|
|
});
|
|
});
|
|
|
|
it('ignores non-array extends', () => {
|
|
const config = { extends: './single' };
|
|
|
|
canonicalizeRelativePresets(config, parent);
|
|
|
|
expect(config).toEqual({ extends: './single' });
|
|
});
|
|
});
|
|
});
|