GitHub Actions if: always() runs a step after cancellation
GitHub Actions if: always() is a function that returns true and checks nothing, so a step guarded with it starts after somebody presses cancel. The companion belief, that adding any condition removes the built-in success check, is the opposite of what the parser does, and it is worth unlearning before you add success() to conditions that never needed it.

What this error means
Nothing here is an error message. The symptom is a step that ran when you did not expect it to, and the bill or the side effect that followed. Somebody cancels a run halfway through and a deployment still goes out, or a cleanup job tears down an environment that the next run needed, or a notification announces a failure for a run a person deliberately stopped. In the log the step is green and the run conclusion is cancelled, which is an unusual combination and the clearest sign that this is what you are looking at. The second half of the problem produces the mirror image: a step that never runs after a failure, because somebody added success() to a condition that already had it.
- name: Upload logs
if: always() # true in every state, cancellation included
run: ./upload-logs.sh
- name: Notify
if: ${{ !cancelled() }} # every state except cancellation
run: ./notify.shalways() does not look at anything
Three of the four status functions read the job status and compare it. The fourth does not read it. Its body has exactly one statement that produces a value, and that statement returns the boolean true, with no branch and nothing to branch on, which is why no circumstance makes it false.
The documentation says the same thing in a sentence: always "causes the step to always execute, and returns true, even when canceled". The reference goes on to warn against it directly, recommending the negated cancellation check instead for anything that should run regardless of success or failure.
// AlwaysFunction.cs, the only value-producing statement in EvaluateCore
return true;
// CancelledFunction.cs
ActionResult jobStatus = executionContext.JobContext.Status ?? ActionResult.Success;
return jobStatus == ActionResult.Cancelled;Common causes
always() was used to mean success or failure
The function name reads like natural language and the intention is almost always narrower than what it does. Teams reach for it on artifact uploads, notifications and teardown steps, and it works until the first time somebody cancels a run, at which point the teardown runs against a half-finished environment.
A status function was added that was already there
The mirror mistake, and it is caused by the belief that any condition drops the gate. Writing success() next to a branch test changes nothing, because the parser would have added it. Writing failure() next to one changes everything, because now the step runs only after a failure.
The negated form was written without braces
A leading exclamation mark is YAML tag syntax, so the bare form is rejected at parse time rather than misbehaving at run time. It looks like a problem with the function when it is a problem with the quoting.
A job condition was copied from a step condition
The two positions allow different contexts. A job-level condition accepts the four status functions but not steps, env, job, runner or the file-hashing function, so a condition that works on a step can be rejected outright when it is moved up to the job.
How to fix it
Say which states you want, then pick the expression
- Write down whether the step should run after success, after failure, and after cancellation.
- Success and failure but not cancellation is the negated cancellation check, in braces.
- Failure only is failure(). Success only is nothing at all.
- All three, including cancellation, is always(), and it should be rare.
Replace always() on anything with an external effect
Deployments, teardown, cloud resources and anything that posts to a third party are the steps where cancellation should mean stop. Swap the function and leave the rest of the step alone.
- name: Tear down preview environment
if: ${{ !cancelled() }}
run: ./teardown.shStop adding success() to ordinary conditions
A condition with no status function in it is already gated. Removing the redundant call makes the expression shorter and makes the ones that really do override the gate visible in a diff.
- name: Deploy to production
if: github.ref == 'refs/heads/main'
run: ./deploy.shCombine a status function with your own test explicitly
When you do want a step to run after a failure and only in some cases, name both halves. The status function is what turns the gate off, and the rest of the expression narrows what is left.
- name: Collect debug bundle
if: failure() && github.event_name == 'pull_request'
run: ./collect-debug.shWhat the parser does to the condition you wrote
Here is the part that is widely reported backwards. Adding a condition to a step does not remove the implicit success check. The converter parses your expression, walks the tree looking for a call to any of the four status functions, and if it finds none it wraps what you wrote rather than replacing it.
So a step with no condition gets success(), and a step conditioned on a branch name gets success() and your branch test, both. The gate goes away only when your own condition mentions a status function, because at that point the parser assumes you are stating the states yourself.
The documentation puts it plainly twice. The status-function section says "A default status check of success() is applied unless you include one of these functions", and the note under failure adds that you "must still include failure() to override the default status check of success() that is automatically applied to if conditions that don't contain a status check function".
// ConvertToIfCondition, WorkflowTemplateConverter.cs
var finalCondition = hasStatusFunction ? condition : $"{WorkflowTemplateConstants.Success}() && ({condition})";The four functions against the states you care about
The table is for a step inside a job, which is where these functions are documented and where the runner code above evaluates them. Pick the row that matches the states you want and write that expression, rather than reaching for always() and hoping.
The fourth row is what almost everybody means when they write always(): run whatever happened, unless a person stopped the run on purpose.
| Condition you write | After success | After failure | After cancel |
|---|---|---|---|
| nothing, or success() | runs | skipped | skipped |
| failure() | skipped | runs | skipped |
| always() | runs | runs | runs |
| ${{ !cancelled() }} | runs | runs | skipped |
| a plain test, no status function | test decides | skipped | skipped |
The exclamation mark needs the expression braces
One practical trap sits on top of this. YAML treats a leading exclamation mark as a tag, so the negated form cannot be written bare. The documentation gives the rule and the escapes: you "must always use the ${{ }} expression syntax or escape with '', "", or () when the expression starts with !, since ! is reserved notation in YAML format".
That is why every correct example of the negated check on this page carries the braces while the plain function calls do not. It is also why a condition that starts with a negation is worth keeping on its own line rather than folding it into a longer expression where the leading character changes.
- name: Publish test report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: junit
path: reports/Not everyone agrees the negated form is the right advice
GitHub recommends the negated cancellation check. The ghlint rule set disagrees with the shape while agreeing about always(), and it is worth knowing that the disagreement exists before you standardize on one of them across a repository.
Its rule NeverUseAlways is titled "Using always() is discouraged." and gives the reason in the terms that matter here: "Implying cancelled() via always() is risky when the step affects something external. If someone manually cancels a workflow run, they explicitly expressed they don't want its effects to happen, but always() will still execute the steps." A second rule in the same file then flags negated status checks, preferring the explicit pair of states instead.
Why there is no recorded run on this page
The failure this page describes is a step doing exactly what it was told, so there is nothing to reproduce and nothing to repair. A recorded run would show a green step inside a run that a person stopped, which is the thing you already saw. Every workflow fragment here is illustrative, and the behavior each one claims is traced to the function that decides it.
How to prevent it
- Treat always() as a keyword that needs a comment explaining why cancellation is included.
- Use the negated cancellation check for uploads, reports and notifications.
- Never put a status function next to a branch or event test unless you mean to drop the gate.
- Grep the repository for always() after any incident involving a canceled run.
Frequently asked questions
Does adding an if condition remove the implicit success check?
Which steps should actually use always()?
Why does if: !cancelled() fail to parse?
Can I use always() on a job as well as on a step?
Related guides
References
- GitHub Actions: expressions reference, status check functions
- actions/runner: AlwaysFunction, CancelledFunction, SuccessFunction and FailureFunction
- actions/runner: ConvertToIfCondition, which wraps a condition in success()
- ghlint: ImplicitStatusCheckRule, the NeverUseAlways and NegativeStatusCheck rules
- GitHub Actions documentation