Scheduled workflows and cron triggers Jump to heading
Not every pipeline should start because someone pushed. Nightly integration builds, weekly dependency audits, daily stale-branch cleanup, hourly mirror refreshes β all run on a clock. CI systems borrow cron syntax for this, along with its sharp edges: schedules are in UTC, the run uses whatever is on the default branch, nothing runs if the workflow file was edited on a feature branch, and every repository in an organisation scheduled for 0 0 * * * starts at the same moment and competes for runners. Some forges also quietly disable schedules on repositories with no recent activity. This page covers writing schedules that run when you expect, on the code you expect, without stampeding shared infrastructure, within CI/CD pipeline trigger mapping.
When to use this approach Jump to heading
- You need jobs that run on a timetable rather than on pushes: nightly builds, audits, reports, cleanup.
- A scheduled job ran at an unexpected time, on unexpected code, or not at all.
- Many repositories in your organisation run scheduled jobs at the same hour.
- You are building a recurring report, such as finding unported commits with git cherry, and need it to run reliably.
Step 1 β Write the schedule in UTC, deliberately off the hour Jump to heading
Cron fields are minute, hour, day of month, month and day of week. Schedules are evaluated in UTC on hosted CI, regardless of your teamβs time zone.
on:
schedule:
- cron: "17 2 * * 1-5" # 02:17 UTC, MondayβFriday
- cron: "43 6 * * 0" # 06:43 UTC, Sundays
workflow_dispatch: # let people run it by hand too Picking an odd minute is not superstition: on hosted CI, schedules at the top of the hour are the most likely to be delayed under load, and on shared self-hosted runners they all compete at once.
# Verification: convert a cron time to your local time before committing it
TZ=Europe/Copenhagen date -d '02:17 UTC' Step 2 β Know which code a scheduled run uses Jump to heading
A scheduled workflow runs the version of the workflow file on the default branch, and checks out the default branchβs tip. Editing the schedule on a feature branch changes nothing until that change merges.
jobs:
nightly:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # default branch tip
- uses: actions/checkout@v4 # or a specific branch, explicitly
with: { ref: release/2.4, path: release }
- run: make integration-test To run the same schedule against several branches β say, every supported release β use a matrix rather than separate workflows.
strategy:
matrix: { branch: [main, release/2.4, release/2.3] }
steps:
- uses: actions/checkout@v4
with: { ref: "${{ matrix.branch }}" } Step 3 β Skip runs when nothing changed Jump to heading
A nightly build of a repository that has not changed since last night wastes runners. Compare the default branchβs tip with the commit of the last successful scheduled run, and exit early when they match.
steps:
- uses: actions/checkout@v4
- id: changed
run: |
last=$(gh run list --workflow nightly.yml --event schedule --status success --limit 1 \
--json headSha --jq '.[0].headSha // ""')
if [ "$last" = "$(git rev-parse HEAD)" ]; then echo "skip=true" >> "$GITHUB_OUTPUT"; fi
env: { GH_TOKEN: "${{ github.token }}" }
- if: steps.changed.outputs.skip != 'true'
run: make nightly Jobs whose purpose is to notice external change β dependency audits, mirror refreshes, certificate expiry checks β must not skip this way, because the repository not changing is exactly when they matter.
Step 4 β Keep schedules alive on quiet repositories Jump to heading
Some hosted forges disable scheduled workflows in public repositories after a period with no repository activity, and notify the owners. For repositories whose only job is a scheduled one β a mirror, a report β that silently stops the job. Watch for disabled workflows and re-enable them.
# List scheduled workflows that are disabled across an organisation
gh repo list "$ORG" --limit 500 --json nameWithOwner --jq '.[].nameWithOwner' | while read -r r; do
gh api "repos/$r/actions/workflows" --jq '.workflows[] | select(.state=="disabled_inactivity") | "\(.path)"' 2>/dev/null |
sed "s|^|$r: |"
done gh workflow enable nightly.yml --repo "$ORG/mirror-tools" Step 5 β Stagger schedules across many repositories Jump to heading
When an organisation has hundreds of repositories with scheduled jobs, give each a stable, spread-out minute instead of letting everyone choose 0 2 * * *. A hash of the repository name gives a deterministic offset.
# Suggest a minute and hour for a repository's nightly job, between 01:00 and 04:59 UTC
repo=acme/payments-api
h=$(printf '%s' "$repo" | cksum | cut -d' ' -f1)
echo "cron: \"$((h % 60)) $((1 + (h / 60) % 4)) * * *\"" Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
How precise are scheduled runs? Jump to heading
Not very. Hosted CI schedules can start minutes late, more under heavy load. Do not use them for anything that must happen at an exact time; use them for things that must happen roughly daily.
Can a schedule pass inputs like a manual run? Jump to heading
No. Scheduled runs have no inputs. If a workflow supports both, give inputs defaults and read them with a fallback, as shown in manual dispatch workflows with inputs.
What permissions does a scheduled run get? Jump to heading
The same as a push to the default branch, including access to repository secrets. Treat scheduled workflows as privileged and review changes to them accordingly.
Related Jump to heading
- CI/CD Pipeline Trigger Mapping β the parent topic.
- Closing Stale Branches and Pull Requests β a typical scheduled job.
- Running rerere in CI Integration Builds β a nightly integration job.
- Measuring Pipeline Duration Trends β a weekly report worth scheduling.