← Blog  ·  October 2026

GitHub Actions Scheduled Workflows: Why They Fail Silently and How to Monitor Them

The on: schedule: trigger has several quiet failure modes that GitHub won't tell you about. Here's every one of them and how to detect them.

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.

What this guide covers:

1 The core problem: schedule: is unreliable by design

GitHub'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.

Key insight: A scheduled workflow that doesn't run produces no failed run entry in the GitHub Actions UI. There is nothing to trigger a notification. You only learn about it by noticing the absence of output — a missing report, a stale database, a silent pipeline.

2 Four failure modes in detail

1. Repository inactivity → auto-disable

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.

2. Load shedding: delayed or skipped runs

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.

3. Step failures with no notification configured

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.

4. Missing workflow_dispatch and permission issues

In 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.


3 GitHub's built-in monitoring: what it can and can't do

GitHub does have some built-in notification options, but they have real gaps when it comes to scheduled workflows.

Notification typeWhat it catchesWhat 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.


4 The dead man's switch pattern for GitHub Actions

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.

Position matters: Place the heartbeat ping as the last step in the job. If it's in the middle, a later step could fail and the monitor would still show the workflow as healthy. The ping should only fire when everything that matters has succeeded.

Handling step failures explicitly

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.


5 Setting up failure notifications for GitHub Actions

For jobs that do run and fail, configure explicit notifications so you're not relying on GitHub's default email behavior.

Email notifications via workflow step

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.

Slack notifications

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>\"}"

6 CronWatch vs GitHub's built-in options

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.

FeatureGitHub built-inCronWatch
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.


CronWatch — Dead Man's Switch Monitoring for GitHub Actions

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.

Join the CronWatch waitlist → Parse cron expressions free

Related reading