diff --git a/lib/config/definitions.ts b/lib/config/definitions.ts index 1f9941c2c22a47f5d82f1d11c59e598171d6c8c6..79a0f256f9c7bdd9535604c55fbea07b837c5bc1 100644 --- a/lib/config/definitions.ts +++ b/lib/config/definitions.ts @@ -971,6 +971,13 @@ const options: RenovateOptions[] = [ type: 'boolean', default: false, }, + { + name: 'stabilityDays', + description: + 'Number of days required before a new release is considered to be stabilized.', + type: 'integer', + default: 0, + }, { name: 'prCreation', description: 'When to create the PR for a branch.', diff --git a/lib/workers/branch/index.js b/lib/workers/branch/index.js index cd382564b9788cfe421dea7340527614f498f7db..562c230a1189b287f2912b3b350d5e3df1cbe03c 100644 --- a/lib/workers/branch/index.js +++ b/lib/workers/branch/index.js @@ -7,7 +7,7 @@ const { getAdditionalFiles } = require('../../manager/npm/post-update'); const { commitFilesToBranch } = require('./commit'); const { getParentBranch } = require('./parent'); const { tryBranchAutomerge } = require('./automerge'); -const { setUnpublishable } = require('./status-checks'); +const { setStability, setUnpublishable } = require('./status-checks'); const { prAlreadyExisted } = require('./check-existing'); const prWorker = require('../pr'); const { appName, appSlug } = require('../../config/app-strings'); @@ -188,6 +188,48 @@ async function processBranch(branchConfig, prHourlyLimitReached, packageFiles) { ); return 'pending'; } + + if ( + config.upgrades.some( + upgrade => upgrade.stabilityDays && upgrade.releaseTimestamp + ) + ) { + // Only set a stability status check if one or more of the updates contain + // both a stabilityDays setting and a releaseTimestamp + config.stabilityStatus = 'success'; + // Default to 'success' but set 'pending' if any update is pending + const oneDay = 24 * 60 * 60 * 1000; + for (const upgrade of config.upgrades) { + if (upgrade.stabilityDays && upgrade.releaseTimestamp) { + const daysElapsed = Math.floor( + (new Date().getTime() - + new Date(upgrade.releaseTimestamp).getTime()) / + oneDay + ); + if (daysElapsed < upgrade.stabilityDays) { + logger.debug( + { + depName: upgrade.depName, + daysElapsed, + stabilityDays: upgrade.stabilityDays, + }, + 'Update has not passed stability days' + ); + config.stabilityStatus = 'pending'; + } + } + } + // Don't create a branch if we know it will be status 'pending' + if ( + !branchExists && + config.stabilityStatus === 'pending' && + ['not-pending', 'status-success'].includes(config.prCreation) + ) { + logger.info('Skipping branch creation due to stability days not met'); + return 'pending'; + } + } + // istanbul ignore if if (masterIssueCheck === 'rebase' || config.masterIssueRebaseAllOpen) { logger.info('Manual rebase requested via master issue'); @@ -269,6 +311,7 @@ async function processBranch(branchConfig, prHourlyLimitReached, packageFiles) { } // Set branch statuses + await setStability(config); await setUnpublishable(config); // Try to automerge branch and finish if successful, but only if branch already existed before this run diff --git a/lib/workers/branch/status-checks.js b/lib/workers/branch/status-checks.js index 061fdd28c18b2dd01b3db0897eb6dd0588a21594..f7069470aaa29736078a9944a1317686223ae915 100644 --- a/lib/workers/branch/status-checks.js +++ b/lib/workers/branch/status-checks.js @@ -2,6 +2,7 @@ const { logger } = require('../../logger'); const { appSlug, urls } = require('../../config/app-strings'); module.exports = { + setStability, setUnpublishable, }; @@ -25,6 +26,23 @@ async function setStatusCheck(branchName, context, description, state) { } } +async function setStability(config) { + if (!config.stabilityStatus) { + return; + } + const context = `${appSlug}/stability-days`; + const description = + config.stabilityStatus === 'success' + ? 'Updates have met stability days requirement' + : 'Updates have not met stability days requirement'; + await setStatusCheck( + config.branchName, + context, + description, + config.stabilityStatus + ); +} + async function setUnpublishable(config) { if (!config.unpublishSafe) { return; diff --git a/renovate-schema.json b/renovate-schema.json index ec947d2b18e49cc9b045dc5c2493b58dc8833055..1cebd4b269e3c8b1a1691fa7ee121e9cb313be99 100644 --- a/renovate-schema.json +++ b/renovate-schema.json @@ -592,6 +592,11 @@ "type": "boolean", "default": false }, + "stabilityDays": { + "description": "Number of days required before a new release is considered to be stabilized.", + "type": "integer", + "default": 0 + }, "prCreation": { "description": "When to create the PR for a branch.", "type": "string", diff --git a/test/workers/branch/index.spec.js b/test/workers/branch/index.spec.js index 5027e424c3f2ee8b7aa7ffe5b7bed3e0a51c1be6..30dd138bd33810ffa452cedeb8bd52b0931c45c6 100644 --- a/test/workers/branch/index.spec.js +++ b/test/workers/branch/index.spec.js @@ -75,6 +75,18 @@ describe('workers/branch', () => { const res = await branchWorker.processBranch(config); expect(res).toEqual('pending'); }); + it('skips branch if not stabilityDays not met', async () => { + schedule.isScheduledNow.mockReturnValueOnce(true); + config.prCreation = 'not-pending'; + config.upgrades = [ + { + releaseTimestamp: '2099-12-31', + stabilityDays: 1, + }, + ]; + const res = await branchWorker.processBranch(config); + expect(res).toEqual('pending'); + }); it('processes branch if not scheduled but updating out of schedule', async () => { schedule.isScheduledNow.mockReturnValueOnce(false); config.updateNotScheduled = true; diff --git a/test/workers/branch/status-checks.spec.js b/test/workers/branch/status-checks.spec.js index 17fec24c5fa5372bb5c2ae19f61e326ff3bbe245..2c69ac7159732c30df74d5cb1d00b2736c4b2621 100644 --- a/test/workers/branch/status-checks.spec.js +++ b/test/workers/branch/status-checks.spec.js @@ -1,4 +1,5 @@ const { + setStability, setUnpublishable, } = require('../../../lib/workers/branch/status-checks'); const defaultConfig = require('../../../lib/config/defaults').getConfig(); @@ -7,6 +8,33 @@ const defaultConfig = require('../../../lib/config/defaults').getConfig(); const platform = global.platform; describe('workers/branch/status-checks', () => { + describe('setStability', () => { + let config; + beforeEach(() => { + config = { + ...defaultConfig, + }; + }); + afterEach(() => { + jest.resetAllMocks(); + }); + it('returns if not configured', async () => { + await setStability(config); + expect(platform.getBranchStatusCheck).toHaveBeenCalledTimes(0); + }); + it('sets status pending', async () => { + config.stabilityStatus = 'pending'; + await setStability(config); + expect(platform.getBranchStatusCheck).toHaveBeenCalledTimes(1); + expect(platform.setBranchStatus).toHaveBeenCalledTimes(1); + }); + it('sets status success', async () => { + config.stabilityStatus = 'success'; + await setStability(config); + expect(platform.getBranchStatusCheck).toHaveBeenCalledTimes(1); + expect(platform.setBranchStatus).toHaveBeenCalledTimes(1); + }); + }); describe('setUnpublishable', () => { let config; beforeEach(() => { diff --git a/website/docs/configuration-options.md b/website/docs/configuration-options.md index 3e2d16bdf23eb9e05d47a49016c8032be8173528..b9b9c8d233b6022b1e9c1445f386cbd3e2632f11 100644 --- a/website/docs/configuration-options.md +++ b/website/docs/configuration-options.md @@ -1083,6 +1083,20 @@ By default, Renovate won't distinguish between "patch" (e.g. 1.0.x) and "minor" Set this to true if you wish to receive one PR for every separate major version upgrade of a dependency. e.g. if you are on webpack@v1 currently then default behaviour is a PR for upgrading to webpack@v3 and not for webpack@v2. If this setting is true then you would get one PR for webpack@v2 and one for webpack@v3. +## stabilityDays + +If this is set to a non-zero value, and an update has a release date/timestamp available, then Renovate will check if the configured "stability days" have elapsed. If the days since the release is less than the configured stability days then a "pending" status check will be added to the branch. If enough days have passed then a passing status check will be added. + +There are a couple of uses for this: + +#### Suppress branch/PR creation for X days + +If you combine `stabilityDays=3` and `prCreation="not-pending"` then Renovate will hold back from creating branches until 3 or more days have elapsed since the version was released. It's recommended that you enable `masterIssue=true` so you don't lose visibility of these pending PRs. + +#### Await X days before Automerging + +If you have both `automerge` as well as `stabilityDays` enabled, it means that PRs will be created immediately but automerging will be delayed until X days have passed. This works because Renovate will add a "renovate/stability-days" pending status check to each branch/PR and that pending check will prevent the branch going green to automerge. + ## statusCheckVerify This feature is added for people migrating from alternative services who are used to seeing a "verify" status check on PRs. If you'd like to use this then go ahead, but otherwise it's more secure to look for Renovate's [GPG Verified Commits](https://github.com/blog/2144-gpg-signature-verification) instead, because those cannot be spoofed by any other person or service (unlike status checks).