diff --git a/docs/usage/config-validation.md b/docs/usage/config-validation.md index e5bc4755c36f0a..f6b6f36a362f11 100644 --- a/docs/usage/config-validation.md +++ b/docs/usage/config-validation.md @@ -48,3 +48,20 @@ $ renovate-config-validator first_config.json You can create a [pre-commit](https://pre-commit.com) hook to validate your configuration automatically. Go to the [`renovatebot/pre-commit-hooks` repository](https://github.com/renovatebot/pre-commit-hooks) for more information. + +### Validation of Renovate config change PRs + +Renovate can validate configuration changes in Pull Requests when you use a special branch name. + +Follow these steps to validate your configuration: + +1. Create a new Git branch that matches the `{{branchPrefix}}reconfigure` pattern. For example, if you're using the default prefix `renovate/`, your branch name must be `renovate/reconfigure`. +1. Commit your updated Renovate config file to this branch, and push it to your Git hosting platform. + +The next time Renovate runs on that repo it will: + +1. Search for a branch that matches the special reconfigure pattern. +1. Check for a config file in the reconfigure branch. Renovate can even find a renamed configuration file (compared to the config file in the default branch). +1. Add a passing or failing status to the branch, depending on the outcome of the config validation run. +1. If there's an _open_ pull request with validation errors from the _reconfigure_ branch then Renovate comments in the PR with details. +1. Validate each commit the next time Renovate runs on the repository, until the PR is merged. diff --git a/lib/util/cache/repository/types.ts b/lib/util/cache/repository/types.ts index d4753ae4929e49..4560e7aa74023a 100644 --- a/lib/util/cache/repository/types.ts +++ b/lib/util/cache/repository/types.ts @@ -42,6 +42,11 @@ export interface OnboardingBranchCache { configFileParsed?: string; } +export interface ReconfigureBranchCache { + reconfigureBranchSha: string; + isConfigValid: boolean; +} + export interface PrCache { /** * Fingerprint of the PR body @@ -129,6 +134,7 @@ export interface RepoCacheData { }; prComments?: Record>; onboardingBranchCache?: OnboardingBranchCache; + reconfigureBranchCache?: ReconfigureBranchCache; } export interface RepoCache { diff --git a/lib/workers/repository/finalize/index.ts b/lib/workers/repository/finalize/index.ts index e0afb3893f1c1b..c4b291b6b1bb27 100644 --- a/lib/workers/repository/finalize/index.ts +++ b/lib/workers/repository/finalize/index.ts @@ -5,6 +5,7 @@ import * as repositoryCache from '../../../util/cache/repository'; import { clearRenovateRefs } from '../../../util/git'; import { configMigration } from '../config-migration'; import { PackageFiles } from '../package-files'; +import { validateReconfigureBranch } from '../reconfigure'; import { pruneStaleBranches } from './prune'; import { runBranchSummary, @@ -16,6 +17,7 @@ export async function finalizeRepo( config: RenovateConfig, branchList: string[] ): Promise { + await validateReconfigureBranch(config); await configMigration(config, branchList); await repositoryCache.saveCache(); await pruneStaleBranches(config, branchList); diff --git a/lib/workers/repository/finalize/prune.spec.ts b/lib/workers/repository/finalize/prune.spec.ts index cca995e99eccfe..3818b973aea8e7 100644 --- a/lib/workers/repository/finalize/prune.spec.ts +++ b/lib/workers/repository/finalize/prune.spec.ts @@ -37,6 +37,12 @@ describe('workers/repository/finalize/prune', () => { expect(git.getBranchList).toHaveBeenCalledTimes(0); }); + it('ignores reconfigure branch', async () => { + delete config.branchList; + await cleanup.pruneStaleBranches(config, config.branchList); + expect(git.getBranchList).toHaveBeenCalledTimes(0); + }); + it('returns if no renovate branches', async () => { config.branchList = []; git.getBranchList.mockReturnValueOnce([]); diff --git a/lib/workers/repository/finalize/prune.ts b/lib/workers/repository/finalize/prune.ts index 87cb4fb4d0cf18..15f06d56f216a6 100644 --- a/lib/workers/repository/finalize/prune.ts +++ b/lib/workers/repository/finalize/prune.ts @@ -6,6 +6,7 @@ import { platform } from '../../../modules/platform'; import { ensureComment } from '../../../modules/platform/comment'; import { scm } from '../../../modules/platform/scm'; import { getBranchList, setUserRepoConfig } from '../../../util/git'; +import { getReconfigureBranchName } from '../reconfigure'; async function cleanUpBranches( config: RenovateConfig, @@ -109,8 +110,10 @@ export async function pruneStaleBranches( return; } // TODO: types (#22198) - let renovateBranches = getBranchList().filter((branchName) => - branchName.startsWith(config.branchPrefix!) + let renovateBranches = getBranchList().filter( + (branchName) => + branchName.startsWith(config.branchPrefix!) && + branchName !== getReconfigureBranchName(config.branchPrefix!) ); if (!renovateBranches?.length) { logger.debug('No renovate branches found'); diff --git a/lib/workers/repository/init/merge.ts b/lib/workers/repository/init/merge.ts index a0233d22e5594a..7c4b2b0b81ac27 100644 --- a/lib/workers/repository/init/merge.ts +++ b/lib/workers/repository/init/merge.ts @@ -32,7 +32,7 @@ import { import { OnboardingState } from '../onboarding/common'; import type { RepoFileConfig } from './types'; -async function detectConfigFile(): Promise { +export async function detectConfigFile(): Promise { const fileList = await scm.getFileList(); for (const fileName of configFileNames) { if (fileName === 'package.json') { diff --git a/lib/workers/repository/reconfigure/index.spec.ts b/lib/workers/repository/reconfigure/index.spec.ts new file mode 100644 index 00000000000000..9efbf29065d5e7 --- /dev/null +++ b/lib/workers/repository/reconfigure/index.spec.ts @@ -0,0 +1,196 @@ +import { mock } from 'jest-mock-extended'; +import { + RenovateConfig, + fs, + git, + mocked, + platform, + scm, +} from '../../../../test/util'; +import { logger } from '../../../logger'; +import type { Pr } from '../../../modules/platform/types'; +import * as _cache from '../../../util/cache/repository'; +import * as _merge from '../init/merge'; +import { validateReconfigureBranch } from '.'; + +jest.mock('../../../util/cache/repository'); +jest.mock('../../../util/fs'); +jest.mock('../../../util/git'); +jest.mock('../init/merge'); + +const cache = mocked(_cache); +const merge = mocked(_merge); + +describe('workers/repository/reconfigure/index', () => { + const config: RenovateConfig = { + branchPrefix: 'prefix/', + baseBranch: 'base', + }; + + beforeEach(() => { + config.repository = 'some/repo'; + merge.detectConfigFile.mockResolvedValue('renovate.json'); + scm.branchExists.mockResolvedValue(true); + cache.getCache.mockReturnValue({}); + git.getBranchCommit.mockReturnValue('sha'); + fs.readLocalFile.mockResolvedValue(null); + platform.getBranchPr.mockResolvedValue(null); + platform.getBranchStatusCheck.mockResolvedValue(null); + }); + + it('no effect on repo with no reconfigure branch', async () => { + scm.branchExists.mockResolvedValueOnce(false); + await validateReconfigureBranch(config); + expect(logger.debug).toHaveBeenCalledWith('No reconfigure branch found'); + }); + + it('logs error if config file search fails', async () => { + const err = new Error(); + merge.detectConfigFile.mockRejectedValueOnce(err as never); + await validateReconfigureBranch(config); + expect(logger.error).toHaveBeenCalledWith( + { err }, + 'Error while searching for config file in reconfigure branch' + ); + }); + + it('throws error if config file not found in reconfigure branch', async () => { + merge.detectConfigFile.mockResolvedValue(null); + await validateReconfigureBranch(config); + expect(logger.warn).toHaveBeenCalledWith( + 'No config file found in reconfigure branch' + ); + }); + + it('logs error if config file is unreadable', async () => { + const err = new Error(); + fs.readLocalFile.mockRejectedValueOnce(err as never); + await validateReconfigureBranch(config); + expect(logger.error).toHaveBeenCalledWith( + { err }, + 'Error while reading config file' + ); + }); + + it('throws error if config file is empty', async () => { + await validateReconfigureBranch(config); + expect(logger.warn).toHaveBeenCalledWith('Empty or invalid config file'); + }); + + it('throws error if config file content is invalid', async () => { + fs.readLocalFile.mockResolvedValueOnce(` + { + "name": + } + `); + await validateReconfigureBranch(config); + expect(logger.error).toHaveBeenCalledWith( + { err: expect.any(Object) }, + 'Error while parsing config file' + ); + expect(platform.setBranchStatus).toHaveBeenCalledWith({ + branchName: 'prefix/reconfigure', + context: 'renovate/config-validation', + description: 'Validation Failed - Unparsable config file', + state: 'red', + }); + }); + + it('handles failed validation', async () => { + fs.readLocalFile.mockResolvedValueOnce(` + { + "enabledManagers": ["docker"] + } + `); + await validateReconfigureBranch(config); + expect(logger.debug).toHaveBeenCalledWith( + { errors: expect.any(String) }, + 'Validation Errors' + ); + expect(platform.setBranchStatus).toHaveBeenCalledWith({ + branchName: 'prefix/reconfigure', + context: 'renovate/config-validation', + description: 'Validation Failed', + state: 'red', + }); + }); + + it('adds comment if reconfigure PR exists', async () => { + fs.readLocalFile.mockResolvedValueOnce(` + { + "enabledManagers": ["docker"] + } + `); + platform.getBranchPr.mockResolvedValueOnce(mock({ number: 1 })); + await validateReconfigureBranch(config); + expect(logger.debug).toHaveBeenCalledWith( + { errors: expect.any(String) }, + 'Validation Errors' + ); + expect(platform.setBranchStatus).toHaveBeenCalled(); + expect(platform.ensureComment).toHaveBeenCalled(); + }); + + it('handles successful validation', async () => { + const pJson = ` + { + "renovate": { + "enabledManagers": ["npm"] + } + } + `; + merge.detectConfigFile.mockResolvedValue('package.json'); + fs.readLocalFile.mockResolvedValueOnce(pJson).mockResolvedValueOnce(pJson); + await validateReconfigureBranch(config); + expect(platform.setBranchStatus).toHaveBeenCalledWith({ + branchName: 'prefix/reconfigure', + context: 'renovate/config-validation', + description: 'Validation Successful', + state: 'green', + }); + }); + + it('skips validation if cache is valid', async () => { + cache.getCache.mockReturnValueOnce({ + reconfigureBranchCache: { + reconfigureBranchSha: 'sha', + isConfigValid: false, + }, + }); + await validateReconfigureBranch(config); + expect(logger.debug).toHaveBeenCalledWith( + 'Skipping validation check as branch sha is unchanged' + ); + }); + + it('skips validation if status check present', async () => { + cache.getCache.mockReturnValueOnce({ + reconfigureBranchCache: { + reconfigureBranchSha: 'new_sha', + isConfigValid: false, + }, + }); + platform.getBranchStatusCheck.mockResolvedValueOnce('green'); + await validateReconfigureBranch(config); + expect(logger.debug).toHaveBeenCalledWith( + 'Skipping validation check as status check already exists' + ); + }); + + it('handles non-default config file', async () => { + merge.detectConfigFile.mockResolvedValue('.renovaterc'); + fs.readLocalFile.mockResolvedValueOnce(` + { + "enabledManagers": ["npm",] + } + `); + platform.getBranchPr.mockResolvedValueOnce(mock({ number: 1 })); + await validateReconfigureBranch(config); + expect(platform.setBranchStatus).toHaveBeenCalledWith({ + branchName: 'prefix/reconfigure', + context: 'renovate/config-validation', + description: 'Validation Successful', + state: 'green', + }); + }); +}); diff --git a/lib/workers/repository/reconfigure/index.ts b/lib/workers/repository/reconfigure/index.ts new file mode 100644 index 00000000000000..281b2696bbd5aa --- /dev/null +++ b/lib/workers/repository/reconfigure/index.ts @@ -0,0 +1,174 @@ +import is from '@sindresorhus/is'; +import JSON5 from 'json5'; +import type { RenovateConfig } from '../../../config/types'; +import { validateConfig } from '../../../config/validation'; +import { logger } from '../../../logger'; +import { platform } from '../../../modules/platform'; +import { ensureComment } from '../../../modules/platform/comment'; +import { scm } from '../../../modules/platform/scm'; +import { getCache } from '../../../util/cache/repository'; +import { readLocalFile } from '../../../util/fs'; +import { getBranchCommit } from '../../../util/git'; +import { regEx } from '../../../util/regex'; +import { detectConfigFile } from '../init/merge'; +import { + deleteReconfigureBranchCache, + setReconfigureBranchCache, +} from './reconfigure-cache'; + +export function getReconfigureBranchName(prefix: string): string { + return `${prefix}reconfigure`; +} +export async function validateReconfigureBranch( + config: RenovateConfig +): Promise { + logger.debug('validateReconfigureBranch()'); + const context = `renovate/config-validation`; + + const branchName = getReconfigureBranchName(config.branchPrefix!); + const branchExists = await scm.branchExists(branchName); + + // this is something the user initiates, so skip if no branch exists + if (!branchExists) { + logger.debug('No reconfigure branch found'); + deleteReconfigureBranchCache(); // in order to remove cache when the branch has been deleted + return; + } + + // look for config file + // 1. check reconfigure branch cache and use the configFileName if it exists + // 2. checkout reconfigure branch and look for the config file, don't assume default configFileName + const branchSha = getBranchCommit(branchName)!; + const cache = getCache(); + let configFileName: string | null = null; + const reconfigureCache = cache.reconfigureBranchCache; + // only use valid cached information + if (reconfigureCache?.reconfigureBranchSha === branchSha) { + logger.debug('Skipping validation check as branch sha is unchanged'); + return; + } + + const validationStatus = await platform.getBranchStatusCheck( + branchName, + 'renovate/config-validation' + ); + // if old status check is present skip validation + if (is.nonEmptyString(validationStatus)) { + logger.debug('Skipping validation check as status check already exists'); + return; + } + + try { + await scm.checkoutBranch(branchName); + configFileName = await detectConfigFile(); + } catch (err) { + logger.error( + { err }, + 'Error while searching for config file in reconfigure branch' + ); + } + + if (!is.nonEmptyString(configFileName)) { + logger.warn('No config file found in reconfigure branch'); + await platform.setBranchStatus({ + branchName, + context, + description: 'Validation Failed - No config file found', + state: 'red', + }); + setReconfigureBranchCache(branchSha, false); + await scm.checkoutBranch(config.defaultBranch!); + return; + } + + let configFileRaw: string | null = null; + try { + configFileRaw = await readLocalFile(configFileName, 'utf8'); + } catch (err) { + logger.error({ err }, 'Error while reading config file'); + } + + if (!is.nonEmptyString(configFileRaw)) { + logger.warn('Empty or invalid config file'); + await platform.setBranchStatus({ + branchName, + context, + description: 'Validation Failed - Empty/Invalid config file', + state: 'red', + }); + setReconfigureBranchCache(branchSha, false); + await scm.checkoutBranch(config.baseBranch!); + return; + } + + let configFileParsed: any; + try { + configFileParsed = JSON5.parse(configFileRaw); + // no need to confirm renovate field in package.json we already do it in `detectConfigFile()` + if (configFileName === 'package.json') { + configFileParsed = configFileParsed.renovate; + } + } catch (err) { + logger.error({ err }, 'Error while parsing config file'); + await platform.setBranchStatus({ + branchName, + context, + description: 'Validation Failed - Unparsable config file', + state: 'red', + }); + setReconfigureBranchCache(branchSha, false); + await scm.checkoutBranch(config.baseBranch!); + return; + } + + // perform validation and provide a passing or failing check run based on result + const validationResult = await validateConfig(configFileParsed); + + // failing check + if (validationResult.errors.length > 0) { + logger.debug( + { errors: validationResult.errors.map((err) => err.message).join(', ') }, + 'Validation Errors' + ); + + // add comment to reconfigure PR if it exists + const branchPr = await platform.getBranchPr( + branchName, + config.defaultBranch + ); + if (branchPr) { + let body = `There is an error with this repository's Renovate configuration that needs to be fixed.\n\n`; + body += `Location: \`${configFileName}\`\n`; + body += `Message: \`${validationResult.errors + .map((e) => e.message) + .join(', ') + .replace(regEx(/`/g), "'")}\`\n`; + + await ensureComment({ + number: branchPr.number, + topic: 'Action Required: Fix Renovate Configuration', + content: body, + }); + } + await platform.setBranchStatus({ + branchName, + context, + description: 'Validation Failed', + state: 'red', + }); + setReconfigureBranchCache(branchSha, false); + await scm.checkoutBranch(config.baseBranch!); + return; + } + + // passing check + await platform.setBranchStatus({ + branchName, + context, + description: 'Validation Successful', + state: 'green', + }); + + setReconfigureBranchCache(branchSha, true); + await scm.checkoutBranch(config.baseBranch!); +} diff --git a/lib/workers/repository/reconfigure/reconfigure-cache.spec.ts b/lib/workers/repository/reconfigure/reconfigure-cache.spec.ts new file mode 100644 index 00000000000000..166f69f1495040 --- /dev/null +++ b/lib/workers/repository/reconfigure/reconfigure-cache.spec.ts @@ -0,0 +1,58 @@ +import { mocked } from '../../../../test/util'; +import * as _cache from '../../../util/cache/repository'; +import type { RepoCacheData } from '../../../util/cache/repository/types'; +import { + deleteReconfigureBranchCache, + setReconfigureBranchCache, +} from './reconfigure-cache'; + +jest.mock('../../../util/cache/repository'); + +const cache = mocked(_cache); + +describe('workers/repository/reconfigure/reconfigure-cache', () => { + describe('setReconfigureBranchCache()', () => { + it('sets new cache', () => { + const dummyCache = {} satisfies RepoCacheData; + cache.getCache.mockReturnValue(dummyCache); + setReconfigureBranchCache('reconfigure-sha', false); + expect(dummyCache).toEqual({ + reconfigureBranchCache: { + reconfigureBranchSha: 'reconfigure-sha', + isConfigValid: false, + }, + }); + }); + + it('updates old cache', () => { + const dummyCache = { + reconfigureBranchCache: { + reconfigureBranchSha: 'reconfigure-sha', + isConfigValid: false, + }, + } satisfies RepoCacheData; + cache.getCache.mockReturnValue(dummyCache); + setReconfigureBranchCache('reconfigure-sha-1', false); + expect(dummyCache).toEqual({ + reconfigureBranchCache: { + reconfigureBranchSha: 'reconfigure-sha-1', + isConfigValid: false, + }, + }); + }); + }); + + describe('deleteReconfigureBranchCache()', () => { + it('deletes cache', () => { + const dummyCache = { + reconfigureBranchCache: { + reconfigureBranchSha: 'reconfigure-sha', + isConfigValid: false, + }, + } satisfies RepoCacheData; + cache.getCache.mockReturnValue(dummyCache); + deleteReconfigureBranchCache(); + expect(dummyCache.reconfigureBranchCache).toBeUndefined(); + }); + }); +}); diff --git a/lib/workers/repository/reconfigure/reconfigure-cache.ts b/lib/workers/repository/reconfigure/reconfigure-cache.ts new file mode 100644 index 00000000000000..03bd9678375cc2 --- /dev/null +++ b/lib/workers/repository/reconfigure/reconfigure-cache.ts @@ -0,0 +1,28 @@ +import { logger } from '../../../logger'; +import { getCache } from '../../../util/cache/repository'; + +export function setReconfigureBranchCache( + reconfigureBranchSha: string, + isConfigValid: boolean +): void { + const cache = getCache(); + const reconfigureBranchCache = { + reconfigureBranchSha, + isConfigValid, + }; + if (cache.reconfigureBranchCache) { + logger.debug({ reconfigureBranchCache }, 'Update reconfigure branch cache'); + } else { + logger.debug({ reconfigureBranchCache }, 'Create reconfigure branch cache'); + } + cache.reconfigureBranchCache = reconfigureBranchCache; +} + +export function deleteReconfigureBranchCache(): void { + const cache = getCache(); + + if (cache?.reconfigureBranchCache) { + logger.debug('Delete reconfigure branch cache'); + delete cache.reconfigureBranchCache; + } +}