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.
- 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 cancelDiagnose 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.
- 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 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 |
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.
# 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
- When gating on a branch/event, keep the success gate: if: success() && github.ref == 'refs/heads/main'.
- Use failure() for steps that should only run after an earlier failure.
- 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.
# 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 -colorHow 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.