GitHub Actions · advanced

GitHub Actions concurrency cancellation: find the run that terminated work

Concurrency cancellation in GitHub Actions is a deliberate workflow-control mechanism, not a bug: a `concurrency` group policy causes a newer or older run to cancel a run that is still executing. The diagnostic task is to identify which run was terminated, which group key triggered the cancellation, and which policy (`concurrency.cancel-in-progress`) was responsible, so engineers can decide whether the cancellation is expected (a re-run, a push to a long-lived branch, a fan-out) or unintentional (a shared key producing collisions across unrelated workflows). Evidence is found in the run's lifecycle payloads, the GraphQL `WorkflowRun` node, and the workflow YAML, not in the job logs of the cancelled run.

The symptoms

  • A workflow run shows a yellow dot transitioning to a grey cancelled state mid-execution, with later steps marked "Skipped" and the run header reporting "This run was cancelled" or "Cancelled upon the request of a previous in-progress run."
  • Job logs for the killed run end abruptly at a specific step with no error string, no stack trace, and no failed exit code from the runner process itself.
  • Production telemetry or downstream artifacts indicate a deploy, build, or release step did not complete, but the Actions UI shows the run as "finished" rather than "failed," with status "cancelled" or "canceled."
  • Multiple workflows or branches appear to interfere: pushing to a feature branch cancels a previously started run on the same branch, or two unrelated pipelines collide because they share a concurrency key.
  • Rerunning a workflow with the same head SHA produces a cancelled run immediately, and the user did not click the Cancel button.

Likely causes

  • A `concurrency:` block in the workflow YAML declares a group key that is too broad (e.g., `${{ github.workflow }}` only, or a hardcoded string), so parallel or unrelated runs collide on the same group.
  • The `concurrency.cancel-in-progress: true` flag is set, so the newer run is allowed to terminate the older, still-running run. This is the explicit, intended cancellation path.
  • A push event, `workflow_dispatch` retrigger, or a scheduled run retriggered the same workflow on the same ref while a previous run was still in progress, and the group key did not include `github.ref` or `github.sha` to distinguish them.
  • A reusable workflow call (`uses: org/repo/.github/workflows/x.yml`) runs under a `concurrency` group defined in the caller, propagating cancellation into the called workflow.
  • A matrix fan-out reuses the same group key for all matrix legs, so the second matrix leg cancels the first before it finishes.
  • Manual cancellation by a user with write access via the UI or API is mistaken for an automatic concurrency cancellation; the audit log and GraphQL `WorkflowRun.event` field disambiguate.

First ten minutes

  1. 01Open the run page in the GitHub Actions UI and read the banner text verbatim. The strings "cancelled by", "Cancelled upon the request of", and "This run was cancelled" are rendered from the auditor and indicate the cancellation source.
  2. 02Capture the run URL, run ID, run attempt number, head SHA, ref, and triggering event from the run header before the page is refreshed, since attempt numbering resets on re-run.
  3. 03Open `.github/workflows/*.yml` for the affected workflow and locate the top-level `concurrency:` block. Record the `group` expression and the `cancel-in-progress` value exactly as written.
  4. 04Look at the Actions list filtered to the same workflow and same ref, ordered by created time, to confirm whether a newer run on the same group exists when the older run was cancelled.
  5. 05Check whether the workflow file contains `workflow_call` triggers or is consumed by a reusable workflow, because the `concurrency` group is evaluated in the caller's context for `workflow_call`-invoked runs.
  6. 06Quarantine the diagnosis: do not yet push a fix or change the YAML; concurrency changes can silently cancel runs that are currently in flight on the default branch.

Evidence to collect

  • The workflow YAML excerpt showing the `concurrency:` block, including the `group` and `cancel-in-progress` keys, with line numbers.
  • The run ID, run attempt, head SHA, ref, and `event` field of both the cancelled run and the run that caused the cancellation (if visible).
  • The timestamp of the cancelled run's last log line and the created_at of the cancelling run, used to compute overlap and confirm precedence.
  • The cancellation banner text from the run page, captured verbatim, plus the `conclusion` field value (`cancelled` or `success`) on the `WorkflowRun` node in the GraphQL API.
  • The concurrency group string that would be computed by evaluating the `group` expression against the cancelled run's context, so you can grep other recent runs for the same group.
  • For reusable workflows, the caller's `concurrency:` block and the callee path, because the group is resolved in the calling run's context.

Where to look

  • Repository file boundary: `.github/workflows/*.yml` for the `concurrency:` block and any `workflow_call` consumers that might inject a group.
  • Run boundary: the run page header on `github.com/<owner>/<repo>/actions/runs/<run_id>` for the cancellation banner, the `Jobs` panel for the last successful step before the cut, and the `Annotations` tab for any system notice.
  • API boundary: the GraphQL `repository.workflowRuns` connection and the `WorkflowRun` node fields `event`, `conclusion`, `headBranch`, `headSha`, `createdAt`, `updatedAt`, and `displayTitle`.
  • Audit boundary: the repository audit log events for `actions.cancel_workflow_run` to confirm whether the cancellation source is the concurrency policy or a user action.
  • Webhook boundary: the `workflow_run` webhook payload, specifically `action: requested`, `action: in_progress`, and any subsequent `action` indicating cancellation, which surfaces the policy reason in the deliverer's metadata.
  • Workflow boundary: the `GITHUB_JOB` and `GITHUB_RUN_ID` environment variables in the cancelled job's logs, which can be cross-referenced to a specific attempt within a run.

Diagnostic steps

  1. 01Compute the literal concurrency group for the cancelled run by substituting the `github.*` context into the `group` expression; if the group contains `github.ref`, repeat for `github.head_ref` and `github.sha` to cover pull_request vs push contexts.
  2. 02Query the Actions list for the same workflow and same group string over the last 24 hours; if a run with a later `createdAt` exists, compare `headSha` and `event` to determine which event superseded which.
  3. 03Inspect the cancelled run's `conclusion` via the GraphQL API and compare it to the banner text; a `conclusion` of `cancelled` together with the absence of any user-issued cancel event in the audit log strongly indicates concurrency-driven cancellation.
  4. 04For matrix jobs, fetch each matrix leg's `WorkflowRun` and check whether legs share the same group key; if the group omits `matrix.*` or `strategy.job-index`, the second leg cancels the first.
  5. 05For reusable workflows, evaluate the `concurrency.group` expression in the caller's context, not the callee's, and verify the callee does not redeclare a conflicting `concurrency:` block that would override the caller.
  6. 06Determine whether the triggering event is one of `push`, `pull_request`, `workflow_dispatch`, `schedule`, or `workflow_call`, because `pull_request` runs from closed or synchronized PRs are the most common source of unintended collisions on default-branch-shaped keys.
  7. 07If the audit log shows a user-initiated cancel, the failure mode is not concurrency; stop the concurrency line of inquiry and route to a manual-cancellation diagnostic instead.

Common mistakes

  • Assuming the cancelled run failed; `conclusion: cancelled` is a terminal state distinct from `failure`, and downstream alerting should branch on the conclusion value rather than treating cancellation as an error.
  • Changing the `concurrency.group` to add `github.sha` without also guarding `cancel-in-progress: false`, which can still cause a later run to cancel an earlier run with the same SHA in emergency retry scenarios.
  • Believing that setting `cancel-in-progress: false` prevents cancellation; it only prevents the *policy* from cancelling, but a user-initiated cancel or a required status check timeout still terminates the run.
  • Reading the last log line of the cancelled job as the error; the runner is sent SIGTERM and then SIGKILL, so the last line is typically the in-progress step's output, not a failure message.
  • Conflating `concurrency` (a per-run group policy) with `jobs.<id>.concurrency` (a per-job lock) and with the "required workflows" feature; the three controls are evaluated at different boundaries and have different semantics.
  • Re-running the cancelled run and assuming the second attempt's success proves the workflow is healthy; the second attempt may have been the cancelling run and never overlapped the first.

Safe fixes

  • If the cancellation is unintended, narrow the `concurrency.group` to include `github.ref` and `github.sha` so that distinct pushes and branches do not collide; verify the new group string resolves to a unique value per run before merging.
  • If the cancellation is intentional (e.g., a "latest deploy wins" policy), keep `cancel-in-progress: true` but document the behavior in the workflow README and add a run-name that includes the SHA so cancelled runs are auditable in the UI.
  • For matrix jobs, append `matrix.job-index` (or another matrix variable) to the group string so that legs do not cancel each other; pair this with the fix above and verify on a test PR.
  • For reusable workflows, hoist the `concurrency:` block to the caller and pass the group components via `inputs` or `secrets` rather than letting the callee declare its own group, which the runner does not merge.
  • If the cancellation source is a user clicking Cancel, do not change the workflow; instead, restrict the "Allow GitHub Actions to create and approve pull requests" and write-access pathways and document the manual-cancel runbook.
  • Apply changes via a PR with `concurrency.cancel-in-progress: false` temporarily, observe one to two cycles, then re-enable, so that any in-flight production runs are not cancelled by the change itself.

Prove the fix

  1. 01Trigger two overlapping runs of the affected workflow on the same ref (e.g., via two pushes in quick succession) and confirm in the Actions list that the older run remains in "in_progress" rather than transitioning to "cancelled" when the newer run starts.
  2. 02Verify via the GraphQL `WorkflowRun.conclusion` of the older run that it terminates as `success` or `failure` rather than `cancelled`, and capture the run IDs of both attempts.
  3. 03For matrix workloads, run a workflow with at least two matrix legs and confirm each leg produces its own `WorkflowRun` (or own job) and that no leg cancels another; the `Jobs` panel should show parallel "in_progress" entries before completion.
  4. 04Confirm by reading the run banner that the only cancelled runs in the next 24 hours are those manually cancelled, and that the audit log contains no `actions.cancel_workflow_run` entries attributable to the concurrency policy.
  5. 05Add a regression check in the workflow's own self-test job: assert `${{ github.run_id }}` is not present in the cancellation banner of the immediately preceding run in the same group, and fail the self-test if the assertion is violated.

Prevention and next steps

  • Adopt a repository-wide convention for `concurrency.group` values, e.g., always `${{ github.workflow }}-${{ github.ref }}-${{ github.sha }}`, and lint workflow files with a CI check that fails any `concurrency:` block missing one of those components.
  • Require a code review annotation explaining intent whenever `cancel-in-progress: true` is set, so the trade-off between "latest wins" and "let it finish" is visible in the PR thread.
  • Make downstream alerting branch on `conclusion` and treat `cancelled` as informational unless the workflow is part of a release pipeline, in which case alert on `cancelled` as a deploy-termination signal.
  • Periodically export the Actions list to a queryable store and dashboard the ratio of `cancelled` to total runs per workflow, so a sudden spike exposes a key collision early.
  • Document reusable-workflow concurrency ownership in the repository's contributing guide so that callers and callees agree on which context evaluates the group.

Safe commands and checks

gh run view <run_id> --repo <owner>/<repo> --json name,conclusion,event,headBranch,headSha,createdAt,updatedAt,displayTitle
gh run list --repo <owner>/<repo> --workflow <workflow_file> --limit 20 --json databaseId,name,conclusion,event,headBranch,headSha,createdAt
gh api graphql -F owner=<owner> -F name=<repo> -F query='query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { workflowRuns(first: 20, orderBy: {field: CREATED_AT, direction: DESC}) { nodes { databaseId conclusion event headBranch headSha createdAt updatedAt } } } }'
gh api /repos/<owner>/<repo>/actions/runs/<run_id>/jobs --jq '.jobs[] | {name, conclusion, started_at, completed_at}'
gh api /repos/<owner>/<repo>/actions/runs/<run_id> --jq '{name, conclusion, event, head_branch, head_sha, run_attempt, created_at, updated_at}'