diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md index 650433f09028e9..17799486a4475b 100644 --- a/docs/usage/configuration-options.md +++ b/docs/usage/configuration-options.md @@ -2323,6 +2323,24 @@ This works because Renovate will add a "renovate/stability-days" pending status Add to this object if you wish to define rules that apply only to minor updates. +## mode + +This configuration option was created primarily for use with Mend's hosted app, but can also be useful for some self-hosted use cases. + +It enables a new `silent` mode to allow repos to be scanned for updates _and_ for users to be able to request such updates be opened in PRs _on demand_ through the Mend UI, without needing the Dependency Dashboard issue in the repo. + +Although similar, the options `mode=silent` and `dryRun` can be used together. +When both are configured, `dryRun` takes precedence, so for example PRs won't be created. + +Configuring `silent` mode is quite similar to `dryRun=lookup` except: + +- It will bypass onboarding checks (unlike when performing a dry run on a non-onboarded repo) similar to `requireConfig=optional` +- It can create branches/PRs if `checkedBranches` is set +- It will keep any existing branches up-to-date (e.g. ones created previously using `checkedBranches`) + +When in `silent` mode, Renovate does not create issues (such as Dependency Dashboard, or due to config errors) or Config Migration PRs, even if enabled. +It also does not prune/close any which already exist. + ## npmToken See [Private npm module support](./getting-started/private-packages.md) for details on how this is used. diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts index 5a2aa34c339fd4..af3688da7dd107 100644 --- a/lib/config/options/index.ts +++ b/lib/config/options/index.ts @@ -5,6 +5,13 @@ import { getVersioningList } from '../../modules/versioning'; import type { RenovateOptions } from '../types'; const options: RenovateOptions[] = [ + { + name: 'mode', + description: 'Mode of operation.', + type: 'string', + default: 'full', + allowedValues: ['full', 'silent'], + }, { name: 'allowedHeaders', description: diff --git a/lib/workers/repository/config-migration/index.spec.ts b/lib/workers/repository/config-migration/index.spec.ts index 9f107189397a1a..3067573bedbeb8 100644 --- a/lib/workers/repository/config-migration/index.spec.ts +++ b/lib/workers/repository/config-migration/index.spec.ts @@ -28,6 +28,13 @@ describe('workers/repository/config-migration/index', () => { }); }); + it('does nothing when in silent mode', async () => { + await configMigration({ ...config, mode: 'silent' }, []); + expect(MigratedDataFactory.getAsync).toHaveBeenCalledTimes(0); + expect(checkConfigMigrationBranch).toHaveBeenCalledTimes(0); + expect(ensureConfigMigrationPr).toHaveBeenCalledTimes(0); + }); + it('does nothing when config migration is disabled', async () => { await configMigration({ ...config, configMigration: false }, []); expect(MigratedDataFactory.getAsync).toHaveBeenCalledTimes(0); diff --git a/lib/workers/repository/config-migration/index.ts b/lib/workers/repository/config-migration/index.ts index 0746ccdf8d77be..856b0ceb277268 100644 --- a/lib/workers/repository/config-migration/index.ts +++ b/lib/workers/repository/config-migration/index.ts @@ -1,4 +1,5 @@ import type { RenovateConfig } from '../../../config/types'; +import { logger } from '../../../logger'; import { checkConfigMigrationBranch } from './branch'; import { MigratedDataFactory } from './branch/migrated-data'; import { ensureConfigMigrationPr } from './pr'; @@ -8,6 +9,12 @@ export async function configMigration( branchList: string[], ): Promise { if (config.configMigration) { + if (config.mode === 'silent') { + logger.debug( + 'Config migration issues are not created, updated or closed when mode=silent', + ); + return; + } const migratedConfigData = await MigratedDataFactory.getAsync(); const migrationBranch = await checkConfigMigrationBranch( config, diff --git a/lib/workers/repository/dependency-dashboard.spec.ts b/lib/workers/repository/dependency-dashboard.spec.ts index 8b12cbfba9e73d..54be5280260931 100644 --- a/lib/workers/repository/dependency-dashboard.spec.ts +++ b/lib/workers/repository/dependency-dashboard.spec.ts @@ -241,6 +241,17 @@ describe('workers/repository/dependency-dashboard', () => { logger.getProblems.mockReturnValue([]); }); + it('does nothing if mode=silent', async () => { + const branches: BranchConfig[] = []; + config.mode = 'silent'; + await dependencyDashboard.ensureDependencyDashboard(config, branches); + expect(platform.ensureIssueClosing).toHaveBeenCalledTimes(0); + expect(platform.ensureIssue).toHaveBeenCalledTimes(0); + + // same with dry run + await dryRun(branches, platform, 0, 0); + }); + it('do nothing if dependencyDashboard is disabled', async () => { const branches: BranchConfig[] = []; await dependencyDashboard.ensureDependencyDashboard(config, branches); diff --git a/lib/workers/repository/dependency-dashboard.ts b/lib/workers/repository/dependency-dashboard.ts index 92a9bbf6dc4aca..17d895c35b22ad 100644 --- a/lib/workers/repository/dependency-dashboard.ts +++ b/lib/workers/repository/dependency-dashboard.ts @@ -181,6 +181,12 @@ export async function ensureDependencyDashboard( packageFiles: Record = {}, ): Promise { logger.debug('ensureDependencyDashboard()'); + if (config.mode === 'silent') { + logger.debug( + 'Dependency Dashboard issue is not created, updated or closed when mode=silent', + ); + return; + } // legacy/migrated issue const reuseTitle = 'Update Dependencies (Renovate Bot)'; const branches = allBranches.filter( diff --git a/lib/workers/repository/error-config.spec.ts b/lib/workers/repository/error-config.spec.ts index f08d0fad23d6e3..f3dc7fb6f05dfd 100644 --- a/lib/workers/repository/error-config.spec.ts +++ b/lib/workers/repository/error-config.spec.ts @@ -29,6 +29,20 @@ describe('workers/repository/error-config', () => { GlobalConfig.reset(); }); + it('returns if mode is silent', async () => { + config.mode = 'silent'; + + const res = await raiseConfigWarningIssue( + config, + new Error(CONFIG_VALIDATION), + ); + + expect(res).toBeUndefined(); + expect(logger.debug).toHaveBeenCalledWith( + 'Config warning issues are not created, updated or closed when mode=silent', + ); + }); + it('creates issues', async () => { const expectedBody = `There are missing credentials for the authentication-required feature. As a precaution, Renovate will pause PRs until it is resolved. diff --git a/lib/workers/repository/error-config.ts b/lib/workers/repository/error-config.ts index 3654ef90db65bb..fb5c735bde65a4 100644 --- a/lib/workers/repository/error-config.ts +++ b/lib/workers/repository/error-config.ts @@ -33,6 +33,12 @@ async function raiseWarningIssue( initialBody: string, error: Error, ): Promise { + if (config.mode === 'silent') { + logger.debug( + `Config warning issues are not created, updated or closed when mode=silent`, + ); + return; + } let body = initialBody; if (error.validationSource) { body += `Location: \`${error.validationSource}\`\n`; diff --git a/lib/workers/repository/init/index.spec.ts b/lib/workers/repository/init/index.spec.ts index 1927900bcd683f..4f96f018118905 100644 --- a/lib/workers/repository/init/index.spec.ts +++ b/lib/workers/repository/init/index.spec.ts @@ -38,7 +38,7 @@ describe('workers/repository/init/index', () => { it('runs', async () => { apis.initApis.mockResolvedValue(partial<_apis.WorkerPlatformConfig>()); onboarding.checkOnboardingBranch.mockResolvedValueOnce({}); - config.getRepoConfig.mockResolvedValueOnce({}); + config.getRepoConfig.mockResolvedValueOnce({ mode: 'silent' }); merge.mergeRenovateConfig.mockResolvedValueOnce({}); secrets.applySecretsToConfig.mockReturnValueOnce( partial(), diff --git a/lib/workers/repository/init/index.ts b/lib/workers/repository/init/index.ts index f4d3ac84dbeab0..8f21f936db2e40 100644 --- a/lib/workers/repository/init/index.ts +++ b/lib/workers/repository/init/index.ts @@ -54,6 +54,11 @@ export async function initRepo( await initializeCaches(config as WorkerPlatformConfig); config = await getRepoConfig(config); setRepositoryLogLevelRemaps(config.logLevelRemap); + if (config.mode === 'silent') { + logger.info( + 'Repository is running with mode=silent and will not make Issues or PRs by default', + ); + } checkIfConfigured(config); warnOnUnsupportedOptions(config); config = applySecretsToConfig(config); diff --git a/lib/workers/repository/init/merge.spec.ts b/lib/workers/repository/init/merge.spec.ts index 51fb01fe249936..7cc7a92a057cec 100644 --- a/lib/workers/repository/init/merge.spec.ts +++ b/lib/workers/repository/init/merge.spec.ts @@ -280,6 +280,18 @@ describe('workers/repository/init/merge', () => { }); }); + it('uses onboarding config if silent', async () => { + scm.getFileList.mockResolvedValue([]); + migrateAndValidate.migrateAndValidate.mockResolvedValue({ + warnings: [], + errors: [], + }); + config.mode = 'silent'; + config.repository = 'some-org/some-repo'; + const res = await mergeRenovateConfig(config); + expect(res).toBeDefined(); + }); + it('throws error if misconfigured', async () => { scm.getFileList.mockResolvedValue(['package.json', '.renovaterc.json']); fs.readLocalFile.mockResolvedValue('{}'); diff --git a/lib/workers/repository/init/merge.ts b/lib/workers/repository/init/merge.ts index c8ced5681b9e3a..dff41b852fafe7 100644 --- a/lib/workers/repository/init/merge.ts +++ b/lib/workers/repository/init/merge.ts @@ -23,6 +23,8 @@ import { readLocalFile } from '../../../util/fs'; import * as hostRules from '../../../util/host-rules'; import * as queue from '../../../util/http/queue'; import * as throttle from '../../../util/http/throttle'; +import { getOnboardingConfig } from '../onboarding/branch/config'; +import { getDefaultConfigFileName } from '../onboarding/branch/create'; import { getOnboardingConfigFromCache, getOnboardingFileNameFromCache, @@ -58,7 +60,7 @@ export async function detectConfigFile(): Promise { export async function detectRepoFileConfig(): Promise { const cache = getCache(); let { configFileName } = cache; - if (configFileName) { + if (is.nonEmptyString(configFileName)) { let configFileRaw: string | null; try { configFileRaw = await platform.getRawFile(configFileName); @@ -89,6 +91,7 @@ export async function detectRepoFileConfig(): Promise { if (!configFileName) { logger.debug('No renovate config file found'); + cache.configFileName = ''; return {}; } cache.configFileName = configFileName; @@ -171,6 +174,16 @@ export async function mergeRenovateConfig( if (config.requireConfig !== 'ignored') { repoConfig = await detectRepoFileConfig(); } + if (!repoConfig.configFileParsed && config.mode === 'silent') { + logger.debug( + 'When mode=silent and repo has no config file, we use the onboarding config as repo config', + ); + const configFileName = getDefaultConfigFileName(config); + repoConfig = { + configFileName, + configFileParsed: await getOnboardingConfig(config), + }; + } const configFileParsed = repoConfig?.configFileParsed || {}; if (is.nonEmptyArray(returnConfig.extends)) { configFileParsed.extends = [ diff --git a/lib/workers/repository/onboarding/branch/check.spec.ts b/lib/workers/repository/onboarding/branch/check.spec.ts index d4130c9a1db812..1761e95932037c 100644 --- a/lib/workers/repository/onboarding/branch/check.spec.ts +++ b/lib/workers/repository/onboarding/branch/check.spec.ts @@ -25,6 +25,11 @@ describe('workers/repository/onboarding/branch/check', () => { onboarding: true, }); + it('returns true if in silent mode', async () => { + const res = await isOnboarded({ ...config, mode: 'silent' }); + expect(res).toBeTrue(); + }); + it('skips normal onboarding check if onboardingCache is valid', async () => { cache.getCache.mockReturnValueOnce({ onboardingBranchCache: { diff --git a/lib/workers/repository/onboarding/branch/check.ts b/lib/workers/repository/onboarding/branch/check.ts index c3773f789b9dbb..e4699ac03cb181 100644 --- a/lib/workers/repository/onboarding/branch/check.ts +++ b/lib/workers/repository/onboarding/branch/check.ts @@ -63,6 +63,12 @@ export async function isOnboarded(config: RenovateConfig): Promise { logger.debug('isOnboarded()'); const title = `Action required: Add a Renovate config`; + // Repo is onboarded if in silent mode + if (config.mode === 'silent') { + logger.debug('Silent mode enabled so repo is considered onboarded'); + return true; + } + // Repo is onboarded if global config is bypassing onboarding and does not require a // configuration file. // The repo is considered "not onboarded" if: diff --git a/lib/workers/repository/onboarding/branch/create.ts b/lib/workers/repository/onboarding/branch/create.ts index 708d82cc33f167..8070cfed2a5831 100644 --- a/lib/workers/repository/onboarding/branch/create.ts +++ b/lib/workers/repository/onboarding/branch/create.ts @@ -9,22 +9,27 @@ import { getOnboardingConfigContents } from './config'; const defaultConfigFile = configFileNames[0]; -export async function createOnboardingBranch( +export function getDefaultConfigFileName( config: Partial, -): Promise { +): string { // TODO #22198 - const configFile = configFileNames.includes(config.onboardingConfigFileName!) - ? config.onboardingConfigFileName + return configFileNames.includes(config.onboardingConfigFileName!) + ? config.onboardingConfigFileName! : defaultConfigFile; +} +export async function createOnboardingBranch( + config: Partial, +): Promise { logger.debug('createOnboardingBranch()'); + const configFile = getDefaultConfigFileName(config); // TODO #22198 - const contents = await getOnboardingConfigContents(config, configFile!); + const contents = await getOnboardingConfigContents(config, configFile); logger.debug('Creating onboarding branch'); const commitMessageFactory = new OnboardingCommitMessageFactory( config, - configFile!, + configFile, ); let commitMessage = commitMessageFactory.create().toString(); @@ -51,7 +56,7 @@ export async function createOnboardingBranch( { type: 'addition', // TODO #22198 - path: configFile!, + path: configFile, contents, }, ], diff --git a/lib/workers/repository/process/deprecated.spec.ts b/lib/workers/repository/process/deprecated.spec.ts index a0981c7177202e..1f15b73f6db1fe 100644 --- a/lib/workers/repository/process/deprecated.spec.ts +++ b/lib/workers/repository/process/deprecated.spec.ts @@ -16,6 +16,15 @@ describe('workers/repository/process/deprecated', () => { await expect(raiseDeprecationWarnings(config, {})).resolves.not.toThrow(); }); + it('returns if in silent mode', async () => { + const config: RenovateConfig = { + repoIsOnboarded: true, + suppressNotifications: [], + mode: 'silent', + }; + await expect(raiseDeprecationWarnings(config, {})).resolves.not.toThrow(); + }); + it('raises deprecation warnings', async () => { const config: RenovateConfig = { repoIsOnboarded: true, diff --git a/lib/workers/repository/process/deprecated.ts b/lib/workers/repository/process/deprecated.ts index 43c699cbf43386..0a27d71b531572 100644 --- a/lib/workers/repository/process/deprecated.ts +++ b/lib/workers/repository/process/deprecated.ts @@ -15,6 +15,12 @@ export async function raiseDeprecationWarnings( if (config.suppressNotifications?.includes('deprecationWarningIssues')) { return; } + if (config.mode === 'silent') { + logger.debug( + `Deprecation warning issues are not created, updated or closed when mode=silent`, + ); + return; + } for (const [manager, files] of Object.entries(packageFiles)) { const deprecatedPackages: Record< string, diff --git a/lib/workers/repository/update/branch/index.spec.ts b/lib/workers/repository/update/branch/index.spec.ts index e5f17516c6bb98..e71f943e5f7249 100644 --- a/lib/workers/repository/update/branch/index.spec.ts +++ b/lib/workers/repository/update/branch/index.spec.ts @@ -543,6 +543,42 @@ describe('workers/repository/update/branch/index', () => { }); }); + it('returns if branch does not exist and in silent mode', async () => { + getUpdated.getUpdatedPackageFiles.mockResolvedValueOnce({ + ...updatedPackageFiles, + }); + npmPostExtract.getAdditionalFiles.mockResolvedValueOnce({ + artifactErrors: [], + updatedArtifacts: [], + }); + scm.branchExists.mockResolvedValue(false); + GlobalConfig.set({ ...adminConfig }); + config.mode = 'silent'; + expect(await branchWorker.processBranch(config)).toEqual({ + branchExists: false, + prNo: undefined, + result: 'needs-approval', + }); + }); + + it('returns if branch needs dependencyDashboardApproval', async () => { + getUpdated.getUpdatedPackageFiles.mockResolvedValueOnce({ + ...updatedPackageFiles, + }); + npmPostExtract.getAdditionalFiles.mockResolvedValueOnce({ + artifactErrors: [], + updatedArtifacts: [], + }); + scm.branchExists.mockResolvedValue(false); + GlobalConfig.set({ ...adminConfig }); + config.dependencyDashboardApproval = true; + expect(await branchWorker.processBranch(config)).toEqual({ + branchExists: false, + prNo: undefined, + result: 'needs-approval', + }); + }); + it('returns if pr creation limit exceeded and branch exists', async () => { getUpdated.getUpdatedPackageFiles.mockResolvedValueOnce({ ...updatedPackageFiles, diff --git a/lib/workers/repository/update/branch/index.ts b/lib/workers/repository/update/branch/index.ts index 762d55622c86d4..08a1fabd2bea27 100644 --- a/lib/workers/repository/update/branch/index.ts +++ b/lib/workers/repository/update/branch/index.ts @@ -183,12 +183,21 @@ export async function processBranch( result: 'pending', }; } - // istanbul ignore if - if (!branchExists && config.dependencyDashboardApproval) { - if (dependencyDashboardCheck) { - logger.debug(`Branch ${config.branchName} is approved for creation`); - } else { - logger.debug(`Branch ${config.branchName} needs approval`); + if (!branchExists) { + if (config.mode === 'silent' && !dependencyDashboardCheck) { + logger.debug( + `Branch ${config.branchName} creation is disabled because mode=silent`, + ); + return { + branchExists, + prNo: branchPr?.number, + result: 'needs-approval', + }; + } + if (config.dependencyDashboardApproval && !dependencyDashboardCheck) { + logger.debug( + `Branch ${config.branchName} creation is disabled because dependencyDashboardApproval=true`, + ); return { branchExists, prNo: branchPr?.number,