Actio
Macros

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:

.actio.yml
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy
        run: ./deploy.sh
        fallback:
          - name: Notify
            run: ./notify.sh failure
generated .yml
steps:
  - name: Deploy
    run: ./deploy.sh
    id: step_deploy
  - name: Notify
    run: ./notify.sh failure
    if: failure() && steps.step_deploy.conclusion == 'failure'
.actio.yml
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy
        run: ./deploy.sh
        fallback:
          - name: Notify
            run: ./notify.sh failure
generated .yml
steps:
  - 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:

.actio.yml
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-cores
generated .yml
jobs:
  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.sh
.actio.yml
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-cores
generated .yml
jobs:
  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.sh

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

.actio.yml
- name: Run tests
  run: ./run-tests.sh
  fallback:
    retry:
      runs-on: ubuntu-latest-16-cores
      when-exit-code: [137]
generated .yml
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
.actio.yml
- name: Run tests
  run: ./run-tests.sh
  fallback:
    retry:
      runs-on: ubuntu-latest-16-cores
      when-exit-code: [137]
generated .yml
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

when-exit-code accepts a single number or a list ([137, 143], OR-joined).

Limitations (v1)

  • when-exit-code is run-only and POSIX-shell-only. It relies on a shell EXIT trap, so it's skipped (with a warning) on uses: steps or non-POSIX shells; the retry then falls back to a plain if: 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 _retry job is emitted; there's no retry-of-the-retry.
  • Step-level only. retry: on a job-level fallback is ignored (warning).

On this page