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
Why off-the-hour schedules start soonerAn illustrative organisation where many repositories schedule jobs. At the top of the hour most of them start together and wait for runners; a few minutes past, almost nothing else is scheduled and jobs start immediately.scheduled jobs starting at each minute (illustrative):00140:1522:173:432hosted CI also delays top-of-hour schedules under load β€” odd minutes avoid both

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 }}" }
Why didn't my schedule run as expected?If the schedule change is only on a feature branch, it is not active yet. If the repository has had no activity for a long period, the forge may have disabled scheduled workflows. If the time looks wrong, it was evaluated in UTC. If runs start late, the hour is congested.What went wrong with the schedule?never ranNot on default branchmerge the workflowstopped runningRepo inactivere-enable, add activityran at wrong timeUTC vs localconvert the timelate starts are a separate problem: move off the top of the hour

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)) * * *\""
Everyone at midnight against hashed offsetsWhen every repository schedules its nightly job for midnight UTC, runners are swamped for an hour and jobs queue. Spreading jobs by a hash of the repository name across a few hours keeps the load flat and every job starts promptly.All at 0 0 * * *Hashed minute and hourrunner loadone spikeflat over hoursqueue waitlong at 00:00near zeropredictabilitysame time everywherestable per repothe hash keeps each repo's time stable, so people can still learn when it runs

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.