Module 2: Anatomy Of A Github Actions Workflow

4. The `schedule` trigger and cron syntax

Description

Lesson 3's three triggers —push, pull_request, workflow_dispatch— have something in common: they all depend on someone doing something (a commit, a PR, a click). schedule is different: it runs by the simple passage of time, with no person triggering it. This lesson teaches you the complete cron: syntax, with the exact use case you're going to build in Module 5 —drift detection, run periodically to discover whether something changed the infrastructure outside of Terraform— and a specific honesty about which part of this trigger can be executed in a written lesson, and which part can't.

Connection to the module

This lesson closes out the conceptual trigger trio (along with push/pull_request/workflow_dispatch from lesson 3) before moving on, in lesson 5, to uses/with — the other pillar of a workflow's anatomy. Module 5 (lessons 6 and 7) returns to schedule with Andes Cargo's complete drift.yml, built on exactly the syntax you see here.


Analogy: an alarm clock, not a doorbell

Lesson 3's triggers are, all of them, a doorbell: someone rings it, and something responds. schedule is an alarm clock: it goes off at the time you set, with nobody having to touch anything — and, like any real alarm, you can't "test it" by fast-forwarding your house's clock; you can only confirm the time is set correctly, and wait (or trigger the same bell that would ring, by hand, to confirm the logic inside works).


The cron: syntax

on:
  schedule:
    - cron: "0 6 * * *"
  workflow_dispatch:

schedule takes a list of cron expressions (notice the dash before cron: — you can declare several distinct schedules for the same workflow, each on its own line). Each cron expression has five fields, space-separated, always in this order:

┌───────────── minute (0 - 59)
│ ┌───────────── hour (0 - 23)
│ │ ┌───────────── day of month (1 - 31)
│ │ │ ┌───────────── month (1 - 12)
│ │ │ │ ┌───────────── day of week (0 - 6, Sunday = 0)
│ │ │ │ │
* * * * *

"0 6 * * *" reads: minute 0, hour 6, any day of the month (*), any month (*), any day of the week (*) — that is, every day at 6:00 AM UTC. The asterisk (*) means "any valid value in this position"; it's the same symbol, with the same meaning, you'd use in a Linux crontab — GitHub Actions didn't invent its own syntax, it adopted the POSIX standard you already knew if you ever scheduled a task with cron on a server.

Two details worth memorizing:

  • The time is always UTC, no matter what time zone you or the server running the runner is in. "0 6 * * *" is 6:00 AM in London during winter time, not your local time zone — if you need it to run at a specific local time, you have to calculate the UTC offset yourself.
  • The minimum interval is 5 minutes. GitHub Actions doesn't allow a cron: that fires more often than that — you're not going to be able to simulate, for example, "every 30 seconds" with this trigger.

Andes Cargo's case: drift detection

Module 5 (lesson 6) builds Andes Cargo's complete drift.yml, but you can already read the trigger's syntax now:

name: drift-detection

on:
  schedule:
    - cron: "0 6 * * *"
  workflow_dispatch:

jobs:
  check-drift:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Running the drift check job, triggered by ${{ github.event_name }}"

The idea, previewed here and built in full in Module 5: every day, at 6:00 AM UTC, this job is going to run a read-only terraform plan against andes-cargo-infra/ — if that plan detects a difference between what Terraform believes exists and what really exists in the cloud (someone changed something by hand, outside the pipeline), the job reports it. workflow_dispatch is there on purpose, alongside schedule — it's the escape hatch to run exactly the same check, by hand, without waiting for the scheduled time; it's what you're going to use later in this very lesson.


Running it: what CAN be run today, and what can't

Here's this lesson's exact honesty. There are two different things when we talk about "running" a schedule workflow:

1. The timer itself — REPRESENTATIVE, for a simple reason. There's no way for a written lesson, read at any moment of any day, to "wait" until it's 6:00 AM UTC to show you a run genuinely triggered by the passage of time. Not even real GitHub lets you simulate this on demand —a cron's automatic trigger on GitHub Actions, additionally, can lag several minutes behind the exact declared time under high platform load, something GitHub's own documentation warns about. This part necessarily stays in the "this is how it works" category, with no terminal output to show.

2. The job that timer would trigger — EXECUTED, for real, right now. This is what you can run: act lets you simulate the schedule event directly, with no clock to wait for — and also, as a simpler alternative, run the same job via workflow_dispatch.

act schedule

What to expect (literal output, executed to write this lesson):

[drift-detection-demo/check-drift] ⭐ Run Set up job
[drift-detection-demo/check-drift] 🚀  Start image=catthehacker/ubuntu:act-latest
[drift-detection-demo/check-drift]   ✅  Success - Set up job
[drift-detection-demo/check-drift] ⭐ Run Main echo "Running the drift check job, triggered by schedule"
[drift-detection-demo/check-drift]   | Running the drift check job, triggered by schedule
[drift-detection-demo/check-drift]   ✅  Success - Main echo "Running the drift check job, triggered by schedule" [63.788917ms]
[drift-detection-demo/check-drift] 🏁  Job succeeded

Notice the printed line: github.event_name equals schedule, exactly as if the real cron: had triggered it — act generates a synthetic schedule-type event so the job runs with the same context it would have in a real scheduled run. The only thing you can't prove is that this synthetic event arrived at 6:00 AM, because it didn't arrive via any clock — it arrived because you typed the command.

act workflow_dispatch -j check-drift

What to expect (literal output, executed to write this lesson):

[drift-detection-demo/check-drift] ⭐ Run Set up job
[drift-detection-demo/check-drift] 🚀  Start image=catthehacker/ubuntu:act-latest
[drift-detection-demo/check-drift]   ✅  Success - Set up job
[drift-detection-demo/check-drift] ⭐ Run Main echo "Running the drift check job, triggered by workflow_dispatch"
[drift-detection-demo/check-drift]   | Running the drift check job, triggered by workflow_dispatch
[drift-detection-demo/check-drift]   ✅  Success - Main echo "Running the drift check job, triggered by workflow_dispatch" [59.139125ms]
[drift-detection-demo/check-drift] 🏁  Job succeeded

Same business logic, different event_name — this is the "by hand" run an Andes Cargo operator would use in Module 5 to confirm the drift check outside its schedule, without having to wait for or fake a schedule event.


Going deeper: why workflow_dispatch accompanies almost every real schedule

Notice that this lesson's drift-detection.yml declares both triggers, schedule and workflow_dispatch, not just one. It's a deliberate, extremely common pattern in real production workflows: schedule gives you the automatic cadence, but workflow_dispatch gives you the ability to run exactly the same check on demand —after a suspicious change, during an incident, or simply to confirm the logic still works without waiting until the next scheduled run. You're going to see this same pair in drift.yml when you finish it in Module 5.


Common mistakes

Expecting cron: to run exactly at the declared time, to the second (expectation-based). What happens: someone configures cron: "0 6 * * *" and is surprised when the real run shows up at 6:11 or 6:23 AM UTC instead of exactly 6:00. Why it happens: GitHub Actions runs cron on infrastructure shared across millions of repositories; the official documentation is explicit that the scheduled time is a minimum, not an exact guarantee, and that the delay can grow during periods of high platform demand. How to spot it: if your monitoring assumes a schedule job ran "late" for being a few minutes after the declared time. How to fix it: design any logic that depends on schedule assuming a tolerance window, not an exact instant — and, if you genuinely need second-level precision, GitHub Actions' schedule isn't the right tool.

Writing a local time in cron:, forgetting it's always UTC (syntax-based, very common). What happens: someone in a UTC-5 time zone wants a job to run "at 6 AM local time" and writes cron: "0 6 * * *", without adjusting — the job ends up running at 6 AM UTC, which is 1 AM in their time zone. How to spot it: if a drift.yml runs at a time that "doesn't make sense" according to your local clock. How to fix it: always calculate the UTC offset before writing cron:"0 6 * * *" for 6 AM in Bogotá (UTC-5) would need to be written as "0 11 * * *".

Confusing act schedule with proof that cron: is written correctly (conceptual). What happens: someone runs act schedule successfully and concludes that the complete cron: syntax —the five-field pattern— is validated. Why it happens: it seems reasonable that simulating the event would include validating the expression that would trigger it in real life. How to spot it: if you think act schedule "checked" your cron: string in some way. How to fix it: act schedule simulates the event type, without evaluating the content of the cron: string you declared at all —the same kind of limit you saw in lesson 3 with branches:/paths:. To validate that a cron expression says what you think it says, use a dedicated tool like crontab.guru (Resources, below), not act.


Exercises

Exercise 1 — Translate three cron expressions. Without using any external tool yet, translate these three expressions into plain English: (a) "0 0 * * *"; (b) "*/15 * * * *"; (c) "0 9 * * 1".

See solution

(a) "0 0 * * *" — every day, at midnight UTC (00:00). (b) "*/15 * * * *" — every 15 minutes, all day, every day (the */15 is the "every N units" syntax in the minute field). (c) "0 9 * * 1" — every Monday, at 9:00 AM UTC (the 1 in the last field is Monday, with Sunday as 0).

Exercise 2 — Explain why this lesson can't "wait" for the cron to run. A colleague, after reading this lesson, asks you: "why didn't you run the real workflow at 6 AM and paste that output, instead of using act schedule?" Answer them with technical precision, not a vague excuse.

See solution

A complete answer sounds, roughly, like this: "A written lesson is read at any moment of any day, in any time zone, potentially years after being written — there's no way to sync its content with a specific instant of the UTC clock. What can be done, and is exactly what this lesson does, is simulate the same event type (schedule) that cron would generate, with no clock to depend on — act schedule runs the job with the same context (github.event_name == 'schedule') it would have in a real run, the only difference being that you triggered it, by typing the command, instead of GitHub's scheduler at 6 AM."

Exercise 3 — Justify why drift.yml needs workflow_dispatch in addition to schedule. Without looking at this lesson, explain in two sentences why the drift.yml you're going to build in Module 5 declares both triggers, instead of just schedule.

See solution

A complete answer sounds, roughly, like this: "schedule gives you the automatic cadence —the check runs on its own, every day, with nobody having to remember— but if you suspect something changed outside of Terraform right now, you don't want to wait until the next scheduled run. workflow_dispatch gives you that emergency exit: it runs exactly the same job, on demand, by pressing a button (or with act workflow_dispatch in this guide), without touching or waiting for the cron."


Summary and next step

In this lesson you learned the complete cron: syntax —five fields, always in UTC, with a minimum of 5 minutes between runs— and really ran the job that cron would trigger, with act schedule and with act workflow_dispatch as a manual alternative. It became clear, with the explicit honesty this guide holds to, which part of schedule is literally impossible to "wait for" in a written lesson (the real passage of time) and which part does execute for real today (the job, with the correct event context).

Before moving on you should be able to: read any five-field cron: expression without outside help; explain why it's always in UTC; and explain the exact difference between "simulating the schedule event" and "waiting for the clock to trigger the real cron:."

With all four triggers covered (push, pull_request, workflow_dispatch, schedule), lesson 5 moves to a workflow's other pillar: what a reusable Action exactly is, and why pinning it by SHA instead of by tag is a real security practice, not a whim.

Resources

  1. GitHub Docs — Events that trigger workflows: schedule — official documentation for the schedule trigger, including the warning about delays under high demand.
  2. crontab.guru — an interactive cron-expression-to-plain-English translator, useful for verifying any cron: before writing it into a real workflow.
  3. nektosact.com — User Guide — official documentation for act schedule and the other event names the command line accepts.