Cuckoo

Monitor GitHub Actions Scheduled Workflows

Updated

GitHub Actions is a convenient place for nightly builds, data syncs and cleanup scripts. But a scheduled workflow can stop without a clear signal: GitHub may delay or drop runs under load, it disables schedules in public repositories after a period of inactivity, and failure emails go to whoever last edited the cron line. This guide adds a Cuckoo check so you get an alert when a scheduled run fails or does not happen.

How Cuckoo sees the workflow

You create a check with the same schedule as the workflow. The workflow then calls:

  • https://cuckoo.metastic.ch/ping/YOUR-UUID/start at the beginning,
  • https://cuckoo.metastic.ch/ping/YOUR-UUID when all steps succeeded,
  • https://cuckoo.metastic.ch/ping/YOUR-UUID/fail when a step failed.

If neither a success nor a failure ping arrives within the schedule plus grace time, the check goes down. That covers the cases where GitHub never started the workflow.

Scheduling facts to know

From the GitHub documentation on the schedule event:

  • Cron expressions run in UTC by default. Since March 2026 you can add an IANA timezone next to cron.
  • The shortest interval is every 5 minutes.
  • Runs can be delayed during periods of high load, and the start of every hour is a busy time. Under heavy load some queued runs may be dropped.
  • In a public repository, scheduled workflows are automatically disabled when there has been no repository activity for 60 days.
  • Scheduled workflows run on the latest commit of the default branch.

These are exactly the failure modes an external check is good at catching: nothing fails, the run just does not happen.

Step 1: store the ping URL as a secret

In the repository, go to Settings → Secrets and variables → Actions → New repository secret and create CUCKOO_URL with the value https://cuckoo.metastic.ch/ping/YOUR-UUID. With the GitHub CLI:

gh secret set CUCKOO_URL --body 'https://cuckoo.metastic.ch/ping/YOUR-UUID'

Secrets are masked in logs and are not passed to workflows triggered from forks.

Step 2: the workflow

name: nightly-sync

on:
  schedule:
    - cron: "17 3 * * *"
      timezone: "Europe/Zurich"   # optional; omit for UTC
  workflow_dispatch: {}

jobs:
  sync:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - name: Cuckoo start
        if: github.event_name == 'schedule'
        run: curl -fsS -m 10 --retry 5 -o /dev/null "${{ secrets.CUCKOO_URL }}/start" || true

      - uses: actions/checkout@v4

      - name: Run sync
        run: ./scripts/sync.sh

      - name: Cuckoo success
        if: success() && github.event_name == 'schedule'
        run: curl -fsS -m 10 --retry 5 -o /dev/null "${{ secrets.CUCKOO_URL }}" || true

      - name: Cuckoo failure
        if: failure() && github.event_name == 'schedule'
        run: curl -fsS -m 10 --retry 5 -o /dev/null "${{ secrets.CUCKOO_URL }}/fail" || true

How the conditions work:

  • success() is true only if every previous step succeeded. It is also the default when no if is set.
  • failure() is true if any previous step failed. It is not true for a cancelled run; use cancelled() if you want to report those as well.
  • github.event_name == 'schedule' keeps manual workflow_dispatch test runs from sending pings. Drop it if you want manual runs to count too.

The minute 17 is deliberate. Avoiding 0 moves the run away from the top of the hour, when the docs say delays are most likely.

Workflows with several jobs

If the work is spread across jobs, add a final job that depends on all of them and always runs:

  report:
    needs: [build, sync, publish]
    if: always() && github.event_name == 'schedule'
    runs-on: ubuntu-latest
    steps:
      - name: Report result
        env:
          HB: ${{ secrets.CUCKOO_URL }}
          FAILED: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
        run: |
          if [ "$FAILED" = "true" ]; then
            curl -fsS -m 10 --retry 5 -o /dev/null "$HB/fail" || true
          else
            curl -fsS -m 10 --retry 5 -o /dev/null "$HB" || true
          fi

Schedule and grace advice

Use the same cron expression in Cuckoo. If you set no timezone in the workflow, choose UTC for the check; if you did, use the same IANA name. Because scheduled runs can start late, set a generous grace time: the job's normal runtime plus 30 to 60 minutes is a sensible starting point for a daily job. Set max runtime to slightly more than timeout-minutes.

Troubleshooting

  • Check went down, no run in the Actions tab: look for a banner saying the workflow was disabled. Re-enable it on the workflow page or with gh workflow enable nightly-sync.
  • Schedule ignored after editing: the schedule trigger only applies on the default branch. Changes on a feature branch have no effect until merged.
  • Pings fail with an empty URL: the secret name does not match, or the run was triggered from a fork, where secrets are not available.

FAQ

Why did my scheduled workflow run late or not at all?

GitHub documents that schedule events can be delayed during high load, and queued runs may be dropped. An external check with a grace time tells you when a delay turns into a missed run.

Do I still need Cuckoo if GitHub emails me about failures?

GitHub notifies about failed runs, but a run that never started produces no failure notification, for example after the 60-day inactivity disable in public repositories. Cuckoo alerts on missing pings.

Can I attach the job log to the failure ping?

Yes. Write output to a file with tee, then POST it: tail -c 10000 out.log | curl -fsS -m 10 --data-binary @- "$HB/fail". The first 10 KB are stored with the ping and shown in the dashboard.

Related guides

All guides · Compare Cuckoo with other tools

Send feedback

Ideas, bugs, questions: we read every message. Or write to hello@metastic.ch.