DEV Community

Cover image for Four GitHub Actions cron scheduling patterns I use in a five-workflow monorepo
MORINAGA
MORINAGA

Posted on Edited on

Four GitHub Actions cron scheduling patterns I use in a five-workflow monorepo

I run seven scheduled GitHub Actions workflows in the same monorepo: content refresh (nightly), Bluesky queue post (daily), trends fetch (daily), YouTube analytics polling (daily), a weekly digest, a weekly Lighthouse run, and a monthly Wayback snapshot. Article publishing and the YouTube uploads are push-triggered rather than scheduled. After about two months of running them, I have a short list of scheduling practices that reduce noise and make the timing predictable.

None of this is novel if you've run Actions workflows at scale. But I picked up each of these through direct pain, and I've seen enough repos that skip them that it's worth writing down.

Off-minute scheduling to avoid queue congestion

The GitHub Actions documentation doesn't mention this directly, but schedulers that fire at 0 * * * * or 0 0 * * * compete with every other workflow that picked the obvious top-of-hour slot. During high-contention windows — midnight UTC, Monday morning UTC — the queue can delay a workflow by 30 to 90 minutes, sometimes longer.

The fix is simple: pick a non-round minute. 37 7 * * * is a real cron entry in one of my workflows. The odd minute doesn't matter functionally — the workflow doesn't care whether it starts at 07:00 or 07:37 UTC. What matters is avoiding the cluster of workflows that fire at :00 and :30.

I also split workflows across different hours rather than staggering them all in the same window. In UTC: analytics polling at 00:30, content refresh at 01:23, Bluesky post at 07:37, trends fetch at 21:45. The analytics one is still sitting on a round half-hour — I haven't moved it yet. The spacing is deliberate: these workflows all push to main, and I don't want two push-triggered workflows racing on each other's commits.

Random start delay inside the workflow

Off-minute scheduling reduces external queue contention. A random in-workflow delay addresses a different problem: bot-pattern timing detection. GitHub's documentation on scheduled events notes that workflows may run later than scheduled during periods of high load — another reason not to depend on exact timing.

If your workflow does something external — posts to Bluesky, calls an API, sends a webhook — firing at a precise clock time every day creates a recognizable fingerprint. The delay is 0–300 seconds in my Bluesky queue workflow:

- name: Random start delay (0-5 min) to avoid bot-pattern timing
  run: |
    DELAY=$(( RANDOM % 300 ))
    echo "Sleeping ${DELAY}s before posting"
    sleep $DELAY
Enter fullscreen mode Exit fullscreen mode

Five minutes of variance is enough to break the pattern without meaningfully affecting when the post goes out. Bluesky doesn't care whether a post arrives at 17:00:00 or 17:04:32 JST.

The same pattern applies to anything with rate limits or bot detection: ETL pipelines that hit public APIs, analytics polling, anything that touches a platform with usage monitoring. Uniform exact-time requests look like automation even when they're not trying to be sneaky. The jitter signals that you've thought about it.

Skip flags via commit message

When a workflow's cron fires right after a commit that already did its job, the cron run is redundant. Commit message skip flags prevent that:

jobs:
  post:
    if: "!contains(github.event.head_commit.message, '[skip bluesky-queue]')"
Enter fullscreen mode Exit fullscreen mode

Whenever my Bluesky queue script commits its own output (marking a queued post as sent), it appends [skip bluesky-queue] to the commit message. The next cron trigger sees that flag and skips the job entirely.

This avoids the situation where a workflow fires, does nothing meaningful because the previous run already handled everything, and emits a green run that gives no information. Green runs that do nothing are worse than it sounds — they hide the baseline, making it harder to notice when a workflow that should run is silently skipped for the wrong reason.

Only two workflows actually gate themselves this way today — the Bluesky queue post and the article publish. The content refresh doesn't, because I want it to run regardless of commit message: freshness is the point.

Workflow Skip flag Rationale
Bluesky queue checks [skip bluesky-queue] Avoid double-post on self-commit
Article publish checks [skip publish-articles] Avoid re-publish on its own publish/OG commit
Trends fetch emits [skip publish-articles] Its daily snapshot commit shouldn't wake the publisher
Content refresh (none) Freshness always wanted

Emitting and checking are separate halves, and the flag is worth naming after the workflow it silences rather than the workflow that writes it: [skip publish-articles] gets appended by two different workflows, because both push commits that shouldn't trigger a publish run.

cancel-in-progress false by default

The default instinct is to set cancel-in-progress: true — if a new push comes in while the workflow is running, kill the old one and start fresh. For most CI workflows, that's right.

For scheduled workflows that push their own commits, it's wrong. The content refresh workflow takes 4–6 minutes. If a new push comes in two minutes into the run (from a concurrent article publish, say), canceling the refresh mid-run leaves the database in a partial update state. The next cron fires the following day. That's 24 hours of stale data from a cancellation that gained nothing.

concurrency:
  group: refresh-content
  cancel-in-progress: false
Enter fullscreen mode Exit fullscreen mode

With cancel-in-progress: false, the second trigger queues and waits. When the refresh completes, the queued run starts, finds nothing to do (data already fresh), and exits quickly. No partial state, no 24-hour gap.

The tradeoff is that you can accumulate a small queue during a burst of pushes. In practice, this hasn't been a problem: the queue drains within minutes, and the cost of a queued idle run (fast exit) is much lower than the cost of a mid-run cancellation.

The rule I use: cancel-in-progress: true for workflows that read-only (tests, linters, builds that don't write). cancel-in-progress: false for workflows that write back to the repo or an external system. (That was the rule as of June 2026; at the end of that month I flipped content refresh, trends fetch, and the analytics pollers to cancel-in-progress: true to cut Actions minutes, so the repo no longer follows it consistently.)


Two months in, these four patterns have eliminated most of the scheduling noise I was dealing with. The main thing I'd add on a larger project is a health check workflow that verifies all scheduled workflows ran within their expected windows — right now I catch missed runs by noticing stale content, which is a lagging indicator. (Written mid-June 2026; I added exactly that watchdog, pipeline-health.yml, on June 21.)

Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.

Top comments (0)