Default GitHub Actions cron workflows have no guard against duplicate runs. If a daily workflow occasionally runs long — network latency, a slow upstream API, a build that takes twice as long — the next scheduled trigger fires while the first is still running. If you trigger a workflow manually while the cron is active, you get two concurrent runs reading and writing the same output files. The concurrency: key solves this, but which pattern fits depends on whether canceling an in-flight run is safe.
Pattern 1: cancel-in-progress for stateless ETL
For read-only or idempotent jobs — daily model refreshes, content scrapes, market listening snapshots — the new run has fresher data, so canceling the old one is correct.
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
The group key ${{ github.workflow }} uses the workflow's name as the concurrency group. All runs of this workflow compete. A new trigger immediately cancels any running instance.
This is what I use for the HuggingFace ETL, the Steam game directory refresh, and the GitHub activity scraper. Each pulls from an external API, writes JSON to the repo, and commits. If the previous run is still mid-pull, canceling it wastes a few seconds of API time but loses nothing — the new run will get fresher data anyway.
One behavior worth knowing: cancel-in-progress: true cancels all running instances in the group, not just the oldest. If you manually trigger a run while a cron is mid-flight, the cron gets canceled and the manual run proceeds. For my ETL jobs this is the correct priority ordering. For jobs where manual triggers are one-off debugging runs that should not interrupt scheduled work, this is the wrong default.
Pattern 2: Separate groups for cron vs. manual triggers
When manual triggers and scheduled triggers should coexist without interfering:
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}
cancel-in-progress: false
The group key now includes github.event_name. A schedule trigger and a workflow_dispatch trigger get separate concurrency groups. They don't cancel each other. A manual debug run can proceed while the daily cron is also running.
cancel-in-progress: false means new triggers queue behind running ones within their group, rather than canceling them. For daily crons, the queue stays bounded naturally — a second cron trigger won't arrive until tomorrow.
The tradeoff: if the workflow runs long two days in a row, the second day's run queues behind the first until it finishes. This is fine for content refreshes that don't depend on real-time freshness. For anything time-sensitive, queuing degrades gracefully but still means the run is late.
GitHub Actions cron scheduling patterns in a monorepo covers the scheduling side of multi-workflow timing. Concurrency on top of it controls what happens when crons from separate workflows compete for the same runner or write the same files.
Pattern 3: Sequential runs without cancellation, for stateful writes
For workflows where every run must complete in order and none should be dropped:
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
This serializes all triggers. Each new run waits for the previous one to finish. I use this for the Bluesky post queue: each run reads the JSONL queue, posts one item, marks it posted, and commits the update file. Two concurrent runs reading the same "next unposted" entry would post the same item twice. Sequential concurrency means each run sees the committed state from the previous run.
The silent limit: GitHub queues at most one pending run per concurrency group. If three triggers fire while one run is active, only the most recent pending run survives — the middle one is dropped silently. For a daily cron this almost never fires. For a high-frequency cron (every 5 or 10 minutes), you lose runs with no log entry.
Three approaches I use to catch silent failures in a cron-heavy GitHub Actions pipeline — this is the same category as a cron that looks healthy in the UI but skipped an execution. Writing a run counter to a file and comparing it downstream is one detection pattern.
The one behavior I got wrong initially
I put concurrency: at the workflow level and assumed it applied to all jobs uniformly. It does. But you can also set concurrency: at the job level, which creates per-job groups instead of per-workflow groups.
For a build-and-deploy workflow with four jobs, workflow-level concurrency serializes the entire workflow — including the build steps that are safe to run in parallel. If I'm deploying three sites that share a build step and each site's deploy must be sequential, the right structure is:
jobs:
build:
# no concurrency — builds can run in parallel
...
deploy:
needs: build
concurrency:
group: deploy-${{ matrix.site }}
cancel-in-progress: false
...
This lets builds proceed in parallel while keeping deploys for the same site serialized. I had workflow-level concurrency on the deploy workflow for about two months before noticing that the build jobs were being unnecessarily queued. The builds themselves are idempotent and the serialization was pure overhead.
When I skip concurrency groups entirely
For workflows that run once a week on a slow cadence and don't write shared state, I leave the concurrency: key out. Adding it introduces a subtle failure mode: if you rename the workflow file, github.workflow changes, and the concurrency group name changes with it. Any queued run under the old name no longer competes with runs under the new name — you can briefly get duplicates during a rename. Transient and harmless in most cases, but unnecessary to introduce for a weekly workflow that doesn't need serialization in the first place. The GitHub Actions concurrency reference covers the full key set, including how concurrency interacts with matrix jobs — a combination I haven't needed yet.
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)