2.2.1. Jobs, Steps, Dependencies, and Conditionals
💡 First Principle: Failure propagation is the default in Actions — a failed step fails its job, a failed job skips everything downstream of it — and if: conditions with status functions are how you deliberately override that default for the cases where you want cleanup, notification, or partial success.
needs: accepts one job or a list, and the resulting graph can fan out and back in freely. What matters is what happens when part of it fails. By default a job with needs: [build] is skipped if build fails. To change that you attach a condition using one of four status check functions:
| Function | Runs when |
|---|---|
success() | All previous steps/needs succeeded — the implicit default |
failure() | Some previous step/need failed |
cancelled() | The run was cancelled |
always() | Unconditionally — even on failure or cancellation |
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: make build
notify:
needs: build
if: ${{ always() }} # runs whether build passed, failed, or was cancelled
runs-on: ubuntu-latest
steps:
- run: ./scripts/notify.sh "${{ needs.build.result }}"
Two nuances worth memorizing. Inside if: at the job or step level the ${{ }} wrapper is optional — GitHub evaluates the expression either way — and you'll see both forms in exam snippets. And continue-on-error: true on a step lets the step fail without failing the job (the step still shows as failed), while on a job it lets the job fail without failing the whole run; that's how you mark an experimental matrix variant non-blocking.
The result of an upstream job is available as needs.<job_id>.result (success, failure, cancelled, or skipped), which is what a notification or rollback job branches on.
⚠️ Exam Trap: if: failure() on a job does not fire when its dependency was skipped — skipped is a distinct result from failed. When a scenario needs cleanup after any outcome including skips, always() is the answer, optionally combined with a needs.<job>.result check.
Reflection Question: Why does GitHub make skipping (not failing) the default for a job whose dependency failed — what would break if downstream jobs ran anyway with a failed dependency?