fallback
A native try/catch for steps and jobs.
A native try/catch. Attach fallback: to a step (or a job) to recover or notify
when it fails. To simply tolerate a failure and continue, see
soft_fail; for teardown that runs regardless of outcome,
see lifecycle.
Options
fallback: is either a list of steps (the catch body, shown below) or an object
with these keys:
Prop
Type
Default = notify
The error is not swallowed; fallback runs via if: failure(), the job still fails.
The guarded step gets an id; the fallback is gated on its conclusion:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy
run: ./deploy.sh
fallback:
- name: Notify
run: ./notify.sh failuresteps:
- name: Deploy
run: ./deploy.sh
id: step_deploy
- name: Notify
run: ./notify.sh failure
if: failure() && steps.step_deploy.conclusion == 'failure'jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy
run: ./deploy.sh
fallback:
- name: Notify
run: ./notify.sh failuresteps:
- name: Deploy
run: ./deploy.sh
id: step_deploy
- name: Notify
run: ./notify.sh failure
if: failure() && steps.step_deploy.conclusion == 'failure'recover: true
recover: true = true try/catch: the guarded step gets continue-on-error: true
and the fallback uses steps.<id>.outcome == 'failure', so the job can continue.
outcome vs conclusion
With continue-on-error, a failed step's conclusion becomes success, so
recovery must key off outcome. Actio gets this right for you.
retry: different runner
Some failures aren't your code — they're the runner (OOM kills, flaky hosted
infra, a job that needs more cores). retry: re-runs the same step in a brand
new job on a different runs-on, gated on the original job failing.
fallback.retry vs the retry macro
Don't confuse this with the standalone retry macro, which
re-runs the same step on the same runner N times for transient flakiness.
fallback.retry runs the step once on a different runner, for infra-level
failures.
The guarded step is lifted into a sibling <job>_retry job with
needs: [<job>] and if: failure(), running on the fallback runner:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build
run: ./build.sh
fallback:
retry:
runs-on: ubuntu-latest-8-coresjobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build
run: ./build.sh
build_retry:
name: Retry "Build" on ubuntu-latest-8-cores
needs:
- build
if: failure()
runs-on: ubuntu-latest-8-cores
steps:
- name: Build
run: ./build.shjobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build
run: ./build.sh
fallback:
retry:
runs-on: ubuntu-latest-8-coresjobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build
run: ./build.sh
build_retry:
name: Retry "Build" on ubuntu-latest-8-cores
needs:
- build
if: failure()
runs-on: ubuntu-latest-8-cores
steps:
- name: Build
run: ./build.shwhen-exit-code
Only retry on specific exit codes (e.g. 137 = SIGKILL/OOM) instead of any
failure. Actio captures the original step's real exit code via an EXIT trap,
exposes it as a job output, and gates the retry job on it.
- name: Run tests
run: ./run-tests.sh
fallback:
retry:
runs-on: ubuntu-latest-16-cores
when-exit-code: [137]jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Run tests
run: |-
trap 'echo "exit_code=$?" >> "$GITHUB_OUTPUT"' EXIT
./run-tests.sh
id: step_run_tests
outputs:
step_run_tests_exit_code: ${{ steps.step_run_tests.outputs.exit_code }}
test_retry:
name: Retry "Run tests" on ubuntu-latest-16-cores
needs:
- test
if: failure() && needs.test.outputs.step_run_tests_exit_code == '137'
runs-on: ubuntu-latest-16-cores
steps:
- name: Run tests
run: ./run-tests.sh- name: Run tests
run: ./run-tests.sh
fallback:
retry:
runs-on: ubuntu-latest-16-cores
when-exit-code: [137]jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Run tests
run: |-
trap 'echo "exit_code=$?" >> "$GITHUB_OUTPUT"' EXIT
./run-tests.sh
id: step_run_tests
outputs:
step_run_tests_exit_code: ${{ steps.step_run_tests.outputs.exit_code }}
test_retry:
name: Retry "Run tests" on ubuntu-latest-16-cores
needs:
- test
if: failure() && needs.test.outputs.step_run_tests_exit_code == '137'
runs-on: ubuntu-latest-16-cores
steps:
- name: Run tests
run: ./run-tests.shwhen-exit-code accepts a single number or a list ([137, 143], OR-joined).
Limitations (v1)
when-exit-codeisrun-only and POSIX-shell-only. It relies on a shellEXITtrap, so it's skipped (with a warning) onuses:steps or non-POSIX shells; the retry then falls back to a plainif: failure()gate.- The guarded step must be self-contained. Preceding in-job setup steps (checkout, tool installs, etc.) are not copied into the retry job. Put any setup the step needs inside the step, or this mode isn't a fit.
- The workflow still goes red. The retry job is an independent recovery
lane — a green sibling cannot un-fail the original job. Downstream jobs that
needs:the original are unaffected and will not see the retry's result. - One retry, no loop. Exactly one
_retryjob is emitted; there's no retry-of-the-retry. - Step-level only.
retry:on a job-levelfallbackis ignored (warning).