Copyright (c) 2026 MindMesh Academy. All rights reserved. This content is proprietary and may not be reproduced or distributed without permission.

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:

FunctionRuns 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?

See how it connects
Alvin Varughese
Written byAlvin Varughese
Founder•20 professional certifications