Skip to content
Latchkey LogoLatchkey home

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.

The cancellation sequence and which conditions get re-evaluated at each point
Cancellation walks the run once. Work that has already started gets its condition read again; work that has not started is simply never reached.

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.

GET /actions/runs/34473891330/jobs on cli/cli, read 2026-09-20
{
  "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.

.github/workflows/e2e.yml (illustrative)
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.sh

Common 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

  1. Write down what should happen on success, on failure, on cancellation and on a skipped dependency.
  2. Anything that must survive a cancellation goes in the job it belongs to, as a step.
  3. 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.

.github/workflows/e2e.yml (illustrative)
      - name: tear down the preview
        if: always()
        run: ./teardown-preview.sh

Use 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.

.github/workflows/e2e.yml (illustrative)
  report:
    needs: test
    if: ${{ !cancelled() && needs.test.result != 'skipped' }}
    runs-on: ubuntu-latest
    steps:
      - run: ./post-summary.sh

Give 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.

.github/workflows/e2e.yml (illustrative)
      - name: tear down the preview
        if: always()
        timeout-minutes: 3
        run: ./teardown-preview.sh

What 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.

.github/workflows/e2e.yml, corrected (illustrative)
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.sh

Where 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 thingState when cancel arrivesCondition re-readResult
stepits job is runningyesruns, inside the 5 minute window
stepits job never startednoskipped with the job
jobrunningyescontinues under always()
jobqueued behind needsnonever started
jobqueued for a runnernonever 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?
Almost certainly because its job had not started. Cancellation re-evaluates conditions "for all currently running jobs" and then for the unfinished steps inside the jobs that continue. A job still queued behind its dependencies is in neither pass.
What is the difference between always() and !cancelled()?
The blanket function "Causes the step to always execute, and returns 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.
How long does a cleanup step have after a cancellation?
Five minutes for the run as a whole: "After the 5 minute cancellation timeout period, the server will forcibly terminate all jobs and steps marked for cancellation that are still running." A step that takes longer is cut off partway, so keep cleanup short and idempotent.
Does cancel-in-progress skip my cleanup job?
It cancels the run, and a cleanup job that had not started is never started. The concurrency setting is doing nothing special: a manual cancel produces the identical outcome, which is why moving the cleanup into the running job is the fix rather than changing the group.

Related guides

References

A cancelled run still bills for everything it started. Latchkey bills it at $0.0025/min at 2 vCPU. Start free → 30-day trial · No credit card