GitHub Actions makes it easy to schedule workflows with a cron expression. Add on: schedule:, push to main, and your workflow runs on a schedule — until it doesn't. Unlike a job that fails with a red X, a missed schedule: run leaves no trace at all. No email, no notification, no failed run in the UI. The workflow simply doesn't appear.
This guide covers every silent failure mode for GitHub Actions scheduled workflows, how to detect them, and how to set up monitoring so you know the moment a scheduled run is skipped or fails.
schedule: is unreliable by designGitHub's documentation contains a note that most developers miss:
"Scheduled workflows are automatically disabled when no repository activity has occurred in 60 days."
That means if nobody pushes a commit, opens a PR, or otherwise touches the repository for two months, GitHub quietly disables every on: schedule: trigger. When you come back and find your nightly data sync hasn't run in weeks, there's no alert waiting for you — just a blank run history.
Beyond the inactivity timeout, GitHub explicitly states that scheduled workflows may be delayed or skipped during periods of high load. This is called load shedding: GitHub's infrastructure deprioritizes scheduled runs when their queues are full. A workflow set to run at midnight UTC may run at 12:34am, or not at all.
After 60 days of no repository activity, GitHub disables all scheduled workflows. The workflows still exist in your .github/workflows/ directory; they're just not triggered. You can re-enable them manually in the GitHub Actions UI, but you have to know to look.
This is especially common for maintenance repositories — things like weekly dependency update checks, periodic data exports, or monitoring scripts that live in a dedicated repo with no other commits.
GitHub's scheduler is best-effort, not guaranteed. Under load, workflows scheduled for a specific cron time may be delayed by minutes to hours, or dropped entirely. GitHub provides no SLA for schedule: triggers. If your workflow needs to run within a reliable window, you cannot rely on on: schedule: alone.
A job that fails will show as a red X in the GitHub Actions UI — but only if you're watching. By default, GitHub sends failure emails only to the user who last pushed. If your scheduled workflow fails at 3am on a Saturday, the email may go to someone who's not monitoring it, or be filtered as noise. No Slack message, no PagerDuty alert, nothing except a red dot in the UI.
workflow_dispatch and permission issuesIn repositories created after mid-2023, the default GitHub Actions permissions are more restrictive. Workflows that previously had write access to repository contents may silently fail with a permissions error if the repository settings were changed. Similarly, if a required secret is deleted or rotated, the workflow will fail on the step that uses it — again, silently, unless notifications are configured.
GitHub does have some built-in notification options, but they have real gaps when it comes to scheduled workflows.
| Notification type | What it catches | What it misses |
|---|---|---|
| Email on failure (default) | Jobs that run and fail | Workflows that never run (load shedding, inactivity disable) |
| GitHub notification settings | Run failures visible in UI | Skipped runs, runs that produce no entry |
| Slack via GitHub app | Run completions (with configuration) | Silently skipped runs |
The fundamental gap: GitHub can only notify you about runs it knows about. A workflow that was never triggered — due to load shedding or inactivity disable — produces no run record, and therefore no notification. This is exactly what a dead man's switch is designed to handle.
A dead man's switch (also called a heartbeat monitor) inverts the monitoring model. Instead of watching for a failure event, you configure your workflow to send a "success ping" at the end of every run. A monitoring service watches for that ping. If the ping stops arriving within the expected window, the service alerts you.
This catches every failure mode listed above:
The implementation is a single step added as the last step in your job. It only fires when all previous steps have succeeded:
name: Nightly data sync
on:
schedule:
- cron: '0 2 * * *' # 2am UTC every day
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run data sync
run: python scripts/sync.py
- name: Run report generation
run: python scripts/generate_report.py
# This step only runs if all previous steps succeeded
- name: Ping heartbeat monitor
run: curl -fsS "https://cronwatch.ping/YOUR_TOKEN" > /dev/null
The -fsS flags for curl are important: -f treats HTTP error responses as failures, -s suppresses progress output, and -S still shows errors. The redirect to /dev/null keeps the step output clean.
If a step in your workflow uses continue-on-error: true, GitHub won't fail the job even if that step fails. In that case, capture the outcome and gate the ping manually:
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run data sync
id: sync
run: python scripts/sync.py
continue-on-error: true
- name: Run secondary task
run: python scripts/secondary.py
# Only ping if the critical step succeeded
- name: Ping heartbeat monitor
if: steps.sync.outcome == 'success'
run: curl -fsS "https://cronwatch.ping/YOUR_TOKEN" > /dev/null
This pattern gives you fine-grained control: secondary tasks can fail without blocking the ping, but a failure in the critical step prevents the ping from firing.
For jobs that do run and fail, configure explicit notifications so you're not relying on GitHub's default email behavior.
You can use a conditional failure step to send an alert when the job fails:
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run data sync
run: python scripts/sync.py
# Runs only if the previous steps failed
- name: Notify on failure
if: failure()
uses: dawidd6/action-send-mail@v3
with:
server_address: smtp.gmail.com
server_port: 465
username: ${{ secrets.MAIL_USERNAME }}
password: ${{ secrets.MAIL_PASSWORD }}
subject: "GitHub Actions failure: ${{ github.workflow }}"
to: you@example.com
from: GitHub Actions
body: |
Workflow ${{ github.workflow }} failed.
Repository: ${{ github.repository }}
Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
This approach handles the "job runs and fails" case well. But it still won't catch workflows that were silently skipped or disabled — for those, you still need the dead man's switch ping.
A simple curl to a Slack webhook works without any third-party action:
- name: Notify Slack on failure
if: failure()
run: |
curl -s -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \
-H "Content-Type: application/json" \
-d "{\"text\":\"⚠️ Workflow failed: ${{ github.workflow }} — <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View run>\"}"
GitHub's notification system is designed around events: a run starts, a run completes, a run fails. It has no concept of "this workflow was supposed to run and didn't." For GitHub Actions schedule monitoring — catching the absence of a run — you need an external dead man's switch service.
| Feature | GitHub built-in | CronWatch |
|---|---|---|
| Alert on step failure | ✓ (email to last pusher) | ✓ (email + configurable) |
| Alert on skipped run (load shedding) | ✗ | ✓ (dead man's switch) |
| Alert on inactivity-disabled workflow | ✗ | ✓ (ping stops → alert) |
| Configurable grace period | ✗ | ✓ (per-job setting) |
| Setup time | Minutes (email only) | One curl step |
The grace period setting is important for GitHub Actions specifically. A workflow cron'd for midnight UTC may actually run at 12:15am when GitHub's queues are full. Without a grace period, you'll get false alerts on every delayed-but-successful run. A 20–30 minute grace period on a daily workflow prevents the noise while still catching genuine outages.
Add one curl step to your scheduled workflow. If it stops firing — whether from load shedding, inactivity disable, or a step failure — you get an alert. Configurable grace periods so you don't get paged for a 5-minute delay.