GitHub Actions Scheduled Workflow Not Running? Debug It and Add Missed-Run Alerts
A scheduled workflow can disappear without producing a failed job. Your YAML remains unchanged, manual execution succeeds, and the Actions tab contains no run for the expected time. The database sync never happens, but there is no process exit code to inspect.
This is a scheduling failure, not necessarily an application failure. Diagnosing it requires separating event generation, workflow eligibility, runner allocation, workload execution, and completion reporting.
Monitoring it requires something outside that execution chain to notice that the expected work did not finish.
1. The Invisible Infrastructure Gap: When GitHub Crons Disappear
The frustrating version of this incident looks like this:
- Yesterday’s scheduled execution completed successfully.
- Today’s expected execution never appeared.
- Clicking Run workflow produces a successful manual run.
- No failure notification explains the missing execution.
A successful workflow_dispatch run demonstrates that the selected workflow version can execute under the conditions of that manual invocation. It does not prove that GitHub emitted the scheduled event or that the next scheduled execution will occur.
GitHub explicitly documents that scheduled events can be delayed during heavy Actions load. The start of each hour is a particularly busy period. Under sufficiently high load, some queued jobs may be dropped. GitHub recommends scheduling at a different minute to reduce contention. GitHub’s workflow troubleshooting documentation
For example:
on:
schedule:
- cron: '17 2 * * *'
Running at 02:17 instead of 02:00 avoids the documented top-of-hour hotspot. It does not establish an execution guarantee.
Distinguish those documented scheduling delays and drops from other resource constraints. A run waiting for an available runner, concurrency capacity, or approval has different evidence from a run that never appears. Calling every case “GitHub throttling” obscures the next diagnostic step.
For production routines such as database synchronization, billing reconciliation, and certificate renewal, the operational requirement is not that a cron expression exists. It is that the required result becomes available before a deadline.
2. The Diagnostic Tree: Finding the Break in the GitHub Actions Chain
Start by identifying the last observable event.
| Observation | Investigate next |
|---|---|
| No scheduled run appears | Default-branch workflow, enabled state, cron expression, actor eligibility, platform scheduling |
| Run exists but is queued or waiting | Runner availability, concurrency, approvals, account limits |
| Run starts and fails | Workload logs, credentials, dependencies, exit codes |
| Run succeeds but the expected result is absent | Validation logic, skipped work, swallowed errors, asynchronous operations |
| Required work succeeds but no heartbeat appears | Secret configuration, reporting-step condition, outbound HTTP delivery |
Inspect scheduled runs separately from manual runs
These read-only GitHub CLI commands make the distinction explicit:
REPO="OWNER/REPO"
WORKFLOW="daily-sync.yml"
gh workflow list \
--repo "$REPO" \
--all \
--limit 100 \
--json id,name,path,state
gh run list \
--repo "$REPO" \
--workflow "$WORKFLOW" \
--event schedule \
--all \
--limit 50 \
--json databaseId,createdAt,event,status,conclusion,url
The --all option includes disabled workflows. The event filter excludes manual dispatches from the second command. GitHub CLI workflow-list reference, run-list reference
An empty result is not proof of a platform drop. Confirm repository access, workflow identity, command errors, filters, and available history. Increase the limit when investigating older occurrences.
Then work through the following checklist.
Check 1: Is the workflow on the actual default branch?
The scheduled workflow must exist under .github/workflows/ on the repository’s default branch. A feature-branch copy does not receive scheduled events. Do not assume the default branch is named main or master. GitHub’s trigger troubleshooting guidance
Inspect that branch directly:
REPO="OWNER/REPO"
WORKFLOW="daily-sync.yml"
DEFAULT_BRANCH="$(
gh repo view "$REPO" \
--json defaultBranchRef \
--jq '.defaultBranchRef.name'
)"
gh workflow view "$WORKFLOW" \
--repo "$REPO" \
--ref "$DEFAULT_BRANCH" \
--yaml
Compare the deployed YAML with the version used by the successful manual run. A feature-branch fix does not change the default-branch schedule until it is merged.
Check 2: Was the workflow disabled after repository inactivity?
In public repositories, GitHub automatically disables scheduled workflows after 60 days without repository activity. This is not a blanket inactivity rule for all private repositories. Check the workflow’s enabled state rather than inferring it from its presence in the Actions tab. GitHub’s schedule documentation
Also check whether someone disabled the workflow manually or changed repository Actions settings.
After re-enabling it, verify a real scheduled execution. A successful manual run alone leaves the original scheduling question unanswered.
Check 3: Is the cron expression valid, and is the timezone correct?
GitHub schedules use five-field POSIX cron syntax:
minute hour day-of-month month day-of-week
For example:
on:
schedule:
- cron: '17 2 * * *'
workflow_dispatch:
Without an explicit timezone, this means 02:17 UTC every day. Current GitHub documentation also supports an optional IANA timezone field. Match that timezone in your monitor if you use it.
Do not add a seconds field or use shortcuts such as @daily. GitHub’s minimum scheduling interval is five minutes. GitHub’s cron syntax reference
Keep the expression quoted. Review the next expected occurrence in the intended timezone, especially after a schedule change.
Check 4: Is the schedule’s actor still eligible?
GitHub documents that schedules can stop when their associated actor account is suspended, deleted, or deprovisioned, even while manual runs still work. An authorized user with write access can commit a cron change to re-establish the schedule under an active actor. GitHub’s actor troubleshooting guidance
Account deprovisioning deserves particular attention in Enterprise Managed Users environments. Removing organization membership alone is not equivalent to disabling the account. Scheduled-workflow actor requirements
Keep actor eligibility separate from token expiration:
- A disabled schedule actor can prevent invocation.
- An expired PAT, cloud credential, or API secret used by the workload usually causes an execution-time failure.
GITHUB_TOKENis generated for each job. It is not a persistent cron credential that you periodically renew. GitHub’s token documentation
3. The Monitoring Limitation: Why Native Actions Failures Cannot Catch Missed Runs
A failure notification needs an observed failure.
If the scheduled workflow never initializes, there may be no run record, no allocated runner, and no workload logs. A notification tied to that run cannot announce its failure because the expected execution never became an observable run.
This limitation is broader than a container failing to boot. GitHub-hosted Ubuntu jobs normally execute on virtual machines unless you explicitly configure containers, and failures can occur outside individual shell steps.
The important distinction is:
Execution monitoring:
An observed run encountered a failure.
Expectation monitoring:
Required work did not report completion before its deadline.
Keep native failure notifications. They can provide prompt, detailed information about runs that exist. Add an independent expectation check for runs that are absent, delayed beyond tolerance, cancelled, or unable to report completion.
Consider a daily database sync:
02:17 Expected schedule
02:25 Normal completion time
03:02 Completion deadline, including grace
If no success signal exists by the deadline, someone needs to investigate regardless of whether GitHub recorded a failed step.
Polling GitHub from another workflow in the same scheduling system also shares part of the failure domain. An independently operated monitor provides a separate place to maintain that expectation.
4. Implementing a Robust Heartbeat Step with PingCron
Send the heartbeat after the required work and its validation finish.
Do not send it immediately after checkout, after submitting an asynchronous task, or before verifying the destination data.
Store the heartbeat URL as a secret
Create a PingCron monitor and copy its unique success URL.
In the repository, open:
Settings → Secrets and variables → Actions → New repository secret
Name the secret:
PINGCRON_URL
Alternatively, use the GitHub CLI’s interactive prompt:
gh secret set PINGCRON_URL --repo OWNER/REPO
Pass the secret into the reporting step through an environment variable. This keeps the value out of the workflow file and avoids interpolating it directly into shell source. GitHub’s secrets documentation
Complete workflow template
Save this as .github/workflows/daily-sync.yml on the default branch.
This template assumes your repository contains:
scripts/daily-sync.sh, which waits for the actual data changes to finish.scripts/verify-daily-sync.sh, which checks the resulting destination state and exits nonzero when validation fails.
Replace those two commands with your existing workload and verification commands. Configure their dependencies and credentials before those steps.
name: Daily production sync
on:
schedule:
# 02:17 UTC daily. Avoid the top-of-hour scheduling hotspot.
- cron: '17 2 * * *'
workflow_dispatch:
permissions:
contents: read
# Prevent overlapping executions without cancelling active work.
concurrency:
group: daily-production-sync
cancel-in-progress: false
jobs:
sync:
# Prevent a manual feature-branch run from reporting production success.
if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
runs-on: ubuntu-24.04
timeout-minutes: 20
defaults:
run:
shell: bash
steps:
- name: Check out repository
# actions/checkout v7.0.1, pinned to its release commit.
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- name: Run daily sync
run: |
bash -euo pipefail scripts/daily-sync.sh
- name: Verify destination data
run: |
bash -euo pipefail scripts/verify-daily-sync.sh
- name: Report Success Heartbeat
if: success()
timeout-minutes: 2
env:
PINGCRON_URL: ${{ secrets.PINGCRON_URL }}
run: |
set -euo pipefail
case "${PINGCRON_URL:-}" in
https://?*) ;;
*)
printf '%s\n' \
'::error::PINGCRON_URL is missing or does not use HTTPS.'
exit 1
;;
esac
if http_code="$(
curl \
--silent \
--fail \
--request POST \
--output /dev/null \
--write-out '%{http_code}' \
--proto '=https' \
--connect-timeout 5 \
--max-time 10 \
--retry 3 \
--retry-delay 5 \
--retry-max-time 45 \
"$PINGCRON_URL" 2>/dev/null
)"; then
case "$http_code" in
2??)
printf '%s\n' 'Heartbeat accepted.'
;;
*)
printf '%s\n' \
'::error::Heartbeat returned an unexpected HTTP status.'
exit 1
;;
esac
else
printf '%s\n' \
'::error::Workload completed, but heartbeat delivery failed.'
exit 1
fi
The checkout reference is pinned to the official v7.0.1 release commit. Review dependency updates through your normal maintenance process.
What if: success() actually guarantees
GitHub’s success() status function evaluates previous step success. Used here, it prevents the reporting step from running after an unhandled failure in the sync or verification steps. GitHub’s status-function documentation
The resulting behavior is:
| Condition | Success heartbeat |
|---|---|
| Sync and verification succeed | Sent |
| Sync fails | Skipped |
| Verification fails | Skipped |
| Workflow never starts | Never reached |
| Execution is cancelled before reporting | Not sent |
| HTTP delivery fails | Attempted, but receipt must be checked |
success() does not establish that every previous operation completed flawlessly. It evaluates reported status. A script that catches an error and exits zero can still produce a false success.
For critical steps:
- Avoid
continue-on-error: true. - Do not suppress failures with
|| true. - Wait for asynchronous work to finish.
- Verify the resulting business state.
- Do not conditionally skip required work and then report success unconditionally.
For a database sync, verification might check the destination watermark, expected partition, reconciliation totals, or another invariant tied to the current batch.
For certificate renewal, verify the certificate actually served by the destination, not merely the successful submission of a renewal request.
Why the HTTP request needs more than retries
A bare curl command can exit successfully after receiving an HTTP error response. The example adds --fail, checks for a 2xx status, and bounds connection and request time.
It also avoids following redirects and suppresses response bodies and verbose diagnostics that could expose the private URL. Retry behavior and time limits are documented in the curl manual.
These retries apply only to heartbeat delivery. They do not rerun the sync.
In this template, exhausted reporting attempts fail the reporting step. Inspect which step failed before rerunning the job: the data changes may already have completed. Production mutations should be idempotent or otherwise safe to resume.
Multiple jobs require a completion barrier
A heartbeat at the end of one job says nothing about another independent job.
For workflows with several required jobs, use either:
- Separate monitors for independently scheduled outcomes.
- A final reporting job with explicit
needsdependencies and conditions requiring every required job’s result to equalsuccess.
Do not attach one shared success heartbeat to every matrix branch. One successful branch could hide another branch’s failure.
Also distinguish workload completion from GitHub’s final run conclusion. Post-job cleanup may execute after the last ordinary step. A heartbeat marks completion of the work you placed before it.
5. Setting Up the Monitor and Validating Your Protection Loop
Configure the monitor around the same expected occurrence as the workflow.
For the example:
| Setting | Value |
|---|---|
| Monitor name | production-daily-sync |
| Cron expression | 17 2 * * * |
| Timezone | UTC |
| Initial grace period | 45 minutes |
| Alert destination | Responsible team’s Slack, Discord, or Email destination |
PingCron supports success check-ins through POST and alerts when an expected check-in remains missing beyond its grace period. Verify both the receipt and the configured notification destination. PingCron’s integration guidance
Allow for scheduling delay and execution time
A GitHub Actions schedule needs a tolerance budget that includes more than application runtime:
Grace period >=
scheduling delay
+ runner and concurrency waiting time
+ workload execution
+ result verification
+ heartbeat delivery allowance
+ operating margin
For a daily workflow, 30 to 60 minutes can be a reasonable initial range when the business deadline permits it. It is not a mandatory platform value, a measured universal delay, or an upper bound promised by GitHub.
For example:
Expected schedule: 02:17 UTC
Allowed scheduling delay: 20 minutes
Execution budget: 20 minutes
Reporting and margin: 5 minutes
Grace period: 45 minutes
The missing check-in becomes overdue around 03:02 UTC. Detection and notification processing can add further delay.
The workflow’s timeout-minutes: 20 limits execution after the job starts. It does not reserve a runner or bound how long the schedule takes to produce a running job.
Tune grace using observed behavior and the actual completion deadline. If the business requires completion within five minutes, extending grace to an hour merely hides a requirement the scheduler cannot reliably satisfy. Consider a scheduling system with guarantees appropriate to that workload.
Test 1: Verify the first heartbeat manually
Before dispatching production work, confirm that a manual execution is safe and idempotent. Otherwise, use a staging copy.
Run the workflow from the default branch and verify:
- The workload completed.
- Its output validation passed.
- The heartbeat step received an accepted HTTP response.
- The corresponding receipt appears in PingCron.
- The configured alert-destination test arrives.
This validates installation and routing. It does not validate scheduled invocation.
The example also sends a heartbeat on manual runs. During a missing-run investigation, an ad hoc dispatch can produce a new receipt and obscure the absence you are testing. Use a separate test monitor for repeated experiments, or restrict production reporting to scheduled events after onboarding.
Test 2: Observe an actual scheduled run
Wait for the next scheduled occurrence.
Confirm that its event type is schedule, inspect its workload result, and match it to the new heartbeat receipt. Keep this evidence separate from the earlier manual test.
Also confirm that the monitor schedule remained synchronized with the workflow after any cron changes.
Test 3: Fail work before the heartbeat
Use an isolated workflow and monitor. Temporarily insert this step before the reporting step:
- name: Intentional monitoring test failure
run: exit 1
Verify that:
- The test execution fails.
- The success-heartbeat step is skipped.
- No success receipt appears.
- An external alert arrives after the expected occurrence and grace period.
Do not run a successful manual dispatch against that monitor while waiting for the absence alert.
Test 4: Prevent the test workflow from starting
Disable only the isolated test workflow, leaving its PingCron monitor active.
Wait through the next expected occurrence and grace period. Confirm that the external notification arrives even though no workload step executed.
This validates the failure mode that a normal failed-run notification cannot cover.
Re-enable the test workflow afterward and confirm that subsequent scheduled executions produce receipts.
Investigate alerts without assuming the cause
A missing heartbeat establishes missing completion evidence. It does not identify the broken component.
Use the diagnostic tree:
No run?
Inspect schedule eligibility and platform scheduling.
Run queued or waiting?
Inspect capacity, concurrency, and approvals.
Workload failed?
Inspect logs and dependencies.
Workload succeeded?
Inspect validation and heartbeat delivery.
Retain native failure notifications alongside the external monitor. They answer different operational questions.
6. Summary: Give the Schedule an Independent Completion Check
A green manual run does not validate the next scheduled invocation. A valid cron expression does not guarantee that GitHub will start the workflow on time. A successful shell exit does not establish that the expected data or certificate reached its destination.
Validate the result, report success afterward, and maintain the completion deadline outside the scheduler that runs the work.
Start with your most critical recurring workflow. Create a free PingCron monitor, add the final success heartbeat, and verify a real scheduled receipt followed by a controlled missed-run alert.
Related monitoring guides
For configuration and setup, see the GitHub Actions monitoring guide.