# GitHub Actions always() skipped when canceled

> GitHub Actions always() skipped when canceled comes down to when the condition is read. See the cancellation sequence, then guard work that counts.

Source: https://latchkey.dev/learn/github-actions/github-actions-if-always-skipped-when-canceled  
Updated: 2026-09-20

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.

```GET /actions/runs/34473891330/jobs on cli/cli, read 2026-09-20
{
  "name": "acceptance (ubuntu-latest)",
  "status": "completed",
  "conclusion": "cancelled",
  "steps": []
}
```

## 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
```

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

## 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
```

## 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 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 |

> If the job skipped without a cancellation anywhere in sight, the dependency result is the subject instead: [GitHub Actions job has been skipped (needs)](/learn/github-actions/github-actions-job-skipped-needs-result).

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

## FAQ

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

## References

- [GitHub Actions: workflow cancellation reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-cancellation)
- [GitHub Actions: expressions, status check functions](https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#status-check-functions)
- [actions/runner#3664: always() fails to run if needs jobs are skipped](https://github.com/actions/runner/issues/3664)
- [GitHub Actions: contexts reference, context availability](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#context-availability)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
