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.

⚠️ 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
Founder18 professional certifications