# GitHub Actions if: always() Runs a Step Even After Cancellation

> Fix GitHub Actions if: conditions using always(), success(), failure(), and cancelled() - always() runs even on cancel, and a bare if overrides the default success gate.

Source: https://latchkey.dev/learn/github-actions/gha-if-always-vs-success-misuse  
Updated: 2026-06-25

Status-check functions in if: do not behave the way they read. always() also runs when a run is canceled, and adding any if: removes the implicit success() gate, so a step you meant to gate now runs in states you did not expect.

## Diagnose it: print the context before you change anything

Most workflow-expression bugs are not syntax errors, they are an expression reading something that is empty. GitHub resolves a missing property to an empty string instead of failing the run, so a wrong reference looks like a logic bug rather than a mistake. Dump the contexts first and you will usually see the answer immediately.

```.github/workflows/ci.yml
- name: Dump contexts
  run: |
    echo '--- github ---'   ; echo '${{ toJSON(github) }}'
    echo '--- needs ---'    ; echo '${{ toJSON(needs) }}'
    echo '--- steps ---'    ; echo '${{ toJSON(steps) }}'
    echo '--- matrix ---'   ; echo '${{ toJSON(matrix) }}'
    echo '--- inputs ---'   ; echo '${{ toJSON(inputs) }}'
```

> An empty `{}` or a blank line is the finding. It means the context is not populated at that point, which is a different problem from the value being wrong, and it needs a different fix.

## Check the context is allowed where you used it

Contexts are not available everywhere. The same expression can be valid in a step `if` and invalid in a job `if`, which is why an expression that works in one workflow fails when moved.

| Where you wrote it | Contexts available there |
| --- | --- |
| `run-name` | `github`, `inputs`, `vars` |
| `concurrency` | `github`, `inputs`, `vars` |
| Top-level `env` | `github`, `secrets`, `inputs`, `vars` |
| `jobs.<id>.if` | `github`, `needs`, `vars`, `inputs` |
| `jobs.<id>.steps.if` | `github`, `needs`, `strategy`, `matrix`, `job`, `runner`, `env`, `vars`, `steps`, `inputs` |
| `jobs.<id>.outputs` | Full access, including `secrets` |
| Reusable workflow `outputs` | `github`, `jobs`, `vars`, `inputs` |

> The most common trap in this table: `steps` and `matrix` are available in a **step** `if` but not in a **job** `if`. Moving a condition up a level silently breaks it.

## Catch it before it reaches CI

Every failure in this cluster is statically detectable. `actionlint` parses workflow expressions, checks context availability against the same rules above, and validates `needs` references, so these bugs never need to cost you a run.

```Terminal
# one-off
docker run --rm -v "$(pwd):/repo" --workdir /repo rhysd/actionlint:latest -color

# as a job, before anything expensive runs
- uses: actions/checkout@v4
- run: |
    bash <(curl -s https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)
    ./actionlint -color
```

## FAQ

### What causes GitHub Actions if: always() runs a step even after cancellation?

There are 2 common causes: always() includes the canceled state and any if: drops the implicit success() gate. always() means run regardless of status - including cancellation.

### How do I fix GitHub Actions if: always() runs a step even after cancellation?

There are 2 fixes depending on which cause you have: use the function that matches the states you want and re-add success() when you add another condition. Work through them in order, since the first is the most common.

### What does GitHub Actions if: always() runs a step even after cancellation actually mean?

A cleanup or notification step runs even when the workflow was canceled, or a step runs after a previous failure because adding an if: silently dropped the default "only on success" behavior.

### How do I stop GitHub Actions if: always() runs a step even after cancellation happening again?

Prefer !cancelled() over always() unless you truly want cancel included. The prevention section lists 3 changes that keep it from recurring.

---

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
