Skip to content

Repository files navigation

preempted

Finds the GitHub Actions concurrency settings that cancel the deploy they were meant to protect — or silently drop the run waiting behind it.

CI npm License: MIT Node >= 20.10

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 held

Nothing 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 apply

The problem

A 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 apply happens 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.

What preempted checks

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.

Install

npm install --save-dev preempted    # or: npx preempted

Node ≥ 20.10. Zero runtime dependencies.

Usage

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

How it works

  1. Parse every file in .github/workflows with a position-tracking, zero-dependency YAML parser (no anchors/aliases — a workflow using them is reported as unanalysable rather than analysed wrongly).
  2. 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.workflow resolves to the actual name: value, which is how two same-named workflows end up in one group.
  3. 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).
  4. 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, so echo "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.

Try it on a broken repository

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 design

examples/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.

Limitations

Stated plainly, because a static tool that overclaims is worse than none:

  • Bare on: push is treated as branch pushes. Tag pushes also fire it; modelling every push workflow as tag-triggered would drown the report. Workflows with an explicit tags: filter are modelled as tag-triggered.
  • Opaque expressions produce no findings. format(), ternaries, needs.*/matrix.* in groups, and vars.* 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_request runs 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 crossfire finding is a provable collision, not a measured incident.

FAQ

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.

Roadmap

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

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.

License

MIT

About

Finds the GitHub Actions concurrency settings that cancel the deploy they were meant to protect — or silently drop the run waiting behind it.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages