Skip to content
Latchkey

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

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.

What this error means

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.

.github/workflows/ci.yml
- run: ./upload-logs.sh
  if: always()        # also runs when the job is CANCELED, not just on failure
# wanted: run on success or failure, but not on cancel

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) }}'

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 itContexts available there
run-namegithub, inputs, vars
concurrencygithub, inputs, vars
Top-level envgithub, secrets, inputs, vars
jobs.<id>.ifgithub, needs, vars, inputs
jobs.<id>.steps.ifgithub, needs, strategy, matrix, job, runner, env, vars, steps, inputs
jobs.<id>.outputsFull access, including secrets
Reusable workflow outputsgithub, jobs, vars, inputs

Common causes

always() includes the canceled state

always() means run regardless of status - including cancellation. To run after success or failure but skip cancellation, use !cancelled() instead.

Any if: drops the implicit success() gate

Without an if:, a step only runs if prior steps succeeded. The moment you add any if: (for example if: github.ref == ...), that default gate is gone, so the step can run after a failure unless you re-add success().

How to fix it

Use the function that matches the states you want

Combine status functions explicitly so the step runs in exactly the intended states.

.github/workflows/ci.yml
# run on success or failure, but NOT on cancel
- run: ./notify.sh
  if: ${{ !cancelled() }}
# run only when something failed
- run: ./collect-debug.sh
  if: failure()

Re-add success() when you add another condition

  1. When gating on a branch/event, keep the success gate: if: success() && github.ref == 'refs/heads/main'.
  2. Use failure() for steps that should only run after an earlier failure.
  3. Reserve always() for steps that must run even when the run is canceled.

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

How to prevent it

  • Prefer !cancelled() over always() unless you truly want cancel included.
  • Remember adding any if: removes the implicit success() gate - re-add it if needed.
  • Use failure()/success() explicitly for conditional cleanup and notifications.

Frequently asked questions

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.

Related guides

References

Not every red build is your code. Latchkey repairs the ones that are not, on the runner. Start free → 30-day trial · No credit card