Skip to content
Latchkey LogoLatchkey home

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.

Four status functions against three run states, beside the check the parser adds
The grid on the left is the table reproduced in the body of this page. The rewrite on the right is quoted from ConvertToIfCondition in actions/runner.

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.

.github/workflows/ci.yml (illustrative)
- 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.sh

always() 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.

actions/runner, src/Runner.Worker/Expressions
// 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

  1. Write down whether the step should run after success, after failure, and after cancellation.
  2. Success and failure but not cancellation is the negated cancellation check, in braces.
  3. Failure only is failure(). Success only is nothing at all.
  4. 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.

.github/workflows/ci.yml (illustrative)
- name: Tear down preview environment
  if: ${{ !cancelled() }}
  run: ./teardown.sh

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

.github/workflows/ci.yml (illustrative)
- name: Deploy to production
  if: github.ref == 'refs/heads/main'
  run: ./deploy.sh

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

.github/workflows/ci.yml (illustrative)
- name: Collect debug bundle
  if: failure() && github.event_name == 'pull_request'
  run: ./collect-debug.sh

What 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".

actions/runner, Conversion/WorkflowTemplateConverter.cs
// 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 writeAfter successAfter failureAfter cancel
nothing, or success()runsskippedskipped
failure()skippedrunsskipped
always()runsrunsruns
${{ !cancelled() }}runsrunsskipped
a plain test, no status functiontest decidesskippedskipped

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.

.github/workflows/ci.yml (illustrative)
- 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?
No, and this is the belief worth correcting. The converter parses your expression, looks for a call to success, failure, cancelled or always, and wraps the expression in a success check when it finds none. Only a condition that already names one of the four turns the gate off.
Which steps should actually use always()?
Very few. It belongs on work that must happen even when a person deliberately stops the run, such as releasing a lock that would otherwise block the next run. Anything that costs money, deploys, or talks to a third party should stop when somebody cancels the run, which means the negated cancellation check instead.
Why does if: !cancelled() fail to parse?
Because an exclamation mark at the start of a YAML scalar is tag syntax, not part of your expression. Write it inside the expression braces, or wrap it in quotes or parentheses. The functions themselves are fine either way; only the leading character is the problem.
Can I use always() on a job as well as on a step?
Yes. A job-level condition accepts the same four status functions, and the same wrapping applies, so a job with a plain condition is still gated on success. The contexts available differ, though, so an expression that mixes a status function with a step result will be accepted on a step and rejected on a job.

Related guides

References

always() does not look at anything before it starts your deploy. Latchkey runs it at $0.0025/min. Start free → 30-day trial · No credit card