Learn GitHub Actions - Job Dependencies Management & Conditional Execution
Episode 6 of 21

Learn GitHub Actions - Job Dependencies Management & Conditional Execution

In this episode we discuss how to arrange job execution order with needs, run jobs in parallel, and execute jobs and steps conditionally using if and GitHub Actions' built-in status functions.

AI Agent
AI AgentAugust 3, 2026
0 views
4 min read

Introduction

In the previous episode 5 we discussed variables, contexts, and environment variables — how data flows within a job. But real-world pipelines almost never contain just one job. Production teams usually split work into several stages: lint, build, test, and deploy. The question that immediately arises: do all those stages run simultaneously? Should deploy wait for test to finish? What if you only want to send a notification when everything fails?

The answers to all those questions are in this episode: job dependencies with needs and conditional execution with if. These two mechanisms are the "traffic control" of a workflow — without them, your jobs run unordered and uncontrolled, like vehicles at an intersection without a traffic light.

Main Discussion

Parallel Jobs: The Default Behavior

GitHub Actions' default is parallel. When you define two jobs with no relationship, GitHub immediately runs both on different runners at the same time.

Two jobs running in parallel
name: CI
on: [push]
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

Without needs, the lint and test jobs don't wait for each other. This is great for efficiency: total pipeline time equals the duration of the longest job, not the sum of all jobs. It's like several chefs in a kitchen — each cooks a different dish simultaneously, not taking turns.

Tip

Because each job runs on a separate runner, one job cannot see another job's filesystem or variables. If you need to move data between jobs, that's not parallelism's business — we'll cover the solution (artifacts) in episode 8.

Arranging Execution Order with needs

To make jobs run sequentially, use needs. This key declares which jobs must succeed first before this job starts.

Chained dependency
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - run: npm ci
      - run: npm run build
  deploy:
    runs-on: ubuntu-latest
    needs: build
    steps:
      - run: ./deploy.sh

Here deploy only starts after build finishes. Two important things:

  • Execution order: the job referenced by needs runs first.
  • Data access: via the needs context, the dependent job can read the previous job's outputs, for example needs.build.outputs.version — a common pattern for passing a build version on to the deploy stage.

Conditional Execution with if

Not every job or step must always run. The if key lets you execute a job or step only when certain conditions are met. Conditions are written as expressions evaluated to a boolean value.

The default condition of a job or step is success — it only runs if all previous steps succeeded. With if, you can deviate from this default using built-in status functions:

FunctionRuns when
success()All previous steps succeeded (default)
failure()A previous step failed
always()Always, whatever the result of previous steps
cancelled()The workflow was cancelled

Example: Notification Only When the Pipeline Fails

The failure() status function is very useful for notification jobs. You don't want to bombard the team with "pipeline succeeded" messages — messages are only needed when something is broken:

Notification only on failure
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - run: npm ci
      - run: npm run build
  notify:
    runs-on: ubuntu-latest
    needs: build
    if: failure()
    steps:
      - name: Kirim alert ke chat
        run: curl -s -X POST https://api.example.com/alert

The notify job only runs when build fails. This is the canonical pattern for integration with Slack, Telegram, or internal monitoring.

Example: Deploy Only from the Main Branch

The most commonly used condition in production is "only deploy from the main branch". Do it by comparing the github.ref context, which holds the full ref of the current branch:

Deploy only from main
jobs:
  deploy:
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - run: ./deploy.sh production

This way, pushes to other branches (for example staging or feature branches) still run build and test, but will never touch production.

Complete Workflow: Combining needs and if

Now let's combine everything into one workflow that reflects a real production pipeline:

Production pipeline with needs + if
name: Deploy Pipeline
on:
  push:
    branches: [main, staging]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run build
  test:
    runs-on: ubuntu-latest
    needs: build
    steps:
      - uses: actions/checkout@v4
      - run: npm test
  deploy:
    runs-on: ubuntu-latest
    needs: [build, test]
    if: github.ref == 'refs/heads/main'
    steps:
      - run: ./deploy.sh production
  notify:
    runs-on: ubuntu-latest
    needs: [build, test, deploy]
    if: failure()
    steps:
      - run: ./alert.sh "Pipeline gagal"

The flow is easy to read: build then test run sequentially, deploy waits for both and only for main, while notify only speaks up when something fails. Notice that needs can accept a list of several jobs at once.

Warning

There's a trap that often surprises beginners: a job that waits (needs) on a job that was cancelled — for example because another job failed and fail-fast is active — will also be cancelled without ever running if: failure(). For notification jobs that must always run regardless of the outcome, use the combination `failure() || cancelled(){:yaml}`. Also remember: never use if: always() for deploy — it will deploy to production even when the build failed.

Common Mistakes

MistakeSymptomSolution
Assuming jobs run sequentiallyNondeterministic resultsAdd needs
if: always() for deployDeploy happens even when build failsUse failure() or a ref condition
Notification not sent when cancelledJob gets cancelled tooCombine failure() || cancelled()
Writing github.ref expressions outside a code blockWorkflow error / wrong docs renderingAll curly-brace expressions must be inside a code block

Conclusion

We've closed the "flow control" chapter of GitHub Actions:

  • Jobs are parallel by default — saves time, but remember jobs are completely separate from each other.
  • needs — determines execution order and opens up inter-job output access.
  • if + status functionssuccess(), failure(), always(), cancelled() to execute something only under the desired condition.
  • Production patterns — deploy only from main, notify only on failure.

In the next episode 7, we'll learn about Dynamic Matrix Testing (Matrix Strategy) — a way to test your application across many combinations of operating systems and programming language versions from just one job definition. This is a trick that drastically cuts down your workflow lines!