Files
renovate/lib/config-validator.ts
T
silverwind 11a217ea2e fix(config-validator): warn when RE2 is not in use (#44894)
* fix(config-validator): warn when RE2 is not in use

Without RE2 the validator checks patterns with the JS RegExp engine, which
accepts syntax RE2 rejects, so configs can validate clean here and then be
rejected when Renovate runs.

Co-Authored-By: Claude (Opus 5) <noreply@anthropic.com>

* fix(config-validator): debug log for the RE2 opt-out

Setting RENOVATE_X_IGNORE_RE2 is deliberate, so it does not warrant a warning,
matching what the global worker already does.

Co-Authored-By: Claude (Opus 5) <noreply@anthropic.com>

---------

Co-authored-by: Claude (Opus 5) <noreply@anthropic.com>
Co-authored-by: Jamie Tanna <jamie.tanna@mend.io>
2026-07-30 11:00:48 +00:00

317 lines
10 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) {
for (const hostRule of globalConfig.hostRules) {
addHostRule(hostRule);
}
}
}
async function validate(
configType: 'global' | 'repo',
desc: string,
config: RenovateConfig,
strict: boolean,
isPreset = false,
): Promise<void> {
if (config.hostRules) {
for (const hostRule of config.hostRules) {
addHostRule(hostRule);
}
}
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);
});