When should GitHub Actions queue a deployment instead of canceling the old run?
Use `concurrency` to keep release branches in order and let preview branches cancel stale deploys, then verify the policy with a local regression test.
The failure is easy to describe and easy to mishandle. A second push lands while the first deployment is still running. On preview branches, the old run is usually disposable. On release branches, the old run may represent the last validated artifact, so canceling it can be the wrong tradeoff. GitHub Actions lets you express that difference with `concurrency`, but the queueing and cancellation options solve different problems, and they should not be treated as interchangeable.
This article stays on one operational decision: whether overlapping runs for the same workflow should be canceled or queued, and how to make that choice change by branch. The example is intentionally small. It does not build a full release system or model every GitHub Actions feature. It shows the concurrency policy, the expected behavior, and the verification boundary that keeps the policy honest.
The same workflow can make different overlap decisions depending on the branch
GitHub Actions concurrency groups are a workflow-level or job-level key. If two runs share the same key, GitHub treats them as part of the same group. That is useful, because deployment systems often need to enforce a single active run for a branch or environment. It is also easy to misuse, because the word "cancel" sounds like a general safety valve while queueing sounds like a convenience. In reality, the right choice depends on whether the old run is still meaningful.
Preview branches generally prefer cancellation. If the branch is receiving frequent pushes, the newest commit matters and the older run is stale. Release branches often need the opposite. If the deployment is already in flight or waiting on approval, you may want later pushes to queue instead of killing the current run. The choice is not about GitHub's taste. It is about which run still carries operational value.
| Branch class | Overlap rule | Operational reason |
|---|---|---|
| Preview branches | Cancel stale runs | The newest commit matters more than older in-flight work. |
| Release branches | Queue runs in order | The earlier deploy still represents valid release work. |
SourcesGitHub Docs: Workflow syntax for GitHub Actions (opens a new tab)
`queue: max` and `cancel-in-progress` are separate mechanisms, not variations of the same knob
GitHub's workflow syntax documents both behaviors. `cancel-in-progress: true` tells Actions to stop a matching in-progress run when a new one arrives. `queue: max` does the opposite: it allows runs to wait in the group, up to the documented queue limit, instead of replacing one another. Those behaviors conflict, so the docs explicitly say they cannot be combined.
That distinction is the central operational point. A team that wants "no overlap" may accidentally choose canceling because it sounds stricter, even though the real requirement is "run them one at a time in order." A team that wants "keep only the latest preview" may accidentally queue stale preview deploys because they only copied the example that reserved a production queue. The syntax is small, but the semantics are not.
concurrency:
group: production-deploy
queue: max
SourcesGitHub Docs: Workflow syntax for GitHub Actions (opens a new tab)
A branch-aware policy can keep release runs and preview runs in different lanes
The branch decision belongs in the same workflow that defines concurrency, because the overlap rule is part of release safety, not a separate manual process. GitHub's docs show a branch-aware `cancel-in-progress` expression as an example, and they show that `github.head_ref` is only defined for pull request events. That means a workflow that mixes push and pull request triggers needs a fallback, or else the concurrency key will be missing on some runs.
There are two separate examples here. The branchPolicy helper renders a policy for a supplied branch ref and uses github.ref in its YAML; it does not implement a fallback. The separate previewGroup helper below models the documented head_ref-or-run_id alternative. Its non-PR keys are deliberately unique, which means they do not serialize production deployments. Use a shared environment key when that is the resource requiring protection.
The conditional block below only decides whether an active run can be canceled. It does not turn on a multiple-run queue for release branches: without queue: max, a later arrival can replace the single pending run. To retain multiple pending release deployments, use a separate release job or workflow with queue: max. Do not combine that option with cancel-in-progress: true.
For example, push three release commits while a deployment is active. Under the default pending policy, the newest pending run replaces the previous pending run even though the active deployment continues. Under queue: max, up to 100 runs can wait. That order follows entry into the concurrency wait queue, not necessarily commit order or workflow dispatch order. Validate artifact versions before applying migrations; serialization is not proof that the next artifact is newer.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ !contains(github.ref, 'release/') }}
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
SourcesGitHub Docs: Workflow syntax for GitHub Actions (opens a new tab)GitHub Docs: Contexts reference (opens a new tab)
Separate the disposable preview from the shared production resource
Here is a complete policy-only workflow, with harmless placeholder commands. Save it as a workflow file only after replacing those commands with your own validated artifact operations. A push to main selects the preview job; a release branch selects the release job. There is no workflow-wide cancellation group above these jobs, so a preview push cannot cancel the whole workflow while its release job is waiting. The test parses this exact YAML and checks the two opposing conditions and their concurrency settings.
The release job deliberately uses one production-deploy key rather than a key containing the branch name. Imagine release/1.2 and release/1.3 both targeting one production database. Separate branch keys would permit simultaneous operations on that database. This shared key expresses the resource boundary instead. Every other workflow writing that same resource must follow the same convention; a workflow-specific prefix would defeat this particular cross-workflow lock. The preview key is different because this example assumes each branch has its own disposable preview target.
Run the local test before changing a key or condition. Then test the actual workflow against a disposable deployment target: start one long-running release operation and submit two more runs, inspect which jobs are waiting, and confirm no second writer reaches the target concurrently. This hosted exercise has not been performed for this article. Do not describe the local YAML assertions as a production deployment or an ordering benchmark. Replace the placeholder only after artifact provenance, permissions, approval settings, and rollback behavior have been checked in your repository.
A shared key prevents overlapping writers only within its coordination scope. It does not stop a developer's laptop, another repository, or an external deployment service from changing the target. It also cannot undo a partially completed database migration when someone cancels a run manually. Those are explicit limits of this example: use the environment's own lock or deployment controls for other writers, and design destructive operations to tolerate interruption before relying on a queue to make them safe.
name: Branch deployment policy
on:
push:
branches: [main, "release/**"]
permissions:
contents: read
jobs:
preview:
if: ${{ !startsWith(github.ref, 'refs/heads/release/') }}
runs-on: ubuntu-latest
concurrency:
group: preview-${{ github.ref }}
cancel-in-progress: true
steps:
- run: printf 'Replace with preview deployment of this run artifact\n'
release:
if: ${{ startsWith(github.ref, 'refs/heads/release/') }}
runs-on: ubuntu-latest
environment: production
concurrency:
group: production-deploy
queue: max
steps:
- run: printf 'Replace with verified promotion of this run artifact\n' The local test verifies the policy, not GitHub's hosted runner
The tests cover two local functions, not GitHub's service. branchPolicy renders a concurrency block for a supplied ref. previewGroup models the preview expression's choice of a branch or run ID. Two runs with one PR branch produce the same preview key; two runs without that branch produce different keys. Removing the fallback would make both non-PR cases collapse into the same missing-value key, which the test is intended to prevent.
The test is narrow on purpose. It proves that the draft policy maps the intended branches to the intended concurrency mode. It does not prove environment approvals, deployment order inside a third-party environment, or the effect of a manual rerun button. Those belong to separate operational checks.
Run the controlled helper test
Execute `node --test .firebase/daily/examples/github-actions-concurrency-policy.test.mjs` from the repository root.
Inspect the branch results
Confirm that the release branch emits `queue: max` and the preview branch emits `cancel-in-progress: true`.
Record the boundary
Note that the test validates the generated policy only; it does not exercise GitHub's hosted runner or environment protection rules.
SourcesGitHub Docs: Workflow syntax for GitHub Actions (opens a new tab)
A runbook needs failure signals, not just the happy path
The overlap decision is only one layer of deployment safety. In practice, a team has to decide what to do when the workflow is already queued, when a queued run becomes stale, when a manual rerun appears, and when the branch name does not match the policy expression. Those are not edge cases invented for the article. They are the ordinary ways a release workflow stops being legible if the policy is too broad.
The most useful runbook is therefore procedural. First check the branch class. Then check whether the active run still matters. Then decide whether the new run should replace the old one or wait behind it. If the branch is not clearly a release branch, do not silently queue it just because the YAML expression happened to resolve that way. Make the policy explicit enough that the operational choice is visible in the workflow file and in the test.
This also helps with troubleshooting. A canceled preview run is usually a success, not a failure, if the goal was to avoid wasting time on stale commit history. A canceled release run is usually a problem, because it means a run that still mattered was discarded. Queueing can hide the reverse problem: the workflow is safe from accidental replacement, but the release train may be backing up because no one noticed the queue growing. The article should teach the reader how to distinguish those outcomes, not just which keyword to paste into the file.
| Signal | Likely meaning | Next diagnostic |
|---|---|---|
| Queued runs keep growing on release branches | The release lane is preserving order, but throughput is backing up | Check whether the branch expression is too broad or the deploy time is too long. |
| Older preview runs are still finishing | The preview lane is not canceling stale work | Inspect the concurrency key and confirm the branch falls into the cancel path. |
| A run from another workflow is canceled | The concurrency key is too generic | Check whether both workflows intentionally share one deployment target; isolate unrelated targets, not cooperating writers. |
SourcesGitHub Docs: Workflow syntax for GitHub Actions (opens a new tab)
The operational steps are different from the policy expression
An authoring example should not pretend the YAML alone solves the whole deployment problem. A workflow file can encode the overlap rule, but a human still has to decide which branch types map to which rule, what the stop condition is when a queue grows, and whether the environment should accept a queued release at all. That is why a runbook section belongs in a DevOps article: it turns a static configuration snippet into an operation with a start state, an expected observation, and a failure branch.
For this subject, the start state is a new push arriving while another deployment is still active. The expected observation depends on branch class. On a preview branch, the new push should replace the older run. On a release branch, the new push should wait until the existing deployment finishes. If the system does the opposite, the page should make the reader stop and investigate the expression instead of treating the output as normal.
That difference matters in teams that deploy both preview environments and production artifacts from the same repository. The preview lane values speed and freshness. The release lane values order and reproducibility. A single concurrency policy can support both lanes only if it is explicit about which branch names count as release work. If the rule relies on tribal knowledge, the next person to touch the YAML will almost certainly flatten that distinction.
Identify the branch
Check whether the run belongs to a preview branch or a release branch before reading the overlap result.
Inspect the current run
Confirm whether the active run still matters. A stale preview run should be replaceable; a release run should usually be preserved.
Choose the correct overlap mode
Apply queueing only when the earlier run must finish in order. Apply cancellation only when the older run is disposable.
Verify the branch rule
Re-run the helper test if the policy expression changes, especially if the concurrency key or release prefix changes.
SourcesGitHub Docs: Workflow syntax for GitHub Actions (opens a new tab)GitHub Docs: Contexts reference (opens a new tab)
The wrong repair fails for different reasons depending on what the workflow is trying to preserve
There are three tempting mistakes here. The first is to always cancel, discarding release work that still matters. The second is to always queue, retaining stale previews that nobody needs. The third is to give unrelated workflows one key merely because it is convenient. That can cause unintended cancellation. Sharing a key is correct when several workflows really write to the same target; the mistake is ignoring what the key is supposed to protect.
Those alternatives are useful to name because they fail for different reasons. The article is not saying GitHub Actions concurrency is tricky, so avoid it. It is saying that concurrency is a precise tool. A branch-aware key with the right overlap mode solves one operational problem, but it does not solve all of them at once. If the reader leaves with one habit, it should be to decide which runs are disposable before choosing the keyword.
This is also where implementation drift appears in larger repositories. A lint workflow should not accidentally join a production deployment group. Conversely, two deployment workflows writing to the same database should not escape coordination merely because their workflow names differ. Name the shared resource first, decide which writers must cooperate, and document that choice beside the key. The complete example therefore isolates branch previews but deliberately shares the production key.
| Incomplete fix | Preserves | Breaks |
|---|---|---|
| Always cancel | Freshness for preview branches | Order for release branches |
| Always queue | Release order | Preview freshness and throughput |
| Shared group key everywhere | Shorter YAML | Workflow isolation and predictable overlap |
SourcesGitHub Docs: Workflow syntax for GitHub Actions (opens a new tab)
The policy does not replace environment protection or deployment validation
If the old run is stale, cancel it. If the old run still matters and the jobs must finish in order, queue it. GitHub Actions gives you both behaviors, but the workflow has to choose them deliberately. The safest pattern is to encode that choice in the workflow syntax, test the generated policy locally, and keep release branches and preview branches on different overlap rules.
This policy is not the same thing as environment protection. If a release branch needs manual approval or a separate protected environment, that is a second control, not a substitute for concurrency. The article therefore stops at overlap policy, not at full release governance.
SourcesGitHub Docs: Workflow syntax for GitHub Actions (opens a new tab)GitHub Docs: Contexts reference (opens a new tab)
