Finds the GitHub Actions concurrency settings that cancel the deploy they were meant to protect — or silently drop the run waiting behind it.
concurrency:
group: deploy-${{ github.head_ref }} # empty on push → every branch shares "deploy-"
cancel-in-progress: true # → a push anywhere cancels the deploy
jobs:
ship:
steps:
- run: terraform apply -auto-approve # killed mid-apply: the state lock stays heldNothing about this workflow fails. It parses, it runs, actionlint is happy,
and the run it kills says only "Canceled" — which reads like somebody's
choice. preempted reads .github/workflows, resolves every group:
expression per trigger — what does ${{ github.head_ref }} actually hold
on a push? — and reports the deploys that get killed mid-run, the groups
that collide across workflows, and the releases silently evicted from the
single pending slot. Offline, deterministic, zero dependencies.
$ npx preempted
9 workflows · 7 concurrency groups · 5 deploy jobs · 1 protected · 4 exposed
██████ 20%
error cancelled-mid-deploy .github/workflows/deploy.yml:7
deploy.yml sets cancel-in-progress over a job that deploys
so: push twice in a minute and the first deploy is killed wherever it happens
to be — a killed apply holds its state lock and leaves resources
half-created — the next run fails on the lock, and someone force-unlocks
state at 2am
fix: set cancel-in-progress: false so a newer run waits instead of killing
this one; add queue: max if more than one run may queue up
.github/workflows/deploy.yml:18 — job ship: terraform applyA concurrency block is two different safety devices, and each one fails
silently when configured as the other:
- For CI, you want
cancel-in-progress: true— a superseded test run has no value. Every guide says so, and so does every linter that checks concurrency at all. - For a deploy, that same line is the bug. A cancelled run gets SIGINT,
then SIGKILL ~7.5 s later — wherever
terraform applyhappens to be. The state lock stays held. People report exactly this: a cancelled Actions run, a permanently locked state file, and a manual DynamoDB intervention as the "fix".
And the failure modes hide in expression evaluation, not in syntax:
| What you wrote | What it means on some trigger |
|---|---|
group: ci-${{ github.head_ref }} with push + pull_request triggers |
on push, head_ref is empty — every branch shares the group ci-, and any push cancels any other branch's run |
group: deploy-${{ github.ref }} in two workflow files |
one group. The workflow that cancels kills the one that deploys |
group: x-${{ github.run_id }} |
unique per run — the concurrency block is decoration; two merges deploy at once |
group: release on push: tags: with the default queue |
GitHub keeps one pending run per group. Push three tags and the middle version is silently never published |
${{ github.workflow }}-… in both a caller and its reusable workflow |
in the called file, github.workflow is the caller's name — one group, and GitHub cancels with "deadlock detected" |
None of these produce an error, an annotation, or a red X. That last gap is a
known, open feature request in actionlint; zizmor's
concurrency-limits audit checks that a concurrency block is present — and
its blanket remediation, cancel-in-progress: true, is precisely the wrong
advice for the deploy case above.
Eight rules, two failure directions.
Direction one — the group cancels what it must not:
| Rule | Finds |
|---|---|
cancelled-mid-deploy |
cancel-in-progress over a job that runs terraform apply, a migration, a rollout, a publish — with the consequence named per category |
crossfire |
two workflows whose groups resolve to the same string at runtime, where one cancels — including the push × schedule meeting on the default branch |
group-collapses |
an expression that is empty on some trigger, collapsing the group to a constant every branch shares |
group-goes-empty |
a group that is entirely empty on some trigger — GitHub rejects the run at queue time |
self-deadlock |
a job or called reusable workflow waiting on the group its own run holds — GitHub cancels with "deadlock detected" |
Direction two — the group protects nothing:
| Rule | Finds |
|---|---|
never-collides |
github.run_id / run_number in the group (a no-op), or github.sha serialising per commit when two merges are two commits |
pending-dropped |
a tag/release/dispatch workflow with the default single pending slot and a group that cannot tell two triggers apart — the middle of three releases is silently cancelled |
deploy-unserialized |
a branch-triggered deploy with no concurrency at all — two merges race |
Every finding names the trigger, the resolved value of the group on that
trigger, the consequence, and the exact YAML to write instead. Severities
track exploitability: cancel-in-progress: true on plain CI is silent —
that is the recommended pattern, not a finding.
npm install --save-dev preempted # or: npx preemptedNode ≥ 20.10. Zero runtime dependencies.
preempted # scan the current repository
preempted path/to/repo # scan another checkout
preempted --json # machine-readable report
preempted --verbose # list every group and deploy job
preempted --fail-on warning # gate on warnings too (default: error)Exit codes: 0 clean · 1 findings at or above the threshold · 2 could
not run. In CI:
- run: npx preempted --fail-on warning- Parse every file in
.github/workflowswith a position-tracking, zero-dependency YAML parser (no anchors/aliases — a workflow using them is reported as unanalysable rather than analysed wrongly). - Resolve each
group:template per trigger, symbolically: literal text survives, and each${{ … }}hole becomes a kind of value — a branch ref, a PR number, a per-run id, the empty string.||fallback chains are followed;github.workflowresolves to the actualname:value, which is how two same-named workflows end up in one group. - Compare resolutions across workflows and events. Two groups collide only when every segment provably unifies — ref value spaces must intersect (a branch and a tag never do; a push and a scheduled run meet on the default branch).
- Weigh each finding against what the covered jobs actually do: a
two-signal deploy detector (the
environment:key, plus a command table spanning terraform/pulumi, six migration runners, kubectl/helm/fly/…, npm publish/twine/cargo, s3 sync) — matched only after string literals, comments and here-documents are blanked, soecho "terraform apply"deploys nothing.
Anything the resolver does not understand — format(), ternaries, needs.*
outputs — resolves to opaque, and every rule treats opaque as "make no
claim". See docs/semantics.md for the full resolution
table and docs/rules.md for each rule's exact trigger
conditions.
The repository ships a demo whose expected output was written down before the tool first ran against it:
git clone https://github.com/hamodywe/preempted && cd preempted
npm ci
node src/cli.ts examples/preempted-demo # 7 errors, 1 warning
node src/cli.ts examples/correct-twin # silence, by designexamples/correct-twin is the fixture the tool must
stay silent about — the same workflow shapes, configured correctly. Every
severity was tuned against it, and CI asserts its silence on every commit.
Stated plainly, because a static tool that overclaims is worse than none:
- Bare
on: pushis treated as branch pushes. Tag pushes also fire it; modelling every push workflow as tag-triggered would drown the report. Workflows with an explicittags:filter are modelled as tag-triggered. - Opaque expressions produce no findings.
format(), ternaries,needs.*/matrix.*in groups, andvars.*values are never guessed at. The cost is missed findings, deliberately paid. - Environment protection rules are invisible. A deploy gated by required reviewers is safer than it looks to this tool; the finding still stands, because the cancellation window still exists once approved.
pull_requestruns from forks, org-level rulesets, and GitHub's scheduling internals are not modelled beyond the documented concurrency semantics (one running + one pending per group, FIFO by wait-start, case-insensitive group names,queue: single|max).- This is static analysis. It proves what a configuration can do, not
what your traffic will make it do. A
crossfirefinding is a provable collision, not a measured incident.
Why not actionlint or zizmor? Use them — they are excellent at what they
cover. actionlint validates syntax and schema; deadlock detection across
concurrency groups is an open request there (#538).
zizmor audits security and checks concurrency presence; its remediation
(cancel-in-progress: true) is correct for CI and wrong for deploys, which
is exactly the distinction this tool exists to make.
Doesn't GitHub's new queue: max fix the pending-slot problem? It fixes
dropped pending runs when you set it — it is not the default, and it is a
validation error combined with cancel-in-progress: true. pending-dropped
tells you where you need it.
My deploy job is protected by an environment with required reviewers.
Is cancelled-mid-deploy a false positive? No — approval delays the start;
once running, a newer run still cancels it. The window is smaller, not gone.
Why is cancel-in-progress: true on my CI not flagged? Because it is
correct there. A rule that fires on the recommended pattern trains you to
scroll past the finding that matters.
See ROADMAP.md. Headlines: --fix emitting the corrected
concurrency block, SARIF output for code scanning, matrix-aware job-level
group resolution, and org-wide scanning across many repositories.
CONTRIBUTING.md. The short version: every rule change must
keep examples/correct-twin silent and
examples/preempted-demo exactly as its README
specifies — the fixtures are the contract.