← Back to Cron DST Drift Check
A cron schedule that names a wall-clock time — 30 2 * * *,
"run at 02:30" — is only unambiguous in a timezone that never changes its
UTC offset. Most timezones people actually live in do change it, twice a
year, and a cron expression has no way to say so. That gap produces two
separate, genuinely different failure modes, and both are invisible from
reading the expression itself.
When clocks spring forward, the clock jumps directly from 01:59:59 to 03:00:00 — 02:00 through 02:59 never happen that day in that timezone. A job scheduled for 02:30 has nothing to run at. When clocks fall back, 01:00 through 01:59 happen twice — a job scheduled for 01:30 has two real, equally valid instants that both match. Neither case is a bug in the cron expression; both are genuine facts about the calendar that most scheduling tools never surface, because most cron parsers only ever reason about which fields match, never about whether the resulting wall-clock time is real.
Plenty of schedulers sidestep timezone ambiguity by not having a
timezone at all — they always run in UTC. GitHub Actions' own
schedule: cron is exactly this, by its own documentation. That
is not itself a bug; the bug is assuming a UTC-only schedule keeps hitting
the same local time year-round. It does not: 09:00
America/New_York is 14:00 UTC in January (EST, UTC-5) and
13:00 UTC in July (EDT, UTC-4). A workflow scheduled at a single fixed UTC
hour is exactly right for one DST segment of the year and an hour off for
the other — a defect nobody notices until someone asks "why did this run
an hour late" in March or November.
| Check | Source | Out of scope for v0 |
|---|---|---|
| Spring-forward skip / fall-back overlap | Resolving a cron occurrence's local wall-clock time against the real IANA timezone database (via Intl) and checking whether it names zero, one, or two real UTC instants | What your specific scheduler actually does with a skipped or ambiguous occurrence — that varies by implementation and is out of this tool's reach |
| Fixed-UTC-cron drift | Converting a target local time to UTC once per DST segment of a year and comparing the results | Timezones with sub-hour or unusual DST offsets are computed correctly (the underlying arithmetic is general), but the report's language assumes the common one-hour case |
| Cron field parsing | Standard 5-field cron (minute hour dom month dow), with the standard OR rule when both day-of-month and day-of-week are restricted; month and day-of-week also accept the standard 3-letter names (JAN-DEC, SUN-SAT), case-insensitive | Seconds fields and non-standard extensions (@daily, L, W) |
Check a new or changed schedule before it merges:
- name: Check the deploy cron for DST risk run: node ventures/cron-dst-drift-check/cli.mjs next "30 2 * * *" --tz America/New_York - name: Check the GitHub Actions schedule for UTC drift run: node ventures/cron-dst-drift-check/cli.mjs drift 09:00 --tz America/New_York
Both subcommands exit non-zero when they find DST risk, so a schedule that would skip, double-fire, or drift is caught in the same review that introduces it, rather than in an incident channel six months later.
A cron expression parser (crontab.guru, node-cron, croniter) tells you which minutes and hours a schedule matches. None of the popular ones surface whether a specific upcoming occurrence's local wall-clock time actually exists that day, or whether a fixed-UTC-only scheduler needs a different UTC time in summer than in winter to hit the same local hour — both are timezone-arithmetic questions the field-matching layer doesn't ask.
JAN or MON?Yes. Month and day-of-week accept the standard 3-letter names (JAN-DEC, SUN-SAT), case-insensitive, anywhere a number is allowed — singly ("MON"), in a list ("MON,WED,FRI"), or as a range ("JAN-MAR"). They are converted to their numeric value with a lookup table before the same numeric parser runs, so every existing rule — lists, ranges, steps, the dom/dow OR rule — applies identically either way. Minute, hour, and day-of-month stay numeric, matching every real cron implementation; a full name ("January") or a typo throws, naming the exact token it did not recognize, rather than guessing.
No, and this is a named limitation rather than a hidden one. Different cron daemons, systemd timers, and language schedulers genuinely disagree on spring-forward and fall-back behavior — some skip, some fire once, some fire twice. This tool computes the timezone fact that is true regardless of implementation (does the local time exist, not exist, or exist twice) and leaves the scheduler-specific behavior to that scheduler's own documentation.