diff --git a/docs/development/configuration.md b/docs/development/configuration.md index d3bfca3b7f6edd..a98d05be8e6863 100644 --- a/docs/development/configuration.md +++ b/docs/development/configuration.md @@ -20,6 +20,10 @@ Options which have `"globalOnly": true` are reserved only for bot global configu You can override default configuration using a configuration file, with default name `config.js` in the working directory. If you need an alternate location or name, set it in the environment variable `RENOVATE_CONFIG_FILE`. +**Note:** `RENOVATE_CONFIG_FILE` must be provided with an explicit file extension. +For example `RENOVATE_CONFIG_FILE=myconfig.js` or `RENOVATE_CONFIG_FILE=myconfig.json` and not `RENOVATE_CONFIG_FILE=myconfig`. +If none is provided, or the file type is invalid, Renovate will fail. + Using a configuration file gives you very granular configuration options. For instance, you can override most settings at the global (file), repository, or package level. e.g. apply one set of labels for `backend/package.json` and a different set of labels for `frontend/package.json` in the same repository. diff --git a/lib/config-validator.ts b/lib/config-validator.ts index aac81196482a76..383c06e148da36 100644 --- a/lib/config-validator.ts +++ b/lib/config-validator.ts @@ -2,14 +2,16 @@ // istanbul ignore file import { dequal } from 'dequal'; import { readFile } from 'fs-extra'; -import JSON5 from 'json5'; import { configFileNames } from './config/app-strings'; import { massageConfig } from './config/massage'; import { migrateConfig } from './config/migration'; import type { RenovateConfig } from './config/types'; import { validateConfig } from './config/validation'; import { logger } from './logger'; -import { getConfig as getFileConfig } from './workers/global/config/parse/file'; +import { + getConfig as getFileConfig, + getParsedContent, +} from './workers/global/config/parse/file'; let returnVal = 0; @@ -52,22 +54,17 @@ type PackageJson = { (name) => name !== 'package.json' )) { try { - const rawContent = await readFile(file, 'utf8'); - logger.info(`Validating ${file}`); + const parsedContent = await getParsedContent(file); try { - let jsonContent: RenovateConfig; - if (file.endsWith('.json5')) { - jsonContent = JSON5.parse(rawContent); - } else { - jsonContent = JSON.parse(rawContent); - } - await validate(file, jsonContent); + logger.info(`Validating ${file}`); + await validate(file, parsedContent); } catch (err) { logger.info({ err }, `${file} is not valid Renovate config`); returnVal = 1; } } catch (err) { // file does not exist + continue; } } try { diff --git a/lib/workers/global/config/parse/__fixtures__/config.json5 b/lib/workers/global/config/parse/__fixtures__/config.json5 new file mode 100644 index 00000000000000..05d6407ca7b492 --- /dev/null +++ b/lib/workers/global/config/parse/__fixtures__/config.json5 @@ -0,0 +1,4 @@ +{ + // comment + "token": "abcdefg", +} diff --git a/lib/workers/global/config/parse/__fixtures__/config.yaml b/lib/workers/global/config/parse/__fixtures__/config.yaml new file mode 100644 index 00000000000000..30bd5dbba13bb0 --- /dev/null +++ b/lib/workers/global/config/parse/__fixtures__/config.yaml @@ -0,0 +1,3 @@ +--- +# comment +token: abcdefg diff --git a/lib/workers/global/config/parse/file.spec.ts b/lib/workers/global/config/parse/file.spec.ts index 6e629aa2448248..cf497566a42a59 100644 --- a/lib/workers/global/config/parse/file.spec.ts +++ b/lib/workers/global/config/parse/file.spec.ts @@ -1,6 +1,7 @@ import fs from 'fs'; import { DirectoryResult, dir } from 'tmp-promise'; import upath from 'upath'; +import { logger } from '../../../../logger'; import customConfig from './__fixtures__/file'; import * as file from './file'; @@ -16,12 +17,17 @@ describe('workers/global/config/parse/file', () => { }); describe('.getConfig()', () => { - it('parses custom config file', async () => { - const configFile = upath.resolve(__dirname, './__fixtures__/file.js'); + it.each([ + ['custom config file with extension', 'file.js'], + ['JSON5 config file', 'config.json5'], + ['YAML config file', 'config.yaml'], + ])('parses %s', async (fileType, filePath) => { + const configFile = upath.resolve(__dirname, './__fixtures__/', filePath); expect( await file.getConfig({ RENOVATE_CONFIG_FILE: configFile }) ).toEqual(customConfig); }); + it('migrates', async () => { const configFile = upath.resolve(__dirname, './__fixtures__/file2.js'); const res = await file.getConfig({ RENOVATE_CONFIG_FILE: configFile }); @@ -33,12 +39,10 @@ describe('workers/global/config/parse/file', () => { expect(await file.getConfig({})).toBeDefined(); }); - it('fatal error and exit if error in parsing config.js', async () => { - const mockProcessExit = jest - .spyOn(process, 'exit') - .mockImplementation(() => undefined as never); - const configFile = upath.resolve(tmp.path, './file3.js'); - const fileContent = `module.exports = { + it.each([ + [ + 'config.js', + `module.exports = { "platform": "github", "token":"abcdef", "logFileLevel": "warn", @@ -48,13 +52,23 @@ describe('workers/global/config/parse/file', () => { "extends": ["config:base"], }, "repositories": [ "test/test" ], - };`; - fs.writeFileSync(configFile, fileContent, { encoding: 'utf8' }); - await file.getConfig({ RENOVATE_CONFIG_FILE: configFile }); - expect(mockProcessExit).toHaveBeenCalledWith(1); - - fs.unlinkSync(configFile); - }); + };`, + ], + ['config.json5', `"invalid":`], + ['config.yaml', `invalid: -`], + ])( + 'fatal error and exit if error in parsing %s', + async (fileName, fileContent) => { + const mockProcessExit = jest + .spyOn(process, 'exit') + .mockImplementationOnce(() => undefined as never); + const configFile = upath.resolve(tmp.path, fileName); + fs.writeFileSync(configFile, fileContent, { encoding: 'utf8' }); + await file.getConfig({ RENOVATE_CONFIG_FILE: configFile }); + expect(mockProcessExit).toHaveBeenCalledWith(1); + fs.unlinkSync(configFile); + } + ); it('fatal error and exit if custom config file does not exist', async () => { const mockProcessExit = jest @@ -66,5 +80,20 @@ describe('workers/global/config/parse/file', () => { expect(mockProcessExit).toHaveBeenCalledWith(1); }); + + it.each([ + ['invalid config file type', './file.txt'], + ['missing config file type', './file'], + ])('fatal error and exit if %s', async (fileType, filePath) => { + const mockProcessExit = jest + .spyOn(process, 'exit') + .mockImplementationOnce(() => undefined as never); + const configFile = upath.resolve(tmp.path, filePath); + fs.writeFileSync(configFile, `{"token": "abc"}`, { encoding: 'utf8' }); + await file.getConfig({ RENOVATE_CONFIG_FILE: configFile }); + expect(mockProcessExit).toHaveBeenCalledWith(1); + expect(logger.fatal).toHaveBeenCalledWith('Unsupported file type'); + fs.unlinkSync(configFile); + }); }); }); diff --git a/lib/workers/global/config/parse/file.ts b/lib/workers/global/config/parse/file.ts index efdefb62b313c0..103d24d8692f4e 100644 --- a/lib/workers/global/config/parse/file.ts +++ b/lib/workers/global/config/parse/file.ts @@ -1,23 +1,47 @@ +import { load } from 'js-yaml'; +import JSON5 from 'json5'; import upath from 'upath'; import { migrateConfig } from '../../../../config/migration'; -import type { AllConfig } from '../../../../config/types'; +import type { AllConfig, RenovateConfig } from '../../../../config/types'; import { logger } from '../../../../logger'; +import { readFile } from '../../../../util/fs'; + +export async function getParsedContent(file: string): Promise { + switch (upath.extname(file)) { + case '.yaml': + case '.yml': + return load(await readFile(file, 'utf8'), { + json: true, + }) as RenovateConfig; + case '.json5': + case '.json': + return JSON5.parse(await readFile(file, 'utf8')); + case '.js': { + const tmpConfig = await import(file); + return tmpConfig.default ? tmpConfig.default : tmpConfig; + } + default: + throw new Error('Unsupported file type'); + } +} export async function getConfig(env: NodeJS.ProcessEnv): Promise { - let configFile = env.RENOVATE_CONFIG_FILE || 'config'; + let configFile = env.RENOVATE_CONFIG_FILE || 'config.js'; if (!upath.isAbsolute(configFile)) { configFile = `${process.cwd()}/${configFile}`; - logger.debug('Checking for config file in ' + configFile); } + logger.debug('Checking for config file in ' + configFile); let config: AllConfig = {}; try { - const tmpConfig = await import(configFile); - config = tmpConfig.default ? tmpConfig.default : tmpConfig; + config = await getParsedContent(configFile); } catch (err) { // istanbul ignore if if (err instanceof SyntaxError || err instanceof TypeError) { logger.fatal(`Could not parse config file \n ${err.stack}`); process.exit(1); + } else if (err.message === 'Unsupported file type') { + logger.fatal(err.message); + process.exit(1); } else if (env.RENOVATE_CONFIG_FILE) { logger.fatal('No custom config file found on disk'); process.exit(1);