Skip to content

Monitor a GitHub Actions scheduled workflow

A scheduled workflow can stop without anyone noticing. GitHub may delay or skip a run, disable the schedule, or the job may start failing in a repository nobody watches. A heartbeat check catches all of these. The Pingstack Heartbeat action reports each run: Pingstack alerts right away when a run fails, and when the next report doesn’t arrive on time.

By the end of this recipe you’ll have a heartbeat check that expects your workflow’s schedule, a repository secret holding its ping URL, and a final workflow step that reports each run.

1. Create a heartbeat check that matches the schedule

Section titled “1. Create a heartbeat check that matches the schedule”

Find the cron expression in your workflow:

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

Create a heartbeat check and set its schedule to Cron with the same expression. GitHub Actions schedules run in UTC unless the schedule sets a timezone with an IANA name, such as America/New_York. Choose the same timezone on the check: UTC, or the schedule’s timezone. See schedules and timezones for how cron expectations work.

Set a generous grace period. GitHub starts scheduled runs late when its runners are busy, and the job itself takes time. Allow for both: a grace period of 30 to 60 minutes suits most daily jobs. Increase it if your job regularly runs longer.

Attach a notification group so a missed run reaches someone.

Copy the location’s ping URL from the check in Pingstack. Keep it private, because anyone who has it can report a run.

In GitHub, open the repository’s Settings › Secrets and variables › Actions, choose New repository secret, and save it as PINGSTACK_PING_URL. GitHub masks secret values in workflow logs.

Add a last step to the job you’re monitoring:

- name: Report to Pingstack
if: always()
uses: pingstack-io/heartbeat-action@v1
with:
ping-url: ${{ secrets.PINGSTACK_PING_URL }}

if: always() makes the step run even when an earlier step failed. The action reads the job’s result: a successful job sends a heartbeat, a failed one reports a failure with a link to the run, and a cancelled one sends nothing. Without if: always(), a failed run sends nothing and Pingstack alerts only when the deadline passes.

Here is a complete workflow:

name: Nightly export
on:
schedule:
- cron: '0 2 * * *'
workflow_dispatch:
jobs:
export:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run the export
run: ./scripts/export.sh
- name: Report to Pingstack
if: always()
uses: pingstack-io/heartbeat-action@v1
with:
ping-url: ${{ secrets.PINGSTACK_PING_URL }}

workflow_dispatch lets you start the workflow by hand, which you’ll use to test it.

The action masks the ping URL in logs, only sends to pingstack.io, retries network errors and Pingstack outages, and never fails your job because a report couldn’t be delivered. Set fail-on-error: true if you want it to. See the action’s README for every input.

In GitHub, open the Actions tab, select the workflow and choose Run workflow. When the run finishes, open the check in Pingstack and confirm the location received a heartbeat and shows a next deadline.

A manual run counts as a check-in like any other. After it, the check expects the next scheduled occurrence.

If the job produces a meaningful count, such as rows exported or files backed up, send it as a numeric value and set warning and critical thresholds on the check. A run that succeeds but exports nothing can then still raise an alert.

- name: Run the export
id: export
run: echo "rows=$(./scripts/export.sh --count)" >> "$GITHUB_OUTPUT"
- name: Report to Pingstack
if: always()
uses: pingstack-io/heartbeat-action@v1
with:
ping-url: ${{ secrets.PINGSTACK_PING_URL }}
value: ${{ steps.export.outputs.rows }}

Give each scheduled workflow its own check, with its own secret. If one workflow has several jobs that must all succeed, report from a final job that needs the others, so one successful job can’t hide another’s failure:

report:
needs: [export, backup]
if: always()
runs-on: ubuntu-latest
steps:
- uses: pingstack-io/heartbeat-action@v1
with:
ping-url: ${{ secrets.PINGSTACK_PING_URL }}
status: ${{ contains(needs.*.result, 'failure') && 'failure' || contains(needs.*.result, 'cancelled') && 'cancelled' || 'success' }}

If you’d rather not use a third-party action, a curl step does the same with a little more YAML:

- name: Report to Pingstack
if: always()
run: |
if [ "$JOB_STATUS" = success ]; then
curl --fail --silent --show-error --max-time 10 --retry 3 "$PINGSTACK_PING_URL"
elif [ "$JOB_STATUS" = failure ]; then
curl --fail --silent --show-error --max-time 10 --retry 3 "$PINGSTACK_PING_URL/fail"
fi
env:
PINGSTACK_PING_URL: ${{ secrets.PINGSTACK_PING_URL }}
JOB_STATUS: ${{ job.status }}

Passing the secret through env keeps it out of the command that GitHub prints.

  • Timezone mismatch. The check’s timezone must match the schedule’s timezone, or UTC when the schedule doesn’t set one.
  • Delayed runs. GitHub can start scheduled runs well after the cron time under load. If you see late alerts that recover on their own, lengthen the grace period.
  • Disabled schedules. In public repositories, GitHub disables scheduled workflows after 60 days without repository activity. Re-enable the workflow from the Actions tab.
  • Default branch only. Schedules run from the workflow file on the default branch. A schedule added on another branch never runs.
  • Missing secret. Forks and pull requests from forks don’t receive repository secrets, so the report step has no URL there. The action warns and sends nothing.

If alerts still don’t match what you expect, see troubleshooting monitoring.