GitHub Actions on: schedule workflows are one of the most popular ways to run recurring automation — nightly reports, stale-issue sweeps, dependency updates, data syncs, cache warm-ups. They're easy to set up and cheap to run. They're also surprisingly easy to lose track of, because GitHub gives you almost no visibility when a scheduled workflow stops executing.
This guide covers every failure mode for GitHub Actions cron jobs, how to detect them, and how to add reliable external monitoring so you know immediately when a scheduled run is missed.
GitHub's documented behavior: scheduled workflows are automatically disabled if the repository has no activity for 60 days. "Activity" means commits, pull requests, or other pushes to the default branch — not workflow runs themselves.
This creates a specific trap for utility repositories. Consider a small repo that exists solely to run a nightly script — a backup job, an inventory sync, a report mailer. Once the initial setup is committed, there's no ongoing code activity. The repo sits idle. Sixty days later, GitHub disables the scheduled workflow. The repository owner gets no email, no notification, and no indication in the GitHub UI unless they actively navigate to the Actions tab and notice the workflow shows as "disabled."
The fix is to keep the repository minimally active. A common workaround is a second workflow that commits a timestamp file every 30 days, but this adds complexity. The more reliable solution is external monitoring that alerts you when the expected run doesn't happen.
The 60-day disable is the most invisible failure, but it's not the only one.
GitHub Actions runs on shared infrastructure. During incidents, major events, or peak usage times, scheduled workflows can be delayed by minutes to hours — or skipped entirely if the queue backlog causes them to miss their window. GitHub doesn't retry missed scheduled runs; if the slot was missed, that run is gone.
Free GitHub accounts include a monthly compute budget for Actions minutes. Private repositories draw from this budget; public repositories run for free. If your account hits its spending cap — or if a billing card fails and the account is restricted — scheduled workflows stop running without warning. The error state shows in the billing settings, not in the Actions tab.
If your organization uses self-hosted runners, a scheduled workflow queues up and waits for an available runner. If all runners are busy, crashed, or offline, the job stays queued indefinitely. GitHub will eventually cancel long-queued jobs, but there's no alert sent when this happens.
A pull request that changes the workflow YAML can introduce a syntax error or invalid expression that only manifests at runtime. The workflow runs until the bad change is merged, then silently fails. The failure appears in the Actions tab — but only if someone goes looking.
GitHub Actions uses UTC for all cron expressions. A workflow meant to run at 3 AM local time will run at a different UTC hour depending on DST transitions and the team's time zone. If the intended window is critical (e.g., "before business opens"), a misconfigured expression can mean the job runs at the wrong time without technically failing.
You can verify any cron expression before committing it:
Enter any cron expression to see exactly when it will next fire, what the human-readable schedule means, and whether there are edge cases (DST, leap years, end-of-month behavior).
Free cron parser →The most obvious approach, and the least reliable. You can go to the repository Actions tab, filter by the scheduled workflow, and verify the last successful run timestamp. For critical workflows, this means manual checking on a regular schedule — which is easy to forget and doesn't work across many repositories.
A workflow can check its own recent run history using the GitHub API and fail loudly if the previous run was too long ago:
- name: Check for missed previous run
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
LAST_RUN=$(gh run list \
--workflow=nightly-sync.yml \
--status=success \
--limit=1 \
--json=createdAt \
--jq='.[0].createdAt')
HOURS_SINCE=$(( ($(date +%s) - $(date -d "$LAST_RUN" +%s)) / 3600 ))
if [ "$HOURS_SINCE" -gt 26 ]; then
echo "ERROR: Last successful run was $HOURS_SINCE hours ago"
exit 1
fi
This step only runs if the workflow itself runs — it won't catch the case where the workflow stops executing entirely. It's useful for catching partial failures (the workflow runs but the logic inside breaks), not missed runs.
The only approach that catches every failure mode — including the 60-day disable — is a dead man's switch: your workflow sends an HTTP ping to an external monitoring service at the end of each successful run. If the monitoring service doesn't receive the ping within the expected window, it sends an alert.
Implementation is a single step at the end of your workflow:
name: Nightly Sync
on:
schedule:
- cron: '0 3 * * *' # 3:00 AM UTC daily
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run sync
run: python sync.py
- name: Notify CronWatch (heartbeat)
if: success()
run: |
curl -fsS "https://cronwatch.example.com/heartbeat/${{ secrets.CRONWATCH_TOKEN }}"
The if: success() condition ensures the ping only fires when the sync step completes without errors. If the sync fails, the heartbeat step is skipped, and the monitoring service alerts you after the expected window passes.
Beyond monitoring, a few structural choices make scheduled workflows more resilient:
workflow_dispatch alongside scheduleAdding a manual trigger lets you re-run a missed job on demand without modifying the code:
on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch: # allows manual trigger from Actions UI or API
Without a timeout, a hung job occupies a runner indefinitely and blocks subsequent runs. Set a job-level timeout:
jobs:
sync:
runs-on: ubuntu-latest
timeout-minutes: 30 # force-kill if the job exceeds this
steps:
- ...
If a scheduled run takes longer than its period, the next run can start before the previous finishes. Use concurrency to prevent stacked runs:
concurrency:
group: nightly-sync
cancel-in-progress: false # let the running job finish; queue the next one
Using @main or @latest for actions means a third-party change can silently break your workflow. Pin to a specific commit SHA for scheduled jobs where reliability matters:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
If your organization has dozens of repositories with scheduled workflows, manual checking is not viable. The approaches that scale:
GET /repos/{owner}/{repo}/actions/runs?event=schedule&per_page=1 for each repo and check the created_at timestamp. Write a script that runs hourly and alerts when any workflow is behind schedule.When you discover a scheduled workflow hasn't run, work through this checklist:
workflow_dispatch to verify it runs successfullyCronWatch monitors your scheduled workflows with a dead man's switch. Add one curl step to your workflow, set the expected interval, and get alerted via email or Slack if any run is missed — including the silent 60-day GitHub disable.