mirror of
https://github.com/renovatebot/renovate.git
synced 2026-10-24 14:34:21 +00:00
af055eba22
In a similar way to how `allowedEnv` works, we should allow a self-hosted admin to specify whichever headers they wish in their `hostRules` - whether in their global config, a `repositories[]` entry, or a preset a `repositories[]` entry extends. Previously, this would require a self-hosted admin to add any header they wished to set to `allowedHeaders`, which would also allow any of their users' repositories to set that same header. We can align this behaviour with `allowedEnv`, which makes sure that the admin's own `headers` are always applied regardless of `allowedHeaders`, while a repository's own `hostRules` - and those of any preset it extends - remain constrained by it as before. We make sure to use an explicit `[]` to make it clear when there are no headers to allow, opposed to using something less clear like `undefined`. We do not currently allow an `extends` (without a `repositories[]` entry) to bypass, and they'll be followed-up with in #45670. Co-authored-by: Claude Opus 5 <jamie.tanna+claude-code@mend.io>
319 lines
11 KiB
JavaScript
319 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
import 'source-map-support/register.js';
|
|
import './punycode.cjs';
|
|
|
|
import { styleText } from 'node:util';
|
|
import { isNonEmptyStringAndNotWhitespace } from '@sindresorhus/is';
|
|
import { Command, CommanderError } from 'commander';
|
|
import { dequal } from 'dequal';
|
|
import { diffLines } from 'diff';
|
|
import fs from 'fs-extra';
|
|
import { getConfigFileNames } from './config/app-strings.ts';
|
|
import { GlobalConfig } from './config/global.ts';
|
|
import { massageConfig } from './config/massage.ts';
|
|
import { migrateConfig } from './config/migration.ts';
|
|
import type { RenovateConfig } from './config/types.ts';
|
|
import { validateConfig } from './config/validation.ts';
|
|
import { pkg } from './expose.ts';
|
|
import { init, logger } from './logger/index.ts';
|
|
import { getEnv } from './util/env.ts';
|
|
import { add as addHostRule } from './util/host-rules.ts';
|
|
import { regEx, regexEngineStatus } from './util/regex.ts';
|
|
import { getConfig as getFileConfig } from './workers/global/config/parse/file.ts';
|
|
import { parseConfigs } from './workers/global/config/parse/index.ts';
|
|
import { getParsedContent } from './workers/global/config/parse/util.ts';
|
|
|
|
await init();
|
|
|
|
const { pathExists, readFile } = fs;
|
|
|
|
let returnVal = 0;
|
|
|
|
/**
|
|
* Make sure that we've resolved configuration from the different places that Renovate users would expect them to be specified
|
|
*
|
|
* This then allows a `configType=repo` config to i.e. be validated alongside a `config.js` or `env RENOVATE_ALLOWED_COMMANDS=...`
|
|
*
|
|
* Note that we intentionally don't fully initialize Renovate and its modules, as we're not fully running, and it would require a Platform to be configured
|
|
* */
|
|
async function partiallyGlobalInitialize(): Promise<void> {
|
|
// NOTE that this doesn't allow command-line arguments
|
|
const globalConfig = await parseConfigs(getEnv(), []);
|
|
GlobalConfig.set(globalConfig);
|
|
|
|
if (globalConfig.hostRules) {
|
|
// this is the self-hosted admin's own config, so its `headers` are exempt from `allowedHeaders` altogether - see `hostRules.add()`
|
|
for (const hostRule of globalConfig.hostRules) {
|
|
addHostRule(hostRule, { trusted: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
async function validate(
|
|
configType: 'global' | 'repo',
|
|
desc: string,
|
|
config: RenovateConfig,
|
|
strict: boolean,
|
|
isPreset = false,
|
|
): Promise<void> {
|
|
if (config.hostRules) {
|
|
// a `global` config is the self-hosted administrator's own, so its `headers` are exempt from `allowedHeaders` altogether - see `hostRules.add()`; a `repo` config's `hostRules` are still constrained by this instance's `allowedHeaders`
|
|
for (const hostRule of config.hostRules) {
|
|
addHostRule(hostRule, { trusted: configType === 'global' });
|
|
}
|
|
}
|
|
const { isMigrated, migratedConfig } = migrateConfig(config);
|
|
if (isMigrated) {
|
|
logger.warn(
|
|
{
|
|
oldConfig: config,
|
|
newConfig: migratedConfig,
|
|
},
|
|
'Config migration necessary',
|
|
);
|
|
const changedObjects = diffLines(
|
|
JSON.stringify(config, null, 2),
|
|
JSON.stringify(migratedConfig, null, 2),
|
|
{ ignoreWhitespace: false, newlineIsToken: false },
|
|
);
|
|
const added = styleText('green', '+ ');
|
|
const removed = styleText('red', '- ');
|
|
const msg = changedObjects
|
|
.flatMap((part) => {
|
|
let linePrefix: string;
|
|
if (part.added) {
|
|
linePrefix = added;
|
|
} else if (part.removed) {
|
|
linePrefix = removed;
|
|
} else {
|
|
linePrefix = ' ';
|
|
}
|
|
return part.value
|
|
.split('\n')
|
|
.filter(isNonEmptyStringAndNotWhitespace)
|
|
.map((line) =>
|
|
line.replace(regEx(/^(?<ws> *)/), `${linePrefix}$<ws>`),
|
|
);
|
|
})
|
|
.join('\n');
|
|
// oxlint-disable-next-line renovate/logger-static-message -- the multi-line, colorized diff must be interpolated to stay readable; as a metadata field it is JSON-escaped onto one line
|
|
logger.warn(`Config migration diff:\n${msg}`);
|
|
if (strict) {
|
|
returnVal = 1;
|
|
}
|
|
}
|
|
const massagedConfig = massageConfig(migratedConfig);
|
|
const res = await validateConfig(configType, massagedConfig, isPreset);
|
|
if (res.errors.length) {
|
|
logger.error(
|
|
{ file: desc, errors: res.errors },
|
|
'Found errors in configuration',
|
|
);
|
|
returnVal = 1;
|
|
}
|
|
if (res.warnings.length) {
|
|
logger.warn(
|
|
{ file: desc, warnings: res.warnings },
|
|
'Found errors in configuration',
|
|
);
|
|
returnVal = 1;
|
|
}
|
|
}
|
|
|
|
interface PackageJson {
|
|
renovate?: RenovateConfig;
|
|
'renovate-config'?: Record<string, RenovateConfig>;
|
|
}
|
|
|
|
(async () => {
|
|
await partiallyGlobalInitialize();
|
|
|
|
const program = new Command('renovate-config-validator')
|
|
.summary('Validate Renovate configuration files')
|
|
.description(
|
|
`Validate your Renovate configuration (repo config, shared presets or global configuration) files\n` +
|
|
// oxlint-disable-next-line renovate/no-hardcoded-docs-url -- CLI help text, no config available
|
|
'If no [config-files...] are given, renovate-config-validator will look at the default config file locations (https://docs.renovatebot.com/configuration-options/)',
|
|
)
|
|
.addHelpText(
|
|
'after',
|
|
// oxlint-disable-next-line renovate/no-hardcoded-docs-url -- CLI help text, no config available
|
|
`
|
|
When specifying [config-files...], Renovate will treat them as global self-hosted configuration files. You can disable this behaviour with --no-global
|
|
|
|
Examples:
|
|
|
|
$ renovate-config-validator
|
|
$ renovate-config-validator --strict
|
|
$ renovate-config-validator first_config.json
|
|
$ renovate-config-validator --strict config.js
|
|
$ renovate-config-validator --no-global renovate.json5
|
|
$ env RENOVATE_CONFIG_FILE=obscure-name.json renovate-config-validator
|
|
|
|
Global configuration:
|
|
|
|
If you have specified global self-hosted configuration (https://docs.renovatebot.com/self-hosted-configuration/) in environment variables or in a \`config.js\`, this will be detected:
|
|
|
|
$ env RENOVATE_ALLOWED_ENV='["GO*"]' renovate-config-validator
|
|
# if passing the filename, make sure it's not validating as a global config
|
|
$ env RENOVATE_ALLOWED_ENV='["GO*"]' renovate-config-validator --no-global renovate.json`,
|
|
)
|
|
.argument('[config-files...]')
|
|
.version(pkg.version, '-v, --version')
|
|
.option(
|
|
'--strict',
|
|
'Fail command if any configuration warnings, errors, or a migration is needed',
|
|
)
|
|
.option(
|
|
'--no-global',
|
|
'When specifying [config-files], do not treat them as global self-hosted configuration file(s)',
|
|
true,
|
|
)
|
|
// allow us to manage the exit code
|
|
.exitOverride();
|
|
|
|
program.action(async (files, opts) => {
|
|
const strict = opts.strict ?? false;
|
|
let filesValidated = 0;
|
|
|
|
// without RE2, patterns are checked by RegExp, which accepts syntax RE2 rejects (e.g. lookahead)
|
|
if (regexEngineStatus.type === 'unavailable') {
|
|
logger.warn(
|
|
{ err: regexEngineStatus.err },
|
|
'RE2 not usable, falling back to RegExp: regex validation may be inaccurate',
|
|
);
|
|
} else if (regexEngineStatus.type === 'ignored') {
|
|
logger.debug(
|
|
'RE2 ignored via RENOVATE_X_IGNORE_RE2: regex validation may be inaccurate',
|
|
);
|
|
}
|
|
|
|
if (files.length) {
|
|
let isGlobalConfig = true;
|
|
if (opts.global === false) {
|
|
isGlobalConfig = false;
|
|
}
|
|
const configType = isGlobalConfig ? 'global' : 'repo';
|
|
for (const file of files) {
|
|
try {
|
|
if (!(await pathExists(file))) {
|
|
returnVal = 1;
|
|
logger.error({ file }, 'File does not exist');
|
|
break;
|
|
}
|
|
const parsedContent = await getParsedContent(file);
|
|
try {
|
|
logger.info(`Validating ${file} as ${configType} config`);
|
|
await validate(configType, file, parsedContent, strict);
|
|
} catch (err) {
|
|
logger.warn({ file, err }, 'File is not valid Renovate config');
|
|
returnVal = 1;
|
|
}
|
|
} catch (err) {
|
|
logger.warn({ file, err }, 'File could not be parsed');
|
|
returnVal = 1;
|
|
}
|
|
|
|
filesValidated++;
|
|
}
|
|
} else {
|
|
for (const file of getConfigFileNames().filter(
|
|
(name) => name !== 'package.json',
|
|
)) {
|
|
try {
|
|
if (!(await pathExists(file))) {
|
|
continue;
|
|
}
|
|
const parsedContent = await getParsedContent(file);
|
|
try {
|
|
logger.info(`Validating ${file}`);
|
|
await validate('repo', file, parsedContent, strict);
|
|
} catch (err) {
|
|
logger.warn({ file, err }, 'File is not valid Renovate config');
|
|
returnVal = 1;
|
|
}
|
|
} catch (err) {
|
|
logger.warn({ file, err }, 'File could not be parsed');
|
|
returnVal = 1;
|
|
}
|
|
|
|
filesValidated++;
|
|
}
|
|
try {
|
|
const pkgJson = JSON.parse(
|
|
await readFile('package.json', 'utf8'),
|
|
) as PackageJson;
|
|
if (pkgJson.renovate) {
|
|
logger.info(`Validating package.json > renovate`);
|
|
await validate(
|
|
'repo',
|
|
'package.json > renovate',
|
|
pkgJson.renovate,
|
|
strict,
|
|
);
|
|
|
|
filesValidated++;
|
|
}
|
|
if (pkgJson['renovate-config']) {
|
|
logger.info(`Validating package.json > renovate-config`);
|
|
for (const presetConfig of Object.values(
|
|
pkgJson['renovate-config'],
|
|
)) {
|
|
await validate(
|
|
'repo',
|
|
'package.json > renovate-config',
|
|
presetConfig,
|
|
strict,
|
|
true,
|
|
);
|
|
|
|
filesValidated++;
|
|
}
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
try {
|
|
const env = getEnv();
|
|
const fileConfig = await getFileConfig(env);
|
|
if (!dequal(fileConfig, {})) {
|
|
const file = env.RENOVATE_CONFIG_FILE ?? 'config.js';
|
|
logger.info(`Validating ${file}`);
|
|
try {
|
|
await validate('global', file, fileConfig, strict);
|
|
} catch (err) {
|
|
logger.error({ file, err }, 'File is not valid Renovate config');
|
|
returnVal = 1;
|
|
}
|
|
filesValidated++;
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
if (returnVal === 0 && filesValidated) {
|
|
logger.info(
|
|
`Config validated successfully against ${filesValidated} file(s)`,
|
|
);
|
|
} else if (!filesValidated) {
|
|
logger.warn(`No files to perform configuration validation against`);
|
|
}
|
|
// Use exitCode (not process.exit) so async log streams can flush
|
|
process.exitCode = returnVal;
|
|
});
|
|
|
|
await program.parseAsync();
|
|
})().catch((e) => {
|
|
// Commander throws an error at the end of Action execution i.e. as part of the `help` and `version` commands, and so we don't want to return an error code in this case
|
|
if (
|
|
e instanceof CommanderError &&
|
|
(e.code === 'commander.helpDisplayed' || e.code === 'commander.version')
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// oxlint-disable-next-line no-console -- intentional: display critical error on CLI
|
|
console.error(e);
|
|
process.exit(99);
|
|
});
|