2023-01-23 14:07:32 -08:00
|
|
|
import cronstrue from 'cronstrue';
|
2023-01-09 17:51:44 -08:00
|
|
|
|
2023-01-23 14:07:32 -08:00
|
|
|
import {
|
|
|
|
|
MONTH_RANGE,
|
|
|
|
|
HOUR_RANGE,
|
|
|
|
|
MINUTE_RANGE,
|
|
|
|
|
DOM_RANGE,
|
|
|
|
|
DOW_RANGE
|
|
|
|
|
} from './cron-constants'
|
2023-01-09 17:51:44 -08:00
|
|
|
|
|
|
|
|
type Range = {
|
|
|
|
|
min: number
|
|
|
|
|
max: number
|
|
|
|
|
names?: Record<string, number>
|
|
|
|
|
}
|
|
|
|
|
|
2023-01-23 14:07:32 -08:00
|
|
|
type Schedule = {
|
|
|
|
|
minutes?: number[]
|
|
|
|
|
hours?: number[]
|
|
|
|
|
dom?: number[]
|
|
|
|
|
months?: number[]
|
|
|
|
|
dow?: number[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function isValidCron(cron: string): boolean {
|
|
|
|
|
// https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule
|
|
|
|
|
|
|
|
|
|
const parts = cron.split(/ +/)
|
|
|
|
|
if (parts.length != 5) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
const [minutes, hours, dom, months, dow] = parts
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
validateCronPart(minutes, MINUTE_RANGE) &&
|
|
|
|
|
validateCronPart(hours, HOUR_RANGE) &&
|
|
|
|
|
validateCronPart(dom, DOM_RANGE) &&
|
|
|
|
|
validateCronPart(months, MONTH_RANGE) &&
|
|
|
|
|
validateCronPart(dow, DOW_RANGE)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2023-01-23 17:50:54 -08:00
|
|
|
export function getCronDescription(cronspec: string): string | undefined {
|
2023-01-23 14:07:32 -08:00
|
|
|
const schedule = getSchedule(cronspec)
|
|
|
|
|
if (!schedule) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let desc = ''
|
|
|
|
|
try {
|
|
|
|
|
desc = cronstrue.toString(cronspec, {
|
|
|
|
|
dayOfWeekStartIndexZero: true,
|
|
|
|
|
monthStartIndexZero: false,
|
|
|
|
|
use24HourTimeFormat: true,
|
|
|
|
|
// cronstrue sets the description as the error if throwExceptionOnParseError is false
|
|
|
|
|
// so we need to distinguish between an error and a valid description
|
|
|
|
|
throwExceptionOnParseError: true,
|
|
|
|
|
})
|
|
|
|
|
} catch (err) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2023-01-23 15:12:16 -08:00
|
|
|
// Make first character lowercase
|
2023-01-23 17:50:54 -08:00
|
|
|
let result = "Runs " + desc.charAt(0).toLowerCase() + desc.slice(1)
|
|
|
|
|
result += "\n\nActions schedules run at most every 5 minutes." +
|
|
|
|
|
" [Learn more](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule)";
|
|
|
|
|
return result
|
2023-01-23 14:07:32 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseCronPart(part: string, range: Range): number[] | undefined {
|
|
|
|
|
const values: number[] = []
|
|
|
|
|
|
|
|
|
|
if (part === "*") {
|
|
|
|
|
return undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (part.includes(",")) {
|
|
|
|
|
part.split(",").forEach((v) => {
|
|
|
|
|
const value = parseCronPart(v, range)
|
|
|
|
|
if (value) {
|
|
|
|
|
values.push(...value)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (part.includes("/")) {
|
|
|
|
|
const [stepRange, step] = part.split("/")
|
|
|
|
|
const stepNumber = +step
|
|
|
|
|
let startNumber, endNumber
|
|
|
|
|
if (stepRange.includes("-")) {
|
|
|
|
|
const [start, end] = stepRange.split("-")
|
|
|
|
|
startNumber = convertToNumber(start, range.names)
|
|
|
|
|
endNumber = convertToNumber(end, range.names)
|
|
|
|
|
}
|
|
|
|
|
else {
|
|
|
|
|
if (stepRange === "*") {
|
|
|
|
|
startNumber = range.min
|
|
|
|
|
}
|
|
|
|
|
else {
|
|
|
|
|
startNumber = convertToNumber(stepRange, range.names)
|
|
|
|
|
}
|
|
|
|
|
endNumber = range.max
|
|
|
|
|
}
|
|
|
|
|
for (let i = startNumber; i <= endNumber; i += stepNumber) {
|
|
|
|
|
values.push(i)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (part.includes("-")) {
|
|
|
|
|
const [start, end] = part.split("-")
|
|
|
|
|
const startNumber = convertToNumber(start, range.names)
|
|
|
|
|
const endNumber = convertToNumber(end, range.names)
|
|
|
|
|
for (let i = startNumber; i <= endNumber; i++) {
|
|
|
|
|
values.push(i)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const number = convertToNumber(part, range.names)
|
|
|
|
|
if (!isNaN(number)) {
|
|
|
|
|
values.push(number)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return values.sort((a, b) => a - b)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateCronPart(
|
2023-01-09 17:51:44 -08:00
|
|
|
value: string,
|
|
|
|
|
range: Range,
|
|
|
|
|
allowSeparators = true
|
|
|
|
|
): boolean {
|
|
|
|
|
if (range.names && range.names[value.toLowerCase()] !== undefined) {
|
|
|
|
|
return true
|
2023-01-09 16:52:53 -08:00
|
|
|
}
|
2023-01-09 17:51:44 -08:00
|
|
|
|
|
|
|
|
if (value === "*") {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2023-01-10 09:57:07 -08:00
|
|
|
// Operator precedence: , > / > -
|
2023-01-09 17:51:44 -08:00
|
|
|
if (value.includes(",")) {
|
|
|
|
|
if (!allowSeparators) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
2023-01-23 14:07:32 -08:00
|
|
|
return value.split(",").every((v) => v && validateCronPart(v, range))
|
2023-01-09 17:51:44 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (value.includes("/")) {
|
|
|
|
|
if (!allowSeparators) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const [start, step, ...rest] = value.split("/")
|
2023-01-10 11:54:41 -08:00
|
|
|
const stepNumber = +step
|
|
|
|
|
if (rest.length > 0 || isNaN(stepNumber) || stepNumber <= 0 || !start || !step) {
|
2023-01-09 16:52:53 -08:00
|
|
|
return false
|
|
|
|
|
}
|
2023-01-10 10:22:25 -08:00
|
|
|
|
|
|
|
|
// Separators are only allowed in the part before the `/`, e.g. `1-5/2`
|
2023-01-09 16:52:53 -08:00
|
|
|
return (
|
2023-01-23 14:07:32 -08:00
|
|
|
validateCronPart(start, range) && validateCronPart(step, range, false)
|
2023-01-09 16:52:53 -08:00
|
|
|
)
|
|
|
|
|
}
|
2023-01-09 17:51:44 -08:00
|
|
|
|
|
|
|
|
if (value.includes("-")) {
|
2023-01-10 09:57:07 -08:00
|
|
|
if (!allowSeparators) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
2023-01-23 14:07:32 -08:00
|
|
|
|
2023-01-10 09:57:07 -08:00
|
|
|
const [start, end, ...rest] = value.split("-")
|
|
|
|
|
if (rest.length > 0 || !start || !end) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
2023-01-09 17:51:44 -08:00
|
|
|
|
|
|
|
|
// Convert name to integers so we can make sure end >= start
|
2023-01-23 14:07:32 -08:00
|
|
|
const startNumber = convertToNumber(start, range.names)
|
|
|
|
|
const endNumber = convertToNumber(end, range.names)
|
2023-01-09 17:51:44 -08:00
|
|
|
return (
|
2023-01-23 14:07:32 -08:00
|
|
|
validateCronPart(start, range, false) && validateCronPart(end, range, false) && endNumber >= startNumber
|
2023-01-09 17:51:44 -08:00
|
|
|
)
|
2023-01-09 16:52:53 -08:00
|
|
|
}
|
2023-01-09 17:51:44 -08:00
|
|
|
|
|
|
|
|
const number = +value
|
|
|
|
|
return !isNaN(number) && number >= range.min && number <= range.max
|
|
|
|
|
}
|
|
|
|
|
|
2023-01-23 14:07:32 -08:00
|
|
|
export function getSchedule(cron: string): Schedule | undefined {
|
|
|
|
|
if (!isValidCron(cron)) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const [minutes, hours, dom, months, dow] = cron.split(/ +/)
|
|
|
|
|
return {
|
|
|
|
|
minutes: parseCronPart(minutes, MINUTE_RANGE),
|
|
|
|
|
hours: parseCronPart(hours, HOUR_RANGE),
|
|
|
|
|
dom: parseCronPart(dom, DOM_RANGE),
|
|
|
|
|
months: parseCronPart(months, MONTH_RANGE),
|
|
|
|
|
dow: parseCronPart(dow, DOW_RANGE)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Converts a string integer or a short name to a number
|
|
|
|
|
function convertToNumber(value: string, names?: Record<string, number>): number {
|
|
|
|
|
if (names && names[value.toLowerCase()] !== undefined) {
|
|
|
|
|
return +names[value.toLowerCase()]
|
2023-01-09 17:51:44 -08:00
|
|
|
}
|
|
|
|
|
else {
|
|
|
|
|
return +value
|
|
|
|
|
}
|
|
|
|
|
}
|