You set cron: '0 9 * * *', and the run shows up at 09:47. Or not at all. It is one of the most common
complaints about GitHub Actions, and it is not a bug in your workflow. The fix is to keep the workflow exactly as it is
and let something punctual start it.
In short
- 1Add
workflow_dispatch:to the workflow'son:section. - 2Create a fine-grained token with Actions: Read and write on that repository.
- 3Have a scheduler send
POSTto the dispatch API at the times you want.
Why scheduled workflows run late
A schedule: trigger does not start your workflow at the minute you wrote. It puts a run in GitHub's queue,
together with every other scheduled workflow on GitHub. GitHub's own documentation is clear about what that means:
- Runs are delayed under high load. The start of every hour is the busiest moment, because that is when most people schedule things.
- Some runs are dropped. When the load is high enough, queued scheduled runs can be skipped, and nothing tells you.
- Every 5 minutes is the limit. A shorter interval in the cron expression is not supported.
- Quiet public repositories are switched off. After 60 days without activity, GitHub disables scheduled workflows in public repositories.
For a nightly cleanup, an hour late is fine. For a report that must be in someone's inbox at 09:00, a sync that other systems wait for, or anything you promised a customer, it is not.
The usual workarounds
- Pick an odd minute, such as
'17 9 * * *'instead of'0 9 * * *'. It helps, because you avoid the busiest moment, but runs can still be late or dropped. - Run more often and check inside the job whether it is time yet. It wastes Actions minutes and still depends on the same queue.
- Keep-alive commits against the 60-day rule. They work, and they fill your history with noise.
All three keep the schedule inside GitHub. The reliable fix is to move the clock outside and keep only the work inside.
Step 1: Allow the workflow to be dispatched
Add the workflow_dispatch event to the workflow. It lets the workflow be started on request: from the
Actions tab, from the GitHub CLI, or through the API. The workflow file must be on your default branch.
name: Nightly report
on:
workflow_dispatch:
# One run at a time, even if a start ever overlaps a slow run.
concurrency:
group: nightly-report
cancel-in-progress: false
jobs:
report:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./scripts/nightly-report.sh
Leave the old schedule: block in place for now. It goes in step 4, once the new trigger works.
Step 2: Create a token
The API call needs a token that may start workflows in that one repository and do nothing else. A fine-grained personal access token is the simplest choice:
- In GitHub, open Settings › Developer settings › Personal access tokens › Fine-grained tokens and choose Generate new token.
- Choose the owner of the repository as Resource owner, and under Repository access select only that repository.
- Under Repository permissions, set Actions to Read and write. Leave everything else as it is.
- Pick an expiry date, generate the token and copy it. GitHub shows it only once.
For a repository owned by an organization, the organization may have to approve fine-grained tokens first. Put the expiry date in your calendar: an expired token is the most common reason a dispatch suddenly fails.
Step 3: Call the API on a schedule
Starting the workflow is one POST request. Try it once from your terminal, with your own owner,
repository, workflow file and token:
curl -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "X-GitHub-Api-Version: 2026-03-10" \
https://api.github.com/repos/OWNER/REPO/actions/workflows/nightly.yml/dispatches \
-d '{"ref":"main"}'
No output means it worked: GitHub answers 204 No Content, and a new run appears in the Actions tab.
Now the same request has to happen on a schedule, from something that runs on time.
With CronEngine
Create a job with these settings:
- URL
- https://api.github.com/
repos/ OWNER/ REPO/ actions/ workflows/ nightly.yml/ dispatches - Method
- POST
- Authentication
- Bearer token, with the token from step 2
- Content type
- application/json
- Body
- {"ref": "main"}
- Schedule
- The cron expression you had in the workflow, in your own timezone
Under custom headers, add:
Accept: application/vnd.github+json X-GitHub-Api-Version: 2026-03-10
Press Test in the form. A 204 answer and a new run in the Actions tab mean it is set up. From then on CronEngine starts the workflow on schedule, keeps a history of every call, and alerts you when GitHub refuses one, for example because the token expired. The setup is also on the integrations page.
Without a cron service
Any server with a crontab can send the same curl command. Keep the token out of the crontab line itself,
for example in a file only that user can read, and keep in mind that a crontab will not tell you when a call fails.
Step 4: Remove the old schedule
Once a few runs have started on time, delete the schedule: block from the workflow. With both
triggers in place, the workflow runs twice: once on time, and once whenever GitHub's queue gets to it.
on: workflow_dispatch: # schedule: # removed: the run is now started from outside # - cron: '0 9 * * *'
Check that it works
In the Actions tab, runs started through the API show workflow_dispatch as their event, and the run starts
within seconds of the call. If the call fails, GitHub's answer tells you why:
| Answer | What it usually means |
|---|---|
| 204 | The run was created. All good. |
| 401 | The token is wrong, revoked or expired. |
| 403 | The token lacks Actions write access, or the organization has not approved it. |
| 404 | A typo in the owner, repository or workflow file name, or the token cannot see that repository. |
| 422 | The workflow has no workflow_dispatch trigger on the default branch, the ref does not exist, or an input is invalid. |
Passing inputs
A dispatched workflow can take inputs, so one workflow can serve several schedules. Declare them in the workflow:
on:
workflow_dispatch:
inputs:
environment:
description: Where to run
required: true
default: staging
Then send them in the body, next to the ref. Values are sent as strings, and the workflow reads them as
${{ inputs.environment }}:
{
"ref": "main",
"inputs": { "environment": "production" }
}
Would you rather keep GitHub's schedule?
If a late run is fine but a missing one is not, keep the schedule: trigger and add a heartbeat instead:
the workflow pings a URL when it finishes, and you get an alert when the ping does not arrive in time. The
heartbeat recipe for GitHub Actions has the YAML.
Questions
Why does my GitHub Actions cron run late?
The schedule trigger is queued with everybody else's. GitHub documents that scheduled runs can be delayed when Actions is under high load, which is common at the start of every hour, and that some queued runs can be dropped when the load is high enough.
Does calling the dispatch API cost anything?
The API call is free. The workflow uses Actions minutes exactly as a scheduled run would, so your bill does not change.
Does the workflow have to be on the default branch?
Yes. GitHub only accepts a dispatch for a workflow file that exists on the default branch. The ref in the request then picks which branch or tag the run uses.
Can I run a workflow more often than every 5 minutes?
The schedule trigger stops at every 5 minutes, but the dispatch API has no such minimum. In practice you are limited by your scheduler and by GitHub's API rate limits, which a schedule of every minute stays far below.
Will GitHub still disable the workflow after 60 days?
That rule is about the schedule trigger in public repositories with no recent activity. Once the workflow no longer has a schedule trigger, there is no schedule for GitHub to switch off.
Is it safe to store the token in a cron service?
Give the token as little as possible: one repository and only Actions read and write. CronEngine stores it encrypted and never shows it again after saving. If it ever leaks, revoke it in GitHub and create a new one.
Spotted a mistake, or something that behaves differently for you? Tell us and we will update the guide.