Mehdi Akiki
Published on

Why GitHub Actions Cancels Runs After Every Push

Authors
  • Mehdi Akiki avatar
    Name
    Mehdi Akiki
    Twitter

Article · Interrupted execution

GitHub Actions can cancel an older run when a new push enters the same concurrency group with cancel-in-progress: true. First check the group and trigger: the cancellation may be intentional, a collision between workflows, or a policy that keeps restarting a useful check.

Picture a pull request with a slow integration check and an agent that keeps spotting small improvements. The first push starts the check. A second fixes a name. A third improves an error message. A fourth updates a comment.

Each push cancels the previous run.

By lunchtime, the code looks better and no integration run has finished. The latest job is installing dependencies again.

The workflow hasn't broken. It is doing exactly what its cancellation rule says. The agent keeps submitting work faster than the workflow can finish checking it.

What cancellation saves and costs

Cancelling an old run can save useful resources. If a developer has replaced a broken implementation, another twenty minutes testing the discarded version may be wasted. Other people need those runners too.

The trouble starts when every run is replaced before it reaches the slow check that matters. The team pays for repeated setup and gets no completed integration result. Even a run that was nearly finished can disappear because someone pushed a comment fix.

There is work for the developer too. They have to decide whether a partial result still applies, compare old failures with new code, and work out which checks the latest commit still needs.

Before changing the workflow, open a few cancelled runs. Where did they stop? Did the next commit change the behavior under test? Was useful work discarded, or did the run fail early enough that cancellation saved most of the cost?

These details help decide whether to change the cancellation rule, the timing of pushes, or the order of checks. The same fix won't suit every workflow.

The agent can help with this investigation. It can list the cancelled runs and the commits that replaced them. That is more useful than immediately rewriting the workflow to allow every run to continue.

Keep unrelated pull requests apart

For a workflow triggered by pull requests, a group like this gives each request its own cancellation rule:

concurrency:
  group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }}
  cancel-in-progress: true

If the workflow also handles other events, it needs a fallback for events without a pull request number. Otherwise the group may not separate the work as intended.

GitHub's concurrency documentation explains how matching groups limit work and how cancel-in-progress: true can cancel a running member. A group shared by every pull request could make unrelated requests cancel each other.

Including the workflow name also separates workflows that validate the same request. Group names apply across the repository, so a PR number alone could make two different workflows cancel each other. This still leaves the request in our story cancelling its own earlier runs with every push.

For that, the developer can collect small cleanup edits before pushing them, or let one version finish its required checks before asking for more polish. The agent can still read code, explain a result, or prepare suggestions while it waits. It doesn't need to commit every observation immediately.

An early focused check helps too. Even if a later push replaces the full run, the developer may already have an answer about the small behavior they changed. The larger required checks still need to finish for the commit that will merge.

Don't copy the rule into production without thinking

A deployment has different risks. By the time it is cancelled, it may have uploaded files, changed a database, or started replacing a service. Stopping the job doesn't automatically undo those changes.

This site's production workflow lets the active deployment finish:

concurrency:
  group: production-site-deployment
  cancel-in-progress: false

This rule lets the active deployment finish, but the default queue holds only one pending run. A newer arrival can replace that waiting run.

GitHub now offers an alternative when you need more waiting releases:

concurrency:
  group: production-site-deployment
  cancel-in-progress: false
  queue: max

This is an alternative, not this site's current setting. It allows up to 100 pending runs; further arrivals are cancelled. Combining queue: max with cancel-in-progress: true is invalid. Order follows when runs begin waiting for the group, which may differ from dispatch order. If every release must run in a strict order, those limits still need handling.

Choose the behavior before copying either fragment:

SituationConfiguration or next action
Only the latest PR revision should finishGive that workflow and PR their own group; cancellation may save discarded work
Two workflows cancel each otherInclude a unique workflow identity in the group
An active deployment must finishUse a deployment group with cancellation disabled
Several releases must waitConsider the bounded queue, and handle its capacity and ordering limits
Every small cleanup push restarts integrationChange push timing or the check order; a unique group alone will not fix it

The first fragment is for a PR-triggered workflow. If another event also triggers it, define how that event should be grouped instead of assuming a PR number exists. These examples show concurrency decisions; they do not replace runner, permission, or checkout configuration.

An agent changing this configuration should explain which behavior is wanted. “Only test the latest version of this pull request” and “let an active production release finish” need different choices.

For the pull request, first let one revision finish the required integration check. Use that result to decide whether the next push needs a repair or whether the optional cleanup can wait. Keep that result attached to the revision it actually checked; a completed older run is useful history, not verification of a newer parser edit.

After a successful run, another rename is a choice with a visible cost: another round of required checks. Sometimes it is worth doing. Sometimes it can wait for the next change. At least the developer now has a completed result to base that choice on.