Concurrency Control
Push a small fix to main, realize thirty seconds later you forgot something, and push again. Now two deploy runs are in flight at once. The first one is mid-way through “Start app” — it’s just written a new PID to app.pid and backgrounded a process on port 8000 — when the second run’s “Stop previous instance” step reads that same app.pid and kills it. Or worse: both runs reach “Start app” close enough together that they both try to bind port 8000, and one of them fails with Address already in use for no reason its own code did anything wrong. That’s not a hypothetical edge case — it’s the natural result of a deploy pipeline with no concept of “wait your turn.”
Note
This is not specific to demo app.py in earlier chapters because it was just backgrounded on runner itself for practice. In real world, you are deploying to real machine means. All those things above are real issue unless you solve them.
concurrency: is GitHub Actions’ answer to exactly this.
What A Concurrency Group Does
A concurrency group is just a name. Any runs (or jobs) that share the same group name are queued relative to each other — only one runs at a time per group, the rest wait.
concurrency:
group: <some-identifier>
cancel-in-progress: <true-or-false>Adding It To The Deploy Pipeline
name: Deploy App
on:
push:
branches: [main]
concurrency:
group: deploy-app-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
# ...unchangedgroup: deploy-app-${{ github.ref }} builds a group name unique to this workflow and this branch — deploy-app-refs/heads/main, in this case. Including github.ref matters: without it, a deploy triggered from a different branch would be forced to queue behind main’s deploys for no good reason.
group vs. cancel-in-progress
groupanswers “which runs are considered the same line of work?” Two runs with an identical resolvedgroupvalue are mutually exclusive — GitHub Actions never runs both at once.cancel-in-progressanswers “when a new run in this group starts, what happens to the old one?”truecancels the currently in-progress run the moment a new one starts.false(the default) queues the new run to wait for the current one to finish naturally.
For this pipeline, cancel-in-progress: true is the right call — if you’ve pushed twice in quick succession, the second push almost always represents the state you actually want running, and there’s little value in letting a stale, already-superseded deploy finish first.
Warning
cancel-in-progress: true sends the same cancellation signal covered in the earlier chapter on re-running and cancelling runs — the currently-executing step gets a grace period, then gets force-stopped. If the cancelled run is mid-way through “Start app,” it may leave a process running on port 8000 with no app.pid recorded for it (the kill happened before the file was written, or the file write happened but the process survives the signal). In practice the next successful run’s “Stop previous instance” step won’t know to clean up an orphan like that — worth keeping in mind if you ever see a port stay stubbornly occupied after a cancelled run.
Verifying It Works
Push two commits to main in quick succession — close enough that the first deploy is still running when the second lands. Open the Actions tab: the first run should show as cancelled (superseded), and the second should run to completion. Compare that against what would have happened without concurrency: — both runs completing independently, both racing to bind the same port and read/write the same pidfile, with no coordination between them.
Tip
concurrency: works identically at the job level — useful when only one specific job in a workflow touches a shared, order-sensitive resource, and the rest of the workflow is safe to run in parallel as usual.