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.
A step either uses: an action or run:s a command — never both — and every run: step executes through a shell. shell: selects which one, and the valid keywords are a closed set: bash, pwsh, python, sh, plus cmd and powershell on Windows only. You can also give a custom template such as shell: perl {0}. Omit the key and you get the runner's default — bash on Linux and macOS, pwsh on Windows — and defaults.run.shell sets it once for a whole workflow or job rather than per step.
⚠️ Exam Trap: shell: names a shell, not a language runtime. There is no shell: nodejs or shell: node; a step running npm install needs no shell: at all, because npm is just a command the default shell invokes. When two options differ only in that one attaches a runtime to shell:, the one without it is correct — the plausible-looking command beside it is bait.
⚠️ 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?