Skip to content
Latchkey LogoLatchkey home

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.

Four job statuses against the four status functions, showing which return true for each
Three of the four functions are equality tests against one value each. Only always() returns true without looking, which is why the disjunction leaves a gap.

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.

Illustrative step, not a log line
- name: Teardown
  if: ${{ success() || failure() }}
  run: ./teardown.sh
# skipped when the run is canceled

What 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 statussuccess()failure()cancelled()always()
Successtruefalsefalsetrue
Failurefalsetruefalsetrue
Cancelledfalsefalsetruetrue
not yet settruefalsefalsetrue

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

  1. Write down which of the four endings the step must cover: success, failure, cancellation, or all of them.
  2. For all of them, use always().
  3. For everything except a cancellation, use the negated form with braces, which reads as not canceled.
  4. 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.

.github/workflows/e2e.yml (illustrative)
- name: Teardown
  if: always()
  run: ./teardown.sh

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

.github/workflows/e2e.yml (illustrative)
- name: Upload diagnostics
  if: ${{ success() || failure() || cancelled() }}
  run: ./collect-logs.sh

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

.github/actions/deploy/action.yml (illustrative)
runs:
  using: composite
  steps:
    - shell: bash
      if: ${{ always() }}
      run: ./cleanup.sh

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

Frequently asked questions

Why are success() and failure() both false when a run is canceled?
Because each one is a single equality test. success() compares the job status to Success and failure() compares it to Failure, so a status of Cancelled satisfies neither. They are two of four possible values rather than two halves of a boolean.
Does GitHub stop evaluating step conditions after a cancel?
No. The runner sets the job result, re-evaluates the condition of the step that was running, and continues evaluating the conditions of the remaining steps. A step whose condition returns false is completed as skipped, which is why the teardown was reached and answered no.
What is the difference between always() and success() || failure()?
always() returns true without reading the status at all, so it covers every ending. The disjunction covers exactly two of the four values and excludes a cancellation, which is the gap this page is about.
Do these functions behave differently inside a composite action?
Yes for two of them. For an embedded main step, success() and failure() are resolved against the action status from the github context rather than against the job status, while cancelled() reads the job status in every case. Mixing them inside an action asks two different questions in one expression.

Related guides

References

Two tests do not cover four endings, and the resources are still up. Latchkey covers the runs. Start free → 30-day trial · No credit card