Skip to content

Commit

Permalink
feat(config): validate reconfigure branch (#24699)
Browse files Browse the repository at this point in the history
Co-authored-by: HonkingGoose <34918129+HonkingGoose@users.noreply.github.com>
Co-authored-by: Rhys Arkins <rhys@arkins.net>
Co-authored-by: Sebastian Poxhofer <secustor@users.noreply.github.com>
Co-authored-by: Michael Kriese <michael.kriese@visualon.de>
  • Loading branch information
5 people committed Nov 3, 2023
1 parent 947e5f9 commit 32340db
Show file tree
Hide file tree
Showing 10 changed files with 493 additions and 3 deletions.
17 changes: 17 additions & 0 deletions docs/usage/config-validation.md
Expand Up @@ -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.
6 changes: 6 additions & 0 deletions lib/util/cache/repository/types.ts
Expand Up @@ -42,6 +42,11 @@ export interface OnboardingBranchCache {
configFileParsed?: string;
}

export interface ReconfigureBranchCache {
reconfigureBranchSha: string;
isConfigValid: boolean;
}

export interface PrCache {
/**
* Fingerprint of the PR body
Expand Down Expand Up @@ -129,6 +134,7 @@ export interface RepoCacheData {
};
prComments?: Record<number, Record<string, string>>;
onboardingBranchCache?: OnboardingBranchCache;
reconfigureBranchCache?: ReconfigureBranchCache;
}

export interface RepoCache {
Expand Down
2 changes: 2 additions & 0 deletions lib/workers/repository/finalize/index.ts
Expand Up @@ -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,
Expand All @@ -16,6 +17,7 @@ export async function finalizeRepo(
config: RenovateConfig,
branchList: string[]
): Promise<void> {
await validateReconfigureBranch(config);
await configMigration(config, branchList);
await repositoryCache.saveCache();
await pruneStaleBranches(config, branchList);
Expand Down
6 changes: 6 additions & 0 deletions lib/workers/repository/finalize/prune.spec.ts
Expand Up @@ -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([]);
Expand Down
7 changes: 5 additions & 2 deletions lib/workers/repository/finalize/prune.ts
Expand Up @@ -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,
Expand Down Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion lib/workers/repository/init/merge.ts
Expand Up @@ -32,7 +32,7 @@ import {
import { OnboardingState } from '../onboarding/common';
import type { RepoFileConfig } from './types';

async function detectConfigFile(): Promise<string | null> {
export async function detectConfigFile(): Promise<string | null> {
const fileList = await scm.getFileList();
for (const fileName of configFileNames) {
if (fileName === 'package.json') {
Expand Down
196 changes: 196 additions & 0 deletions 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<Pr>({ 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<Pr>({ number: 1 }));
await validateReconfigureBranch(config);
expect(platform.setBranchStatus).toHaveBeenCalledWith({
branchName: 'prefix/reconfigure',
context: 'renovate/config-validation',
description: 'Validation Successful',
state: 'green',
});
});
});

0 comments on commit 32340db

Please sign in to comment.