GitHub Actions success() and failure() both false after a cancel
GitHub Actions success() and failure() both false happens because each of those functions is a single equality test against the job status, and the status has four possible values rather than two. A canceled job is equal to neither, so a condition written as success() or failure() covers two of the four endings and skips the rest.

What this error means
A teardown step guarded by a condition that was meant to mean "whatever happened" does not run when somebody cancels the run. The step shows as skipped with nothing to open, the resources it was supposed to release stay allocated, and the same condition behaves perfectly on a failure, which is what makes it hard to believe. Inside a composite action the effect can be stranger still: the same expression runs on one step and skips on the next, because the two are reading different statuses.
- name: Teardown
if: ${{ success() || failure() }}
run: ./teardown.sh
# skipped when the run is canceledWhat each function actually compares
The four status functions are four small classes in the runner, and three of them have the same shape. Each reads the job status, defaults it to success when it has not been set, and compares it to exactly one enum value: success() to Success, failure() to Failure, cancelled() to Cancelled. The fourth, always(), does not read anything at all. Its evaluation method returns true.
That is the whole mechanism. A disjunction of two equality tests covers two values, and a status that holds a third is false for both. Writing success() or failure() is not a way of saying "ignore the status", it is a way of naming two of the outcomes and excluding the others.
It is worth noticing that the default matters too. When the status has not been set yet, all three treat it as success, which is why a first step with a status condition behaves as though everything upstream passed.
| Job status | success() | failure() | cancelled() | always() |
|---|---|---|---|---|
| Success | true | false | false | true |
| Failure | false | true | false | true |
| Cancelled | false | false | true | true |
| not yet set | true | false | false | true |
Common causes
The condition was meant to say "always" and named two states
The dominant cause. success() or failure() reads like a catch-all and is a list of two of the four endings. The gap only shows on a cancellation, which is rare enough in testing that the condition ships and lives for months.
The author expected the status functions to be exhaustive
Two functions with opposite names suggest a boolean. The status is not a boolean: it is an enum with a value for cancellation, and each function compares against one value, so two of them cover two values and nothing else.
The expression is inside a composite action
Within a composite action's main steps, success() and failure() are resolved against the action status rather than the job status, while cancelled() still reads the job status. An expression combining them is reading two different subjects in one line.
The work belongs to a job that was never reached
A separate problem with the same symptom. In our experience a good share of "my cleanup did not run" reports are jobs that had not started when the cancel arrived, in which case no condition would have helped and the fix is structural.
How to fix it
Name the endings you want, then pick the expression
- Write down which of the four endings the step must cover: success, failure, cancellation, or all of them.
- For all of them, use always().
- For everything except a cancellation, use the negated form with braces, which reads as not canceled.
- For a specific pair, say so explicitly rather than assuming a disjunction covers the rest.
Use always() when the step must run whatever happened
It returns true without reading anything, so it covers all four endings including a cancellation. Use it deliberately, because it also means a deployment step guarded this way will start after somebody cancels the run, which is its own page in this hub.
- name: Teardown
if: always()
run: ./teardown.shAdd cancelled() when you want three of the four
If the step should run on success, on failure and on a cancellation but not in some other case you care about, say so by adding the third function rather than hoping two cover it. The expression is longer and it is now a list of the states you actually chose.
- name: Upload diagnostics
if: ${{ success() || failure() || cancelled() }}
run: ./collect-logs.shKeep status conditions out of composite actions
Put the condition on the step in the workflow that calls the action, where all four functions read the job status. Inside an action, reserve success() and failure() for cases where you genuinely mean the action's own status, and prefer always() when you mean whatever happened.
The step condition is still evaluated after a cancel
A reasonable guess is that a canceled run simply stops evaluating things, which would make the function values irrelevant. That is not what happens. When cancellation arrives, the runner sets the job result, registers a callback that re-evaluates the current step's condition, and keeps walking the remaining steps evaluating each condition as usual.
A condition that comes back false takes the ordinary path for a false condition: the step is completed with a result of skipped. So your teardown was reached, asked, and answered no. The reason it did not run is the expression, not the cancellation.
That distinction matters because the two possible fixes are different. If the step was reached and skipped, change the expression. If the step belongs to a job that had not started when the cancel arrived, no expression can help, and the page in this hub on always() after cancellation covers that case.
The composite action twist
Inside a composite action the same two functions read something else. The runner checks whether the step is an embedded step in its main stage, and for those it resolves success() and failure() against the action status taken from the github context rather than against the job status. Pre and post steps, and steps written directly in the workflow, use the job status as usual.
cancelled() has no such branch. It reads the job status in every case. So within a composite action's main steps you have two functions looking at the action's own status and one looking at the job's, and an expression that mixes them is asking two different questions in one line.
The practical advice is to keep status conditions out of composite actions unless you mean the action's own status specifically, and to be explicit in the workflow instead, where all four functions agree about what they are reading.
runs:
using: composite
steps:
- shell: bash
if: ${{ always() }}
run: ./cleanup.shWhy there is no recorded run on this page
The trigger here is a cancellation, which is a person pressing a button or a concurrency group acting at a moment nobody chose. A recorded run would be a recording of an interruption timed by hand, and its result would be a step that is absent from the log. An absence looks identical whatever produced it, so the recording could not distinguish a step skipped by this expression from a step in a job that never started.
What settles it instead is four small classes in the runner, each a single comparison, plus the branch in the step runner that keeps evaluating conditions after a cancel. Those are three short reads that answer the question exactly, and they do not depend on when somebody pressed the button.
How to prevent it
- Treat the job status as four-valued whenever you write a status condition.
- Review every
success() || failure()in the repository, because each one has this gap. - Decide between always() and the negated form once, and write the reason in a comment.
- Put cleanup in the job that created the thing being cleaned up.