DeployRace
Two pushes, two deploys, the wrong version in production. DeployRace finds the deploy workflows that can race.
GitHub Actions deploy jobs without concurrency, cancel-in-progress: true on a deploy, two workflows that deploy the same environment under different groups, groups that name no environment, pull request previews without a per-PR group, and GitLab CI deploys without resource_group.
The problem
GitHub Actions runs workflows in parallel by default. Merge two pull requests a minute apart and
two deploy runs start side by side:
- The older commit wins. The first run hits a cold cache, the second one deploys the newer
commit first, then the slow run finishes and puts the older build back. Both runs are green and
production has quietly moved backwards.
- Terraform applies collide. Two
terraform apply runs fight over the state lock, or one
applies a plan the other has already changed.
- Migrations run twice, at the same time, against the same database.
The fix is a concurrency: group per environment with cancel-in-progress: false. Cancelling a
deploy halfway (a half-applied Terraform state, a migration stopped in the middle, a half-rolled-out
release) is worse than waiting, so the newer run should queue, not kill the running one.
concurrency:
group: deploy-${{ github.ref }}-production
cancel-in-progress: false
It is easy to miss: the GitHub default is "no limit", the popular CI snippet uses
cancel-in-progress: true (right for tests, wrong for deploys), and a second workflow that deploys
the same environment under a different group name still races.
Real reports:
What you see
- DeployRace in the activity bar (two arrows racing to a finish post). Its Deploy
concurrency view lists every finding grouped by workflow file, and each row names the exact
job:
DR001 deploy.yml / job deploy-prod, DR002 migrate.yml / workflow (job migrate),
DR001 .gitlab-ci.yml / job deploy_production. The description is the rule title; the tooltip
has the full message (what makes the job a deploy, which other workflow deploys the same
environment and with which group), the file and line, and the Quick Fix. Click a row to jump to
the job. The view shows a badge with the number of findings.
- Inline Quick Fix on each row that has one, and the same fix from the lightbulb in the editor.
Every fix is a normal, undoable edit that keeps your comments and indentation.
- Problems: every finding is also a diagnostic on the job, group or
cancel-in-progress line.
- Status bar:
DeployRace: N. Click it to open the view.
- With nothing found, the view says which files it read and what it looks for.
What counts as a deploy
A job is a deploy when it has environment:, or a step that runs a deploy command, or uses a known
deploy action:
terraform apply / tofu apply / terragrunt apply, kubectl apply / rollout restart /
set image, helm upgrade / install, helmfile apply, argocd app sync
serverless deploy, sam deploy, cdk deploy, pulumi up, flyctl deploy, vercel --prod,
netlify deploy --prod, aws ecs update-service, aws lambda update-function-code,
aws cloudformation deploy, gcloud run|app|functions deploy, az webapp deploy,
wrangler deploy, firebase deploy, eb deploy, docker push to a :prod / :production /
:live / :stable tag
- migrations:
alembic upgrade, prisma migrate deploy, rails db:migrate, manage.py migrate,
flyway migrate, liquibase update, knex migrate:latest, sequelize db:migrate,
artisan migrate, typeorm migration:run, dbmate up, ecto.migrate
- actions such as
aws-actions/amazon-ecs-deploy-task-definition, azure/webapps-deploy,
google-github-actions/deploy-cloudrun, actions/deploy-pages, cloudflare/wrangler-action,
dflook/terraform-apply, appleboy/ssh-action, and pulumi/actions with command: up
- your own patterns from
deployRace.deployPatterns
Not a deploy: terraform plan, helm diff / template, --dry-run, kubectl / helm in a job
that starts a local cluster (kind, minikube, k3d), and migrations in a job that runs tests, has
services: or points at a test database.
Rules
| Rule |
Default |
What it finds |
| DR001 |
warning |
A deploy job with no concurrency on the job or the workflow, in a workflow that can run twice at once (push, workflow_dispatch, schedule, release, workflow_run and any other non pull request trigger). Also a group that is unique per run outside pull requests (${{ github.head_ref \|\| github.run_id }}, github.sha), which protects nothing on push. A reusable workflow (on: workflow_call) is checked through the jobs that call it: concurrency on the caller job, the caller workflow or inside the callee all count. GitLab CI: a deploy job without resource_group. Quick Fix: insert a block-style concurrency: with group: deploy-${{ github.ref }}-<environment> and cancel-in-progress: false (on the workflow when it deploys one environment, so queued runs keep push order; on the job otherwise), or resource_group: <environment> for GitLab. When another workflow already deploys the same environment, the fix uses its group. |
| DR002 |
error |
cancel-in-progress: true on a deploy job, or on a workflow that deploys. Pure CI and test jobs are never reported, and neither are pull request preview deploys unless they run Terraform, a stack update or a migration. An expression (${{ ... }}) is not judged. GitLab CI: interruptible: true on a deploy job (own, from a template, extends: or default:), unless workflow:auto_cancel:on_new_commit: none. Quick Fix: false. |
| DR003 |
warning |
Two different workflows deploy the same target with different concurrency groups (or one without): the same environment: name, the same Terraform directory (working-directory, -chdir=, cd dir &&) or the same Kubernetes namespace (-n / --namespace). Groups only serialize runs inside one group, so the workflows still race. Quick Fix: use the shared group (the one most of them already use, or deploy-<target>). |
| DR004 |
warning |
A concurrency group that names neither the environment nor the ref (for example a global group: deploy). A warning when it is shared by different environments (staging then blocks production), information when it serves one target today. Quick Fix: append the environment. |
| DR005 |
information |
A deploy in a pull_request workflow without a per-PR group (github.event.pull_request.number, github.head_ref or github.ref), so preview environments step on each other. Quick Fix: add a per-PR group. |
Settings
| Setting |
Default |
Description |
deployRace.enabled |
true |
Turn DeployRace on or off. |
deployRace.rules |
{} |
Severity per rule (error, warning, information, hint) or off, e.g. { "DR002": "warning", "DR005": "off" }. |
deployRace.deployPatterns |
[] |
Extra regular expressions (case-insensitive) that mark a run: / script: line or a uses: action as a deploy, e.g. ["scripts/ship\\.sh"]. Invalid patterns are ignored. |
deployRace.exclude |
["**/vendor/**"] |
Glob patterns (workspace folder relative) that are never checked. node_modules and .git are always excluded. |
Commands
- DeployRace: Rescan Workspace
- DeployRace: Show Deploy Concurrency
- DeployRace: Open Settings
Files it reads
.github/workflows/*.yml and *.yaml at the root of each workspace folder (where GitHub reads
them), .gitlab-ci.yml, *.gitlab-ci.yml and .gitlab/ci/**/*.yml. Workflows of one workspace
folder are compared with each other for DR003, DR004 and reusable workflows.
Privacy and safety
- No network, no telemetry. DeployRace runs no programs at all, so it works fully in Restricted
Mode (untrusted workspaces).
- Files are read only inside the workspace folders, with a 1 MB size cap. Symlinks are never
followed and every path is checked against its real location.
- Fixes are normal editor edits you can undo; they only touch the workflow file of the finding.
Limitations
- The YAML reading is line based. It handles block and flow style, block scalars, anchors, aliases
and merge keys, which covers real workflow files, but not every corner of YAML.
- Deploy detection is pattern based. A deploy hidden in a script file (
./deploy.sh) is found only
with environment: on the job or a deployRace.deployPatterns entry. A job with environment:
is always treated as a deploy.
- DR003 compares targets by name: two workflows that use namespace
payments in two different
clusters are reported too.
- Remote reusable workflows (
uses: org/repo/.github/workflows/x.yml@v1) are not read; a calling
job counts as a deploy when the file name contains deploy. GitLab include: files from other
projects are not read; local ones are checked when they match the file names above, and
extends: and anchors are followed inside one file.
- Concurrency stops runs from overlapping. It does not stop a deploy started outside the CI system,
and a job-level group alone can still let a later run's deploy start first when an earlier build
is slower: a workflow-level group keeps push order.
- File changes on disk are picked up by the file watcher; where watchers are unavailable,
DeployRace rechecks when the window regains focus and every 15 seconds while it is focused.
License
MIT - included with the extension.
| |