DEV Community

Cover image for GitHub Actions always() skipped when canceled
Kay
Kay

Posted on Originally published at latchkey.dev

GitHub Actions always() skipped when canceled

GitHub Actions always() skipped when canceled is almost never the function: always() returns true on a cancel, so a job or step it guards still runs. When the cleanup did not run, something stricter decided it: a condition such as always() && needs.deploy.result == 'success', a force-cancel, or the cancellation timeout.

The cancellation sequence and a table of which conditions are read, and when, after a cancel
A cancel re-reads conditions and always() keeps answering true. The two rows where cleanup does not run are a stricter condition and a force-cancel.

What this error means

You cancel a run, or a new push cancels it through a concurrency group, and the cleanup you guarded with always() does not happen. Either the teardown job shows as skipped with no steps to open, or the cleanup step starts and stops partway. The same workflow behaves correctly on a failure, which sends people looking at the function. Look at the rest of the condition and at how the run was stopped instead. always() answers true on a cancel, and the documentation warns about the opposite problem: a job guarded by it can keep a canceled run going until it times out.

```GET /actions/runs/34473891330/jobs on cli/cli, read 2026-09-20
{
"name": "acceptance (ubuntu-latest)",
"status": "completed",
"conclusion": "cancelled",
"steps": []
}




## Common causes

### The condition adds a check that a cancel makes false

The common one. `always() && needs.deploy.result == 'success'` is false when `deploy` was canceled, and `success() || failure()`, often written to mean the same as `always()` without the cancel case, is false precisely when the run is canceled. The `always()` part said yes; the rest of the expression said no.

### The run was force-canceled

The force-cancel endpoint exists for runs that do not respond to an ordinary cancel, and it is documented as bypassing an `always()` condition on a job. A run stopped that way skips every cleanup, by design. Check who or what canceled it before changing the workflow.

### The cleanup was the step being interrupted

A step that is running when the cancel arrives is the one that gets `SIGINT`, then `SIGTERM` after 7500 ms, then a kill 2500 ms later. A long teardown that was already in progress is cut off, and after the five minute timeout the server terminates whatever is still running. That looks like a step that did not run if you only check the outcome.

### The condition was never an override at all

A status function has to be present for the default success check to be replaced. A plain comparison on a dependency result leaves the default in place, so the job is disqualified before your comparison is considered. In our experience this arrives after someone removes the status function while simplifying a long condition.

## How to fix it

### Decide which of the four endings each piece of work covers

1. Write down what should happen on success, on failure, on cancellation and on a skipped dependency.
2. Anything that must survive a cancellation gets a bare `always()`, with no result check beside it.
3. Anything that must not run after a cancellation gets `!cancelled()`, not `always()`.

### Let always() stand alone and make the cleanup idempotent

Move the question of what exists out of the condition and into the script. A teardown that checks for the environment and exits cleanly when there is nothing to remove can run after every ending, which is what `always()` is for.



```.github/workflows/e2e.yml (illustrative)
teardown:
    needs: [deploy-preview, test]
    if: always()
    runs-on: ubuntu-latest
    steps:
      - run: ./teardown-preview.sh
Enter fullscreen mode Exit fullscreen mode

Use the negated form for jobs that should not run after a cancel

The blanket function is right only when a canceled run should still do this work. For everything else the recommended alternative overrides the default success check without opting into the cancellation path, and a comparison on the dependency result says the rest.

```.github/workflows/e2e.yml (illustrative)
report:
needs: test
if: ${{ !cancelled() && needs.test.result != 'skipped' }}
runs-on: ubuntu-latest
steps:
- run: ./post-summary.sh




### Give the cleanup its own budget

A teardown that talks to a cloud API can outlast the cancellation window and be killed partway, which is worse than not starting. Put a timeout on the step comfortably under the window, and back it with a scheduled sweep that finishes what the step did not.



```.github/workflows/e2e.yml (illustrative)
- name: tear down the preview
        if: always()
        timeout-minutes: 3
        run: ./teardown-preview.sh
Enter fullscreen mode Exit fullscreen mode

How to prevent it

  • Keep cleanup conditions to a bare always() and put the logic about what exists in the script.
  • Prefer !cancelled() on jobs that should not run after a cancel, and keep always() for work that must.
  • Keep every cleanup idempotent and short, because it may run twice or be cut off in the middle.
  • Back a preview environment with a scheduled sweep, so no single run is the only thing standing between you and a leak.

A minimal workflow that produces it

This file is written for this page and has never been run. It is a common shape: a job that creates a preview environment, tests against it, and a teardown job behind both. The teardown condition starts with always() and then adds a check on the deploy result, so that it only tears down what was created.

Cancel it while deploy-preview is still running and that job ends as cancelled. The teardown is evaluated once its needs have finished, always() returns true, the comparison returns false, and the job is skipped, leaving a half-created preview behind. Cancel it during test instead and the deploy result is success, so the same teardown runs.

```.github/workflows/e2e.yml (illustrative)
name: e2e
on: pull_request
concurrency:
group: e2e-${{ github.ref }}
cancel-in-progress: true

jobs:
deploy-preview:
runs-on: ubuntu-latest
steps:
- run: ./deploy-preview.sh

test:
needs: deploy-preview
runs-on: ubuntu-latest
steps:
- run: npm run test:e2e

teardown:
needs: [deploy-preview, test]
if: always() && needs.deploy-preview.result == 'success'
runs-on: ubuntu-latest
steps:
- run: ./teardown-preview.sh




## What GitHub does when a run is canceled

The workflow cancellation reference sets out the sequence. Step one: "To cancel the workflow run, the server re-evaluates `if` conditions for all currently running jobs. If the condition evaluates to `true`, the job will not get canceled. For example, the condition `if: always()` would evaluate to true and the job continues to run." Step three covers the steps inside those jobs: "For jobs that continue to run, the server re-evaluates `if` conditions for the unfinished steps."

Step four is what happens to the step that is running at that moment: the runner sends `SIGINT` to its process, then `SIGTERM` if it has not exited within 7500 ms, then kills the process tree if it is still there 2500 ms later. And step five is the ceiling on all of it: "After the 5 minute cancellation timeout period, the server will forcibly terminate all jobs and steps marked for cancellation that are still running."

Nothing in that sequence skips an `always()` condition. The expressions reference says the function "returns `true`, even when canceled", and the `needs` reference gives it as the way to run a job "even if a job it is dependent on did not succeed". The one documented path that ignores it is the force-cancel endpoint, which "bypasses conditions that would otherwise cause a workflow execution to continue, such as an always() condition on a job."

## Guard the work where it will actually be reached

The corrected file lets `always()` stand alone on the teardown and moves the decision into the script: tear down whatever exists. A cleanup that is safe to run when the preview was never created, half created or fully created does not need to read the deploy result at all, so no ending of the run can skip it.

Cleanup that belongs to one job, such as uploading its traces, goes inside that job as a step with `if: always()`, which the cancellation sequence re-reads while the job is running. A job that should not run after a cancel gets the documented alternative instead: "If you want to run a job or step regardless of its success or failure, use the recommended alternative: `if: ${{ !cancelled() }}`."



```.github/workflows/e2e.yml, corrected (illustrative)
jobs:
  test:
    needs: deploy-preview
    runs-on: ubuntu-latest
    steps:
      - run: npm run test:e2e
      - if: always()
        uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: traces
          path: traces/

  teardown:
    needs: [deploy-preview, test]
    if: always()
    runs-on: ubuntu-latest
    steps:
      - run: ./teardown-preview.sh   # exits 0 when there is nothing to remove

  report:
    needs: test
    if: ${{ !cancelled() }}
    runs-on: ubuntu-latest
    steps:
      - run: ./post-summary.sh
Enter fullscreen mode Exit fullscreen mode

Where each condition is read, and what that costs you

The table is the documented sequence applied to the shapes people write. Read down the last column for your cleanup: only two rows end with it not running, and neither is caused by always() on its own.

The same rule produces the mirror-image complaint, a job guarded with always() that starts after somebody cancels, or keeps a canceled run open. Its condition was read, it returned true, and the job did what the condition said. Community discussion 26303 is that report, where the accepted answer is that always() work is not cancelable and the suggested change is !cancelled().

Guarded thing When the cancel arrives Condition read Result
step with always() its job is running yes, re-read runs, inside the 5 minute window
job with always() running yes, re-read keeps running
job with always() waiting on needs when its needs finish starts
job with always() && needs.x.result == 'success' waiting on needs when its needs finish skipped if x was canceled
any job or step force-cancel bypassed stopped

If the job skipped without a cancellation anywhere in sight, the dependency result is the subject instead: GitHub Actions job has been skipped (needs).

Why there is no recorded run on this page

A cancellation is not a failure, so there is nothing to reproduce and nothing to repair. The server applied the sequence above, and a runner cannot retry its way out of a decision made by a condition. The payload at the top is a real canceled job from the public cli/cli repository, read 2026-09-20. It shows what a job with no overriding condition looks like when the cancel reaches it first: canceled, with an empty steps array, because no step ever started.

FAQ

Why did my if: always() step not run when I canceled the workflow?

Check three things in order: whether the condition contains anything besides always(), whether the run was force-canceled, and whether the cleanup was the step already running when the cancel arrived. always() itself returns true on a cancel, and the cancellation sequence re-reads conditions for running jobs and their unfinished steps.

What is the difference between always() and !cancelled()?

The blanket function "Causes the step to always execute, and returns true, even when canceled", so a running job guarded with it continues after a cancel. The negated form is true in every case except a cancellation, and the expressions reference recommends it for work that should run regardless of success or failure.

How long does a cleanup step have after a cancellation?

Five minutes for the run as a whole: "After the 5 minute cancellation timeout period, the server will forcibly terminate all jobs and steps marked for cancellation that are still running." A step that is running when the cancel arrives gets SIGINT, then SIGTERM after 7500 ms, then a kill 2500 ms later.

Does cancel-in-progress skip my cleanup job?

Not by itself. It cancels the older run the way a manual cancel does, so a cleanup job guarded by a bare always() is still evaluated when its needs finish and the function is still true. If yours was skipped, read the rest of its condition and its needs results.

References

Top comments (0)