GitHub Actions always() skipped when canceled
GitHub Actions always() skipped when canceled is not the function failing to do what it says. Cancellation re-evaluates conditions only for work that has already started, so a step inside a running job does continue, while a job still waiting in the graph is never asked and never runs.

What this error means
You cancel a run, or a new push cancels it for you through a concurrency group, and the cleanup does not happen. The job that tears down the preview environment shows as skipped with no steps to open, so there is no log explaining itself and no annotation. What makes it hard to argue with is that the same workflow behaves correctly on a failure: the identical condition runs the identical work when a test fails, which sends people looking for a difference in the expression rather than in when the expression was read.
{
"name": "acceptance (ubuntu-latest)",
"status": "completed",
"conclusion": "cancelled",
"steps": []
}A minimal workflow that produces it
This file is written for this page and has never been run. It is the shape almost every report of this takes: a long job, a cleanup job behind it, and the always condition applied in good faith to both. Cancel it while the tests are running and the upload step does run, while the teardown job does not.
name: e2e
on: pull_request
concurrency:
group: e2e-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- run: npm run test:e2e
- if: always()
uses: actions/upload-artifact@v4
with:
name: traces
path: traces/
teardown:
needs: test
if: always()
runs-on: ubuntu-latest
steps:
- run: ./teardown-preview.shCommon causes
The job had not started when the cancellation arrived
The common one. Cancellation re-evaluates conditions for currently running jobs, and a job waiting on needs or waiting for a runner is not one of them. Its condition is never read, so it makes no difference what you wrote there.
The work that must survive is in a separate job
A teardown, a notification or an artifact upload in its own job cannot survive a cancellation, because by construction it starts after something else finishes. The same work as a step inside the job it cleans up after is reached by the re-evaluation pass.
The step ran but the five minute ceiling ended it
A step that does continue is still inside a run being torn down, and after the cancellation timeout the server "will forcibly terminate all jobs and steps marked for cancellation that are still running". A cleanup that takes longer is cut off partway, which looks like a step that did not run if you only check the outcome.
The condition was never an override at all
A status function has to be present for the default success check to be replaced. A plain comparison on a dependency result leaves the default in place, so the job is disqualified before your comparison is considered. In our experience this arrives after someone removes the status function while simplifying a long condition.
How to fix it
Decide which of the four endings each piece of work covers
- Write down what should happen on success, on failure, on cancellation and on a skipped dependency.
- Anything that must survive a cancellation goes in the job it belongs to, as a step.
- Anything that must not run after a cancellation gets the negated form, not the blanket one.
Move cleanup into the job it cleans up after
This is the fix that holds, because it puts the work where the re-evaluation pass can see it. Keep it small enough to finish inside the cancellation window, and safe to run twice, since the job may also end normally.
- name: tear down the preview
if: always()
run: ./teardown-preview.shUse the negated form for jobs that should not run after a cancel
The blanket function is right only when a canceled run should still do this work. For everything else the recommended alternative overrides the default success check without opting into the cancellation path, and a comparison on the dependency result says the rest.
report:
needs: test
if: ${{ !cancelled() && needs.test.result != 'skipped' }}
runs-on: ubuntu-latest
steps:
- run: ./post-summary.shGive the cleanup its own budget
A teardown that talks to a cloud API can outlast the cancellation window, and then it is killed partway, which is worse than not starting. Put a timeout on the step comfortably under the window, and make the operation idempotent so a scheduled sweep can finish what the step did not.
- name: tear down the preview
if: always()
timeout-minutes: 3
run: ./teardown-preview.shWhat GitHub does when a run is canceled
The workflow cancellation reference sets out the sequence, and reading it once settles the question. Step one: "To cancel the workflow run, the server re-evaluates if conditions for all currently running jobs. If the condition evaluates to true, the job will not get canceled. For example, the condition if: always() would evaluate to true and the job continues to run."
Step three covers the inside of those jobs: "For jobs that continue to run, the server re-evaluates if conditions for the unfinished steps. If the condition evaluates to true, the step continues to run." And there is a ceiling on all of it: "After the 5 minute cancellation timeout period, the server will forcibly terminate all jobs and steps marked for cancellation that are still running."
The answer is in the word "running". The pass visits jobs already running, so a job queued behind a dependency is not in it and its condition never gets the second reading it needs. It is not skipped by its own condition; it is never started.
Guard the work where it will actually be reached
The corrected file moves the teardown to where cancellation can still reach it: inside the job that is already running, as a step. The step-level condition is the one the sequence above re-evaluates, so it survives a cancellation and does so inside the five minute window. Read the two samples as a pair: same trigger, same concurrency group, and the second tears the preview down when you press cancel.
The job-level condition changed too, from the blanket form to the documented alternative: "If you want to run a job or step regardless of its success or failure, use the recommended alternative: if: ${{ !cancelled() }}." A separate job is still worth having for the failure and success paths; it is the cancellation path it cannot cover.
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- run: npm run test:e2e
- if: always()
uses: actions/upload-artifact@v4
with:
name: traces
path: traces/
- if: always()
run: ./teardown-preview.sh
report:
needs: test
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
steps:
- run: ./post-summary.shWhere each condition is read, and what that costs you
The two levels are not interchangeable and the table below is the difference. It also explains the mirror-image complaint, a job guarded with the blanket condition that starts after somebody cancels: that job was already running, its condition was re-evaluated, and it returned true. The expressions reference documents exactly that, since the function "Causes the step to always execute, and returns true, even when canceled."
Both complaints come from the same place. The condition is a filter the server applies at specific moments, and the moments differ for a job in flight, a job in the queue and a step inside a running job. actions/runner#3664 is the report from the other end, ending with the line most people arrive at: "When is always() not always?".
| Guarded thing | State when cancel arrives | Condition re-read | Result |
|---|---|---|---|
| step | its job is running | yes | runs, inside the 5 minute window |
| step | its job never started | no | skipped with the job |
| job | running | yes | continues under always() |
| job | queued behind needs | no | never started |
| job | queued for a runner | no | never started |
Why there is no recorded run on this page
A cancellation is not a failure, so there is nothing to reproduce and nothing to repair. The server applied the sequence above, and a runner cannot retry its way out of a decision made before the job was dispatched. The payload at the top is a real canceled job from the public cli/cli repository, read 2026-09-20, quoted because the empty steps array is the evidence: the job was canceled before a single step existed to carry a condition.
How to prevent it
- Put anything that must survive a cancellation in the job it belongs to, as a step rather than as a downstream job.
- Prefer the negated cancellation check on jobs, and keep the blanket form for work that must happen after a cancel.
- Keep every cleanup idempotent, because it may run twice or be cut off in the middle.
- Back a preview environment with a scheduled sweep, so no single run is the only thing standing between you and a leak.
Frequently asked questions
Why did my if: always() step not run when I canceled the workflow?
What is the difference between always() and !cancelled()?
true, even when canceled", so a running job guarded with it continues after a cancel. The negated form is true in every case except a cancellation, and the expressions reference recommends it for work that should run regardless of success or failure.