research/dst-safe-weekday-cron-in-github-actions

A DST-Safe Weekday Cron in GitHub Actions: Two Schedules and a Guard

Engineering Note · November 3, 2026 · 5 minute read · GitHub Actions · Cron · Scheduling

A GitHub Actions workflow that must run at 9:45 AM New York time needs two cron lines, not one, because cron there runs in UTC and New York moves between UTC-4 and UTC-5. Put one line at 13:45 UTC and one at 14:45 UTC, then add a first step that checks the New York clock and exits unless it reads 09:45. We simulated that pairing across all 522 weekdays of 2026 and 2027. Every weekday had exactly one passing run.

Everything below was run with Node.js 22.23.4. We did not run the workflow on GitHub for this note, so the tests cover the schedule logic only.

Why can’t we write one cron line?

A cron expression like 45 13 * * 1-5 means 13:45 UTC on weekdays. That is 9:45 AM in New York from March to early November, and 8:45 AM from November to March. For about four months of the year the job fires an hour early, and for the other eight it is right. Switch the line to 14:45 and the error moves to the other season.

There is no timezone: key for a schedule trigger. The two-line approach is the usual way around that.

What does the workflow look like?

on:
  schedule:
    - cron: '45 13 * * 1-5'   # 9:45 AM EDT
    - cron: '45 14 * * 1-5'   # 9:45 AM EST

jobs:
  rebuild:
    runs-on: ubuntu-latest
    steps:
      - name: Only continue at 9:45 AM in New York
        id: guard
        run: |
          now=$(TZ=America/New_York date +%H:%M)
          echo "New York time is $now"
          if [ "$now" = "09:45" ]; then echo "go=true" >> "$GITHUB_OUTPUT"; fi
      - name: Do the work
        if: steps.guard.outputs.go == 'true'
        run: echo "rebuild here"

Each weekday both schedules wake the workflow. One of them sees 09:45 and works. The other sees 08:45 or 10:45 and stops after one cheap step.

A real run is rarely at the exact minute. GitHub can start scheduled jobs late. If you want slack, check the hour instead of the minute, for example [ "${now%%:*}" = "09" ]. The trade-off is that a delayed run could also pass on the other schedule, so the guard needs to be strict about which schedule is allowed to do the work.

Does it really fire once per weekday?

We simulated the two schedules for every weekday from January 1, 2026 through December 31, 2027. For each weekday we built the two UTC instants, formatted each in America/New_York, and counted how many read 09:45:

const ny = new Intl.DateTimeFormat('en-US', {
  timeZone: 'America/New_York', hour: '2-digit', minute: '2-digit', hourCycle: 'h23'
});
const guard = (d) => ny.format(d) === '09:45';
// for each weekday: fired = ['13:45','14:45'].filter(h => guard(new Date(`${ymd}T${h}:00Z`)))

Output:

{ weekdays: 522, passes: 522, daysWithNone: 0, daysWithTwo: 0 }

Five hundred twenty-two weekdays, 522 passing runs. No day had none and no day had two.

Does the shell guard agree with the JavaScript simulation?

The simulation used Intl.DateTimeFormat. The workflow itself uses the date command, so we checked that one too. Using GNU coreutils 9.4 date and GNU bash 5.2.21, we fed it the four UTC instants that matter, two in July and two in November:

for t in 2026-07-15T13:45:00Z 2026-07-15T14:45:00Z 2026-11-03T13:45:00Z 2026-11-03T14:45:00Z; do
  echo "$t -> $(TZ=America/New_York date -d $t +%H:%M)"
done

Output:

2026-07-15T13:45:00Z -> 09:45
2026-07-15T14:45:00Z -> 10:45
2026-11-03T13:45:00Z -> 08:45
2026-11-03T14:45:00Z -> 09:45

In July only the 13:45 UTC schedule reads 09:45. In November only the 14:45 UTC schedule does. The other schedule on each date reads 10:45 or 08:45 and the guard stops it. Two independent tools, the same answer. We still have not run the workflow on GitHub’s runners, so the runner’s own date build is the one untested piece.

What should the unused run look like?

Expect to see a green but nearly empty run in the Actions list every weekday. That is the guard doing its job, and it costs a few seconds of runner time. If the empty runs bother you, name the guard step clearly, as in the example above, so anyone opening the run sees “New York time is 08:45” and understands why nothing else happened. Do not make the guard fail the job. A red run every day trains people to ignore red runs.

What breaks for evening jobs?

Cron checks the day of the week in UTC, not in New York. For a 7:30 PM job this matters. We checked two Fridays, one in each season:

EDT 7:30PM Fri 2026-10-30 -> UTC weekday 5 NY weekday Fri
EST 7:30PM Fri 2026-11-06 -> UTC weekday 6 NY weekday Fri

In winter, 7:30 PM Friday in New York is 00:30 UTC on Saturday. A schedule written as 30 0 * * 1-5 would skip that Friday evening entirely, because Saturday is outside 1-5. It would also fire at 7:30 PM New York time on Sunday, because 00:30 UTC Monday is Sunday evening there. The fix is to schedule evening jobs daily (* * * for the day fields) and let the script check the New York weekday. This site’s reminder workflow does that for its evening check.

Limitations

  • We simulated schedules. We did not trigger GitHub-hosted runs, and we have not measured how late GitHub starts scheduled jobs.
  • The guard depends on the runner’s time zone data. We tested the same rules through Node.js 22.23.4’s Intl, not the date command on a GitHub runner.
  • A minute-exact guard fails if a run starts after 9:45. Decide whether you prefer a missed run or a slightly early or late one, and write the guard to match.

Frequently asked questions

Does GitHub Actions cron support time zones?

No. Scheduled workflows use UTC. To run at a fixed local time you either accept a one-hour drift when clocks change, or schedule both possible UTC times and skip the one that is not the right local hour.

Why use two cron lines instead of one?

Because New York is UTC-4 in summer and UTC-5 in winter. A 9:45 AM New York job is 13:45 UTC in summer and 14:45 UTC in winter. Listing both guarantees one of them is right on every date.

How does the guard decide which run should proceed?

It asks the system what time it is in America/New_York and continues only if that reads 09:45. The other schedule wakes up, sees 08:45 or 10:45, and exits.

Do weekday ranges in cron stay correct for evening jobs?

Not always. Cron evaluates the weekday in UTC. A 7:30 PM Friday job in winter is 00:30 UTC on Saturday, so a 1-5 weekday filter would skip it. Daily schedules with the guard doing the weekday check avoid the problem.

Are GitHub scheduled runs guaranteed to start on time?

Not that we can promise. Scheduled runs can start late, and we did not measure how late. We only tested the schedule logic, not GitHub's runner timing, so allow slack in anything time-sensitive.

← All research