← Back to blog
·5 min read

How to Monitor GitHub Actions Scheduled Workflows

GitHub Actions supports cron-scheduled workflows via schedule triggers. They're convenient — no separate cron server needed. But they have a critical flaw: when they fail, nobody knows.

The problem with scheduled workflows

GitHub Actions scheduled workflows have several failure modes that produce zero alerts:

1. The workflow is disabled — GitHub automatically disables scheduled workflows in repos with no activity for 60 days 2. The schedule doesn't trigger — GitHub doesn't guarantee exact timing; during high load, schedules can be delayed by minutes or skipped entirely 3. The workflow fails silently — unless you've configured notifications (most people haven't), a red X in the Actions tab goes unnoticed 4. The repo is forked — scheduled workflows are disabled by default in forks

GitHub's own documentation warns: "To prevent unnecessary workflow runs, scheduled workflows may be disabled automatically. When a public repository is forked, scheduled workflows are disabled by default."

Adding heartbeat monitoring

The fix is to add a CronSafe ping at the end of your workflow. If the ping doesn't arrive on schedule, you get alerted via Slack, Discord, email, or Telegram.

Basic example

yaml
# .github/workflows/daily-sync.yml
name: Daily data sync

on:
  schedule:
    - cron: '0 6 * * *'  # 6 AM UTC daily
  workflow_dispatch:       # allow manual trigger

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run sync script
        run: |
          python scripts/sync_data.py
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}

      - name: Ping CronSafe
        if: success()
        run: curl -s https://api.getcronsafe.com/ping/daily-sync

The if: success() condition ensures the ping only fires when all previous steps passed.

With start/end tracking

For longer workflows, track duration by sending start and end pings:

yaml
name: Nightly backup

on:
  schedule:
    - cron: '0 2 * * *'  # 2 AM UTC
  workflow_dispatch:

jobs:
  backup:
    runs-on: ubuntu-latest
    steps:
      - name: Signal start
        run: curl -s https://api.getcronsafe.com/ping/nightly-backup/start

      - uses: actions/checkout@v4

      - name: Run backup
        run: |
          pg_dump $DATABASE_URL > backup.sql
          gzip backup.sql
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}

      - name: Upload to S3
        run: |
          aws s3 cp backup.sql.gz s3://my-backups/$(date +%Y%m%d).sql.gz
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

      - name: Signal success
        if: success()
        run: curl -s https://api.getcronsafe.com/ping/nightly-backup

      - name: Signal failure
        if: failure()
        run: curl -s https://api.getcronsafe.com/ping/nightly-backup/fail

This gives you:

  • Start time tracking
  • Success confirmation with completion time
  • Explicit failure notification with faster alert delivery

With job output

Send the workflow's output with the ping for debugging context:

yaml
name: Hourly health check

on:
  schedule:
    - cron: '0 * * * *'
  workflow_dispatch:

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run health checks
        id: health
        run: |
          OUTPUT=$(python scripts/health_check.py 2>&1)
          echo "$OUTPUT"
          echo "result=$OUTPUT" >> $GITHUB_OUTPUT

      - name: Ping CronSafe with output
        if: success()
        run: |
          curl -s -X POST https://api.getcronsafe.com/ping/health-check \
            -d "${{ steps.health.outputs.result }}"

      - name: Report failure
        if: failure()
        run: |
          curl -s -X POST https://api.getcronsafe.com/ping/health-check/fail \
            -d "Health check failed"

Handling the 60-day inactivity problem

GitHub disables scheduled workflows after 60 days of no repo activity. This is the most common cause of silent failures for scheduled workflows.

Two ways to prevent it:

Option 1: Use workflow_dispatch as backup

Always include workflow_dispatch as a trigger. This lets you manually run the workflow from the Actions tab, which counts as activity.

Option 2: Automate the keep-alive

Create a workflow that commits a timestamp to keep the repo active:

yaml
name: Keep alive

on:
  schedule:
    - cron: '0 0 1 * *'  # monthly

jobs:
  keepalive:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Update keep-alive timestamp
        run: |
          date > .github/keep-alive
          git config user.name "github-actions"
          git config user.email "actions@github.com"
          git add .github/keep-alive
          git diff --cached --quiet || git commit -m "chore: keep alive"
          git push

This ensures the repo always has recent activity, preventing GitHub from disabling your scheduled workflows.

Setting up the CronSafe monitor

When creating the monitor in CronSafe, set these values:

  • Schedule: Match your workflow's cron expression
  • Grace period: GitHub Actions schedules can be delayed by up to 15 minutes during high load. Set your grace period to at least 20 minutes for anything more frequent than daily, and 1 hour for daily jobs.

Complete production-ready template

yaml
name: Scheduled job with monitoring

on:
  schedule:
    - cron: '*/30 * * * *'  # every 30 minutes
  workflow_dispatch:

concurrency:
  group: scheduled-job
  cancel-in-progress: false

jobs:
  run:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - name: Signal start
        run: curl -fsS https://api.getcronsafe.com/ping/my-job/start

      - uses: actions/checkout@v4

      - name: Execute job
        id: job
        run: |
          set -euo pipefail
          OUTPUT=$(./scripts/my-job.sh 2>&1)
          echo "output<<EOF" >> $GITHUB_OUTPUT
          echo "$OUTPUT" >> $GITHUB_OUTPUT
          echo "EOF" >> $GITHUB_OUTPUT

      - name: Signal success
        if: success()
        run: |
          curl -fsS -X POST https://api.getcronsafe.com/ping/my-job \
            -d "${{ steps.job.outputs.output }}"

      - name: Signal failure
        if: failure()
        run: |
          curl -fsS -X POST https://api.getcronsafe.com/ping/my-job/fail \
            -d "Workflow failed — check Actions tab"

Key details in this template:

  • concurrency prevents overlapping runs
  • timeout-minutes kills hung workflows
  • curl -fsS fails silently on network errors but shows HTTP errors
  • Start/success/fail pings cover all cases
  • Job output is captured and sent with the ping

Start monitoring your cron jobs for free

20 monitors, email alerts, GitHub badges. No credit card required.

Get started free →