Skip to content
246 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

GitHub Actions on g1t, part two: running workflows1//! Five-field cron schedules, in UTC: minute, hour, day of month, month,
2//! day of week. Each field takes `*`, a number, a range `a-b`, a list
3//! `a,b`, and a step `*/n` or `a-b/n`; days of the week also take `mon` to
Sidebar: the panels really slide4//! `sun`, and months `jan` to `dec`. For workflows' `on.schedule`.
GitHub Actions on g1t, part two: running workflows5
6#[derive(Clone, Debug, PartialEq, Eq)]
7pub struct Schedule {
8 minutes: Vec<bool>,
9 hours: Vec<bool>,
10 days: Vec<bool>,
11 months: Vec<bool>,
12 weekdays: Vec<bool>,
13 /// Whether day of month and day of week were each restricted: when both
14 /// are, either matching is enough, as in every cron.
15 days_restricted: bool,
16 weekdays_restricted: bool,
17}
18
19const WEEKDAYS: [&str; 7] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
20/// Months by name, from 1: the empty first entry stands for 0.
21const MONTHS: [&str; 13] = ["", "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
22
23fn field(text: &str, low: u32, high: u32, names: &[&str]) -> Result<(Vec<bool>, bool), String> {
24 let mut set = vec![false; (high + 1) as usize];
25 let value = |part: &str| -> Result<u32, String> {
26 if let Some(at) = names.iter().position(|name| !name.is_empty() && part.eq_ignore_ascii_case(name)) {
27 return Ok(at as u32);
28 }
29 part.parse::<u32>().map_err(|_| format!("`{part}` is not a number"))
30 };
31 for item in text.split(',') {
32 let (range, step) = match item.split_once('/') {
33 Some((range, step)) => (range, step.parse::<u32>().map_err(|_| format!("`{step}` is not a step"))?),
34 None => (item, 1),
35 };
36 if step == 0 {
37 return Err("a step cannot be 0".to_owned());
38 }
39 let (from, to) = if range == "*" {
40 (low, high)
41 } else if let Some((a, b)) = range.split_once('-') {
42 (value(a)?, value(b)?)
43 } else {
44 let at = value(range)?;
45 (at, if item.contains('/') { high } else { at })
46 };
47 // Sunday may be written 7.
48 let (from, to) = if names.len() == 7 && to == 7 { (from.min(6), 6) } else { (from, to) };
49 if from < low || to > high || from > to {
50 return Err(format!("`{item}` is outside {low}-{high}"));
51 }
52 let mut at = from;
53 while at <= to {
54 set[at as usize] = true;
55 at += step;
56 }
57 if names.len() == 7 && text.split(',').any(|part| part == "7") {
58 set[0] = true;
59 }
60 }
61 Ok((set, text != "*"))
62}
63
64impl Schedule {
65 pub fn parse(text: &str) -> Result<Schedule, String> {
66 let parts: Vec<&str> = text.split_whitespace().collect();
67 let [minute, hour, day, month, weekday] = parts[..] else {
68 return Err("a schedule has five fields: minute hour day month weekday, such as `0 9 * * mon`".to_owned());
69 };
70 let (minutes, _) = field(minute, 0, 59, &[])?;
71 let (hours, _) = field(hour, 0, 23, &[])?;
72 let (days, days_restricted) = field(day, 1, 31, &[])?;
73 let (months, _) = field(month, 1, 12, &MONTHS)?;
74 let (weekdays, weekdays_restricted) = field(weekday, 0, 6, &WEEKDAYS)?;
75 Ok(Schedule {
76 minutes,
77 hours,
78 days,
79 months,
80 weekdays,
81 days_restricted,
82 weekdays_restricted,
83 })
84 }
85
86 /// Whether it fires in the minute starting at `ms` since the epoch, UTC.
87 pub fn fires_at(&self, ms: u64) -> bool {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails88 self.minutes[(ms / 60_000 % 60) as usize] && self.hour_and_date_at(ms)
89 }
90
91 /// Whether the minute starting at `ms` is in its hours, days and
92 /// months, whatever its minute field says.
93 fn hour_and_date_at(&self, ms: u64) -> bool {
GitHub Actions on g1t, part two: running workflows94 let minutes_total = ms / 60_000;
95 let hour = (minutes_total / 60 % 24) as usize;
96 let days_since_epoch = (minutes_total / 60 / 24) as i64;
97 // 1970-01-01 was a Thursday.
98 let weekday = ((days_since_epoch + 4) % 7) as usize;
99 let (_, month, day) = civil_from_days(days_since_epoch);
100 let day_ok = self.days[day as usize];
101 let weekday_ok = self.weekdays[weekday];
102 let date_ok = match (self.days_restricted, self.weekdays_restricted) {
103 (true, true) => day_ok || weekday_ok,
104 _ => day_ok && weekday_ok,
105 };
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails106 self.hours[hour] && self.months[month as usize] && date_ok
GitHub Actions on g1t, part two: running workflows107 }
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails108
109 /// Whether its minutes come closer together than
110 /// [`MIN_INTERVAL_MINUTES`], counting round the hour.
111 pub fn too_frequent(&self) -> bool {
112 let set: Vec<usize> = (0..60).filter(|&m| self.minutes[m]).collect();
113 let Some(&first) = set.first() else { return false };
114 let wrap = first + 60 - set[set.len() - 1];
115 set.windows(2).map(|pair| pair[1] - pair[0]).chain([wrap]).any(|gap| gap < MIN_INTERVAL_MINUTES as usize)
116 }
117
118 /// Whether a workflow on this schedule runs in the minute starting at
119 /// `ms`. A schedule no more frequent than every
120 /// [`MIN_INTERVAL_MINUTES`] runs when it fires. A more frequent one
121 /// runs on the five-minute marks: at a mark that is itself in its
122 /// hours, days and months, when it fired in the five minutes the mark
123 /// ends. At most every five minutes, and never outside its own hours.
124 pub fn runs_at(&self, ms: u64) -> bool {
125 if !self.too_frequent() {
126 return self.fires_at(ms);
127 }
128 let minute = ms / 60_000;
129 minute % MIN_INTERVAL_MINUTES == 0
130 && self.hour_and_date_at(ms)
131 && (0..MIN_INTERVAL_MINUTES).any(|back| minute >= back && self.fires_at((minute - back) * 60_000))
132 }
GitHub Actions on g1t, part two: running workflows133}
134
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails135/// The shortest interval a workflow's schedule runs at, in minutes.
136pub const MIN_INTERVAL_MINUTES: u64 = 5;
137
GitHub Actions on g1t, part two: running workflows138/// The date of a day counted from 1970-01-01 (Howard Hinnant's algorithm).
139fn civil_from_days(days: i64) -> (i64, u32, u32) {
140 let z = days + 719_468;
141 let era = z.div_euclid(146_097);
142 let doe = z.rem_euclid(146_097);
143 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
144 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
145 let mp = (5 * doy + 2) / 153;
146 let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
147 let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
148 (yoe + era * 400 + i64::from(month <= 2), month, day)
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 /// Milliseconds at a UTC date and time.
156 fn at(days_since_epoch: u64, hour: u64, minute: u64) -> u64 {
157 ((days_since_epoch * 24 + hour) * 60 + minute) * 60_000
158 }
159
160 // 2026-10-05 is a Monday: 20_731 days after 1970-01-01.
161 const MONDAY: u64 = 20_731;
162
163 #[test]
164 fn dates_are_worked_out() {
165 assert_eq!(civil_from_days(0), (1970, 1, 1));
166 assert_eq!(civil_from_days(MONDAY as i64), (2026, 10, 5));
167 }
168
169 #[test]
170 fn mondays_at_nine() {
171 let schedule = Schedule::parse("0 9 * * mon").unwrap();
172 assert!(schedule.fires_at(at(MONDAY, 9, 0)));
173 assert!(!schedule.fires_at(at(MONDAY, 9, 1)));
174 assert!(!schedule.fires_at(at(MONDAY + 1, 9, 0)));
175 assert!(Schedule::parse("0 9 * * 1").unwrap().fires_at(at(MONDAY, 9, 0)));
176 }
177
178 #[test]
179 fn steps_ranges_and_lists() {
180 let every_quarter = Schedule::parse("*/15 * * * *").unwrap();
181 assert!(every_quarter.fires_at(at(MONDAY, 3, 45)));
182 assert!(!every_quarter.fires_at(at(MONDAY, 3, 44)));
183 let weekdays = Schedule::parse("30 8-17/3 * * mon-fri").unwrap();
184 assert!(weekdays.fires_at(at(MONDAY, 14, 30)));
185 assert!(!weekdays.fires_at(at(MONDAY, 15, 30)));
186 assert!(!weekdays.fires_at(at(MONDAY + 5, 14, 30)));
187 let sunday = Schedule::parse("0 0 * * 7").unwrap();
188 assert!(sunday.fires_at(at(MONDAY + 6, 0, 0)));
189 // MONDAY is in October.
190 assert!(Schedule::parse("0 9 * oct mon").unwrap().fires_at(at(MONDAY, 9, 0)));
191 assert!(!Schedule::parse("0 9 * jan-sep *").unwrap().fires_at(at(MONDAY, 9, 0)));
192 assert!(Schedule::parse("0 9 * * *").unwrap().fires_at(at(MONDAY, 9, 0)));
193 }
194
195 #[test]
196 fn day_of_month_or_week_when_both_are_given() {
197 // The 1st, or any Monday.
198 let schedule = Schedule::parse("0 0 1 * mon").unwrap();
199 assert!(schedule.fires_at(at(MONDAY, 0, 0)));
200 assert!(!schedule.fires_at(at(MONDAY + 1, 0, 0)));
201 }
202
203 #[test]
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails204 fn schedules_run_at_most_every_five_minutes() {
205 let runs = |text: &str| -> Vec<u64> {
206 let schedule = Schedule::parse(text).unwrap();
207 (0..30).filter(|&m| schedule.runs_at(at(MONDAY, 3, m))).collect()
208 };
209 assert_eq!(runs("* * * * *"), [0, 5, 10, 15, 20, 25]);
210 assert_eq!(runs("*/2 * * * *"), [0, 5, 10, 15, 20, 25]);
211 // Every five minutes or less often: exactly when it fires.
212 assert_eq!(runs("*/5 * * * *"), [0, 5, 10, 15, 20, 25]);
213 assert_eq!(runs("7,17 * * * *"), [7, 17]);
214 assert_eq!(runs("*/10 * * * *"), [0, 10, 20]);
215 // Two minutes close together: the later one waits for the mark.
216 assert_eq!(runs("0,3 * * * *"), [0, 5]);
217 // Close across the hour counts too.
218 assert!(Schedule::parse("2,58 * * * *").unwrap().too_frequent());
219 assert!(!Schedule::parse("0 9 * * mon").unwrap().too_frequent());
220 // Every minute of one hour: its own marks, 09:00 to 09:55, and
221 // nothing after.
222 let nine = Schedule::parse("* 9 * * *").unwrap();
223 let day: Vec<(u64, u64)> =
224 (0..24 * 60).filter(|&m| nine.runs_at(at(MONDAY, m / 60, m % 60))).map(|m| (m / 60, m % 60)).collect();
225 assert_eq!(day, (0..12).map(|i| (9, i * 5)).collect::<Vec<_>>());
226 assert!(!nine.runs_at(at(MONDAY, 10, 0)));
227 // Every minute of Mondays: the last run is 23:55, none on Tuesday.
228 let mondays = Schedule::parse("*/1 * * * 1").unwrap();
229 assert!(mondays.runs_at(at(MONDAY, 0, 0)));
230 assert!(mondays.runs_at(at(MONDAY, 23, 55)));
231 assert!(!mondays.runs_at(at(MONDAY + 1, 0, 0)));
232 assert!(!(0..24 * 60).any(|m| mondays.runs_at(at(MONDAY + 1, m / 60, m % 60))));
233 // Close minutes: one run for each window they fall in, at its mark.
234 let close = Schedule::parse("0,3 * * * *").unwrap();
235 let hour: Vec<u64> = (0..60).filter(|&m| close.runs_at(at(MONDAY, 3, m))).collect();
236 assert_eq!(hour, [0, 5]);
237 assert!(close.runs_at(at(MONDAY, 4, 0)));
238 }
239
240 #[test]
GitHub Actions on g1t, part two: running workflows241 fn nonsense_is_refused() {
242 for text in ["", "* * * *", "61 * * * *", "* * * * funday", "*/0 * * * *", "5-1 * * * *"] {
243 assert!(Schedule::parse(text).is_err(), "{text}");
244 }
245 }
246}

This file's history is long; its oldest lines are credited to the oldest commit read.