GitHub Actions is many teams' de facto cron: nightly backups, hourly syncs, weekly report jobs, dependency updates — all riding on a schedule: trigger in a workflow file. It is convenient, free for public repos, and quietly one of the least reliable schedulers you can bet a critical job on. Scheduled workflows get automatically disabled after 60 days of repo inactivity, start 15–30 minutes late when GitHub is busy, get dropped outright under heavy load, and fail with a notification model that emails exactly one person — the last one who touched the cron line.
None of this is hidden; it's all in GitHub's docs. But each failure mode is silent in the way that matters: nothing pages your team when a schedule stops running. This guide covers how scheduled workflows actually die, and how a heartbeat monitor — a URL your job pings on success, with an alert when the pings stop — catches every variant with one pattern.
The five ways a scheduled workflow silently dies
- The 60-day disable. In public repositories, GitHub disables scheduled workflows after 60 days without repository activity. One warning email goes out; miss it and your nightly job simply stops. Stale-but-important repos — the docs site, the data-export job, the certificate renewal — are exactly the ones that hit this.
- Schedule jitter and dropped runs.
schedule:events are queued, not guaranteed. Runs routinely start 15–30 minutes late at busy times (the top of the hour is the worst slot, since that's where everyone's cron lines point), and during high-load periods runs can be skipped entirely. GitHub's own docs say as much. - The notification dead end. A failed scheduled run notifies the user who last modified the cron schedule — not a team channel. If they left, muted GitHub email, or filter it to a folder, failures accumulate unseen. There is no escalation and no "this hasn't run in a week" detection.
- Repo-level changes. Schedules only run from the default branch — rename it, or land a commit with a YAML syntax error in the workflow file, and the schedule stops. Spending limits on private repos halt all runs when exhausted.
- The job runs but the work fails. A step exits non-zero, an API the job calls times out, or the backup uploads zero bytes "successfully" — the run history goes red (or worse, green), and nobody looks at run history on a schedule. Our backup monitoring guide covers why "the job ran" and "the work happened" are different claims.
The common thread: every one of these ends with the work not happening, and none of them reliably ends with a human finding out. That's the exact shape of problem heartbeat monitoring solves.
The heartbeat pattern: silence is the alarm
Instead of asking GitHub to tell you about failures (it won't, reliably), invert the direction: the workflow pings a unique CronAlert heartbeat URL as its final step, only on success. CronAlert knows the expected interval; if a ping doesn't arrive within the interval plus a grace period, you get an alert on every channel your team uses. Disabled schedule, dropped run, broken YAML, failed step, exhausted spending limit — they all collapse into the same observable symptom: the ping stops.
name: nightly-backup
on:
schedule:
# 03:17, not 03:00 — off-peak minutes dodge the top-of-hour queue
- cron: "17 3 * * *"
workflow_dispatch: {}
jobs:
backup:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run backup
run: ./scripts/backup.sh
# Last step, no if: — a failed step above skips it, and
# the missing ping is what triggers the alert
- name: Ping CronAlert heartbeat
run: curl -fsS --retry 3 "https://cronalert.com/api/heartbeat/${{ secrets.CRONALERT_HEARTBEAT_TOKEN }}" Three details in that YAML earn their keep. The off-peak cron minute (17 3 instead of 0 3) meaningfully reduces queue delay. The workflow_dispatch trigger lets you re-run manually after fixing a failure, which also resets the heartbeat. And the ping step has no if: condition — GitHub Actions skips remaining steps when one fails, so failure naturally means silence. Never use if: always() on the ping step; that pings on failure and masks it. Keep the token in a repository secret so forks and logs don't leak it.
Sizing the grace period for GitHub's jitter
Heartbeat monitors have two knobs: the expected interval and the grace period. GitHub's scheduling jitter goes straight into the grace period. For a nightly job, expect the run to start up to 30 minutes late and take however long the work takes — a grace period of 60 minutes on a 24-hour interval is a sane default. For an hourly job, 30–60 minutes of grace avoids paging you about GitHub's queue while still catching a schedule that actually died within the hour. If you find yourself needing a tight SLA on start time, GitHub Actions is the wrong scheduler for that job — move it to a real cron host and keep the same heartbeat. Our thresholds guide covers the general art of picking numbers that alert on real failures and sleep through noise.
One heartbeat per job, not per repo
Give each scheduled workflow its own heartbeat monitor — the nightly backup, the hourly sync, and the weekly report are separate failure domains with separate owners and separate intervals. CronAlert's Pro plan includes 100 monitors for $5/mo, so there is no economy in bundling; a fleet of scheduled workflows fits comfortably. Name monitors after the repo and workflow (acme-api / nightly-backup) so the alert tells you where to look before you click. If you manage many repos, the REST API can create heartbeat monitors from a script — useful when rolling the pattern out across an organization.
Beating the 60-day disable specifically
For public repos you barely touch, the disable rule needs a decision, not just detection. Your options, roughly in order of preference:
- Let the heartbeat catch it. Zero setup beyond what you have. When the alert fires, re-enable the workflow in the Actions tab (or
gh workflow enable) and commit something. Right answer for jobs where a day of delay is acceptable. - Keepalive automation. A step that calls the GitHub API to re-enable the workflow (
gh api -X PUT repos/OWNER/REPO/actions/workflows/ID/enable) or a community keepalive action that makes an empty commit when the repo has been quiet for ~50 days. Fine, but you are now maintaining automation whose job is to outwit your scheduler — worth it only for genuinely critical jobs stuck in inactive repos. - Move the job. Critical schedules in dead repos are better hosted where activity is constant — a monorepo's workflow directory, or an actual cron host. Keep the heartbeat either way.
Don't forget the workflow's outputs
A heartbeat proves the job ran; it doesn't prove the result is good. Pair it with checks on what the job produces: if the workflow deploys a site, an HTTP monitor on the site catches a green run that shipped a broken build; if it generates a data file or feed, a content-stale check alerts when the output stops changing even though something is pinging. The CI/CD monitoring guide covers the deploy side — including creating monitors from GitHub Actions after each deploy.
Set it up in five minutes
- Create a CronAlert account (heartbeat monitors are on every paid plan, from $5/mo Pro).
- Create a heartbeat monitor per scheduled workflow: set the expected interval to the cron cadence and a grace period sized for GitHub's jitter (60 minutes for nightly jobs is a good start).
- Copy each monitor's heartbeat URL into a repo secret (
CRONALERT_HEARTBEAT_TOKEN). - Add the
curlstep as the final step of each job — noif:condition. - Wire alerts to where your team actually looks: Slack, Discord, Teams, Telegram, PagerDuty, email, or push.
- Trigger a test failure (
exit 1in a step viaworkflow_dispatch) and confirm the alert arrives after the grace period.
Frequently asked questions
Why did my GitHub Actions scheduled workflow stop running?
Usually the 60-day inactivity disable (public repos), a spending limit, a broken workflow file, or a default-branch change. All of them are silent; a heartbeat monitor turns each into an alert when the expected ping stops arriving.
Why does my GitHub Actions cron run late?
Schedules are queued, not guaranteed — 15–30 minute delays are normal at busy times and runs can be dropped under load. Use an off-peak cron minute and a grace period that absorbs the jitter.
Does GitHub notify me when a scheduled workflow fails?
Only the person who last edited the cron line, by email, with no escalation — and never for "it didn't run at all." Heartbeats route both cases to your team's real alert channels.
How do I stop the 60-day automatic disable?
Any repo activity resets the clock; for quiet repos use a keepalive step or move the job — and keep a heartbeat regardless, since the 60-day rule is only one of several silent killers.
Should the heartbeat ping be a separate workflow step?
Last step of the same job, with no if: condition — failed steps skip it, so failures go silent and silence alerts. Never if: always(), which would ping on failure and mask it.
Stop trusting the scheduler to report on itself
Every failure mode in this post ends the same way: the work stops happening and GitHub doesn't tell your team. One curl line per workflow and a heartbeat monitor per schedule turns all of it — disables, drops, breakage, failed steps — into an alert in the channel where your team already lives. Create a free CronAlert account, add your first heartbeat on the $5/mo Pro plan, and give your nightly jobs a witness.
Related reading: cron job heartbeat monitoring, monitoring Kubernetes CronJobs, monitoring scheduled database backups, batch job monitoring, and uptime monitoring in CI/CD pipelines.