diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md index f780c472f7fef8691a5fc8a965b99c86cccdc69c..68b14902227d933af957f3a295528e19473c8e13 100644 --- a/docs/usage/configuration-options.md +++ b/docs/usage/configuration-options.md @@ -2375,7 +2375,9 @@ You can configure the `rollbackPrs` property globally, per-lanuage, or per-packa ## schedule The `schedule` option allows you to define times of week or month for Renovate updates. -Running Renovate around the clock may seem too "noisy" for some projects and therefore `schedule` is a good way to reduce the noise by reducing the timeframe in which Renovate will operate on your repository. +Running Renovate around the clock can be too "noisy" for some projects. +To reduce the noise you can use the `schedule` config option to limit the time frame in which Renovate will perform actions on your repository. +You can use the standard [Cron syntax](https://crontab.guru/crontab.5.html) and [Later syntax](https://github.com/breejs/later) to define your schedule. The default value for `schedule` is "at any time", which is functionally the same as declaring a `null` schedule. i.e. Renovate will run on the repository around the clock. @@ -2392,8 +2394,11 @@ after 10pm and before 5:00am after 10pm and before 5am every weekday on friday and saturday every 3 months on the first day of the month +* 0 2 * * ``` +Note: For Cron schedules, you _must_ use the `*` wildcard for the minutes value, as Renovate doesn't support minute granularity. + One example might be that you don't want Renovate to run during your typical business hours, so that your build machines don't get clogged up testing `package.json` updates. You could then configure a schedule like this at the repository level: @@ -2422,6 +2427,7 @@ To restrict `aws-sdk` to only monthly updates, you could add this package rule: Technical details: We mostly rely on the text parsing of the library [@breejs/later](https://github.com/breejs/later) but only its concepts of "days", "time_before", and "time_after". Read the parser documentation at [breejs.github.io/later/parsers.html#text](https://breejs.github.io/later/parsers.html#text). +To parse Cron syntax, Renovate uses [@cheap-glitch/mi-cron](https://github.com/cheap-glitch/mi-cron). Renovate does not support scheduled minutes or "at an exact time" granularity. ## semanticCommitScope diff --git a/lib/config/validation.spec.ts b/lib/config/validation.spec.ts index ea0566e943ac39844ba6b3866947604ad2a9c19c..1f31f88bd5d5005468a3915d2ccd608c0dead2a5 100644 --- a/lib/config/validation.spec.ts +++ b/lib/config/validation.spec.ts @@ -699,5 +699,22 @@ describe('config/validation', () => { }, ]); }); + + it('errors if schedule is cron and has no * minutes', async () => { + const config = { + schedule: ['30 5 * * *'], + }; + const { warnings, errors } = await configValidation.validateConfig( + config + ); + expect(warnings).toHaveLength(0); + expect(errors).toMatchObject([ + { + message: + 'Invalid schedule: `Invalid schedule: "30 5 * * *" has cron syntax, but doesn\'t have * as minutes`', + topic: 'Configuration Error', + }, + ]); + }); }); }); diff --git a/lib/workers/branch/schedule.spec.ts b/lib/workers/branch/schedule.spec.ts index bfe2a4f9c77e325c68601df837c690fe364524fb..605a1ccf6b4b6eb291672ea45b6bb3142f11118c 100644 --- a/lib/workers/branch/schedule.spec.ts +++ b/lib/workers/branch/schedule.spec.ts @@ -81,6 +81,11 @@ describe('workers/branch/schedule', () => { ])[0] ).toBeTrue(); }); + + it('returns true if schedule uses cron syntax', () => { + expect(schedule.hasValidSchedule(['* 5 * * *'])[0]).toBeTrue(); + }); + it('massages schedules', () => { expect( schedule.hasValidSchedule([ @@ -150,11 +155,53 @@ describe('workers/branch/schedule', () => { const res = schedule.isScheduledNow(config); expect(res).toBeFalse(); }); + it('supports outside hours', () => { config.schedule = ['after 4:00pm']; const res = schedule.isScheduledNow(config); expect(res).toBeFalse(); }); + + it('supports cron syntax with hours', () => { + config.schedule = ['* 10 * * *']; + let res = schedule.isScheduledNow(config); + expect(res).toBeTrue(); + + config.schedule = ['* 11 * * *']; + res = schedule.isScheduledNow(config); + expect(res).toBeFalse(); + }); + + it('supports cron syntax with days', () => { + config.schedule = ['* * 30 * *']; + let res = schedule.isScheduledNow(config); + expect(res).toBeTrue(); + + config.schedule = ['* * 1 * *']; + res = schedule.isScheduledNow(config); + expect(res).toBeFalse(); + }); + + it('supports cron syntax with months', () => { + config.schedule = ['* * * 6 *']; + let res = schedule.isScheduledNow(config); + expect(res).toBeTrue(); + + config.schedule = ['* * * 7 *']; + res = schedule.isScheduledNow(config); + expect(res).toBeFalse(); + }); + + it('supports cron syntax with weekdays', () => { + config.schedule = ['* * * * 5']; + let res = schedule.isScheduledNow(config); + expect(res).toBeTrue(); + + config.schedule = ['* * * * 6']; + res = schedule.isScheduledNow(config); + expect(res).toBeFalse(); + }); + describe('supports timezone', () => { const cases: [string, string, string, boolean][] = [ ['after 4pm', 'Asia/Singapore', '2017-06-30T15:59:00.000+0800', false], diff --git a/lib/workers/branch/schedule.ts b/lib/workers/branch/schedule.ts index a84d7ed839ae551d1b618f9d55a3532bb2e3ee8b..8dd9efc6abdc2d888a36ae2eb3872007de6a32fb 100644 --- a/lib/workers/branch/schedule.ts +++ b/lib/workers/branch/schedule.ts @@ -1,10 +1,13 @@ import later from '@breejs/later'; +import { parseCron } from '@cheap-glitch/mi-cron'; import is from '@sindresorhus/is'; import { DateTime } from 'luxon'; import { fixShortHours } from '../../config/migration'; import type { RenovateConfig } from '../../config/types'; import { logger } from '../../logger'; +const minutesChar = '*'; + const scheduleMappings: Record<string, string> = { 'every month': 'before 3am on the first day of the month', monthly: 'before 3am on the first day of the month', @@ -32,9 +35,24 @@ export function hasValidSchedule( } // check if any of the schedules fail to parse const hasFailedSchedules = schedule.some((scheduleText) => { + const parsedCron = parseCron(scheduleText); + if (parsedCron !== undefined) { + if ( + parsedCron.minutes.length !== 60 || + scheduleText.indexOf(minutesChar) !== 0 + ) { + message = `Invalid schedule: "${scheduleText}" has cron syntax, but doesn't have * as minutes`; + return true; + } + + // It was valid cron syntax and * as minutes + return false; + } + const massagedText = fixShortHours( scheduleMappings[scheduleText] || scheduleText ); + const parsedSchedule = later.parse.text(massagedText); if (parsedSchedule.error !== -1) { message = `Invalid schedule: Failed to parse "${scheduleText}"`; @@ -63,6 +81,33 @@ export function hasValidSchedule( return [true, '']; } +function cronMatches(cron: string, now: DateTime): boolean { + const parsedCron = parseCron(cron); + + if (parsedCron.hours.indexOf(now.hour) === -1) { + // Hours mismatch + return false; + } + + if (parsedCron.days.indexOf(now.day) === -1) { + // Days mismatch + return false; + } + + if (parsedCron.weekDays.indexOf(now.weekday) === -1) { + // Weekdays mismatch + return false; + } + + if (parsedCron.months.indexOf(now.month) === -1) { + // Months mismatch + return false; + } + + // Match + return true; +} + export function isScheduledNow(config: RenovateConfig): boolean { let configSchedule = config.schedule; logger.debug( @@ -119,13 +164,23 @@ export function isScheduledNow(config: RenovateConfig): boolean { // We run if any schedule matches const isWithinSchedule = configSchedule.some((scheduleText) => { - const massagedText = scheduleMappings[scheduleText] || scheduleText; - const parsedSchedule = later.parse.text(fixShortHours(massagedText)); - logger.debug({ parsedSchedule }, `Checking schedule "${scheduleText}"`); + const cronSchedule = parseCron(scheduleText); + if (cronSchedule) { + // We have Cron syntax + if (cronMatches(scheduleText, now)) { + logger.debug(`Matches schedule ${scheduleText}`); + return true; + } + } else { + // We have Later syntax + const massagedText = scheduleMappings[scheduleText] || scheduleText; + const parsedSchedule = later.parse.text(fixShortHours(massagedText)); + logger.debug({ parsedSchedule }, `Checking schedule "${scheduleText}"`); - if (later.schedule(parsedSchedule).isValid(jsNow)) { - logger.debug(`Matches schedule ${scheduleText}`); - return true; + if (later.schedule(parsedSchedule).isValid(jsNow)) { + logger.debug(`Matches schedule ${scheduleText}`); + return true; + } } return false; diff --git a/package.json b/package.json index 995624821cc0273390c2f3df559aa9b996f176ff..48662da32156d5538a4d0e0afee24dda4824bb64 100644 --- a/package.json +++ b/package.json @@ -214,6 +214,7 @@ "@jest/globals": "27.4.6", "@jest/reporters": "27.4.6", "@jest/test-result": "27.4.6", + "@cheap-glitch/mi-cron": "1.0.1", "@ls-lint/ls-lint": "1.10.0", "@renovate/eslint-plugin": "https://github.com/renovatebot/eslint-plugin#v0.0.4", "@semantic-release/exec": "6.0.3", diff --git a/yarn.lock b/yarn.lock index ceffb3be8a3d6a613e5c0124d68f017509999cb3..b258a8da1c79a79a403a3d272938576beb958de5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1241,6 +1241,11 @@ resolved "https://registry.yarnpkg.com/@breejs/later/-/later-4.1.0.tgz#9246907f46cc9e9c9af37d791ab468d98921bcc1" integrity sha512-QgGnZ9b7o4k0Ai1ZbTJWwZpZcFK9d+Gb+DyNt4UT9x6IEIs5HVu0iIlmgzGqN+t9MoJSpSPo9S/Mm51UtHr3JA== +"@cheap-glitch/mi-cron@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@cheap-glitch/mi-cron/-/mi-cron-1.0.1.tgz#111f4ce746c269aedf74533ac806881763a68f99" + integrity sha512-kxl7vhg+SUgyHRn22qVbR9MfSm5CzdlYZDJTbGemqFFi/Jmno/hdoQIvBIPoqFY9dcPyxzOUNRRFn6x88UQMpw== + "@chevrotain/types@^9.1.0": version "9.1.0" resolved "https://registry.yarnpkg.com/@chevrotain/types/-/types-9.1.0.tgz#689f2952be5ad9459dae3c8e9209c0f4ec3c5ec4"