I run five scheduled GitHub Actions workflows in the same monorepo: content refresh (nightly), Bluesky queue post (daily), article publish (push-triggered but with schedule fallbacks), YouTube script generation (daily), and analytics polling (daily). After three 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. Content refresh at 07:37, analytics at 08:15, Bluesky post at 16:37. 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
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]')"
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.
I use this pattern in four of my five scheduled workflows. The exception is the content refresh workflow, which I want to run regardless of commit message because freshness is the point.
| Workflow | Skip flag | Rationale |
|---|---|---|
| Bluesky queue | [skip bluesky-queue] |
Avoid double-post on self-commit |
| Bluesky OG | [skip bluesky-queue] |
Same skip group |
| YT publish | [skip yt-publish] |
Avoid re-upload on upload commit |
| Article publish | [skip publish-articles] |
Avoid re-publish on publish commit |
| Content refresh | (none) | Freshness always wanted |
The skip group naming matters: [skip bluesky-queue] controls two separate workflows because both should stop when a Bluesky operation just ran.
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
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.
Three 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.
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)