Jobs, Steps, Dependencies, and Conditions

A workflow is a graph of jobs, and each job is a sequence of steps. By default every job in a workflow starts at the same time and runs independently on its own runner. The needs keyword turns that flat list into an ordered graph, and if conditions decide whether a job or step actually executes once its turn comes up. Getting these three primitives right is what separates a workflow that merely works from one that fails safely, reports clearly, and doesn’t waste runner minutes.

Overview / How it works

Each job in a workflow runs on a fresh virtual machine (or self-hosted runner) with its own filesystem, its own checkout, and its own installed dependencies. Nothing is shared between jobs unless you explicitly pass it along using needs, job outputs, or artifacts. Because jobs are isolated, GitHub Actions can run them concurrently, which is the default behavior when there is no dependency declared.

Steps inside a single job are different: they run sequentially on the same runner and share the same filesystem and workspace directory. However, each run: step still starts a brand-new shell process. A shell variable exported in one step does not exist in the next step’s shell unless you persist it through GitHub’s environment file ($GITHUB_ENV) or step outputs ($GITHUB_OUTPUT).

By default, a step only runs if all previous steps in the job succeeded, and a job only starts if all of its needs dependencies succeeded. You can override this default with an explicit if: condition and status-check functions such as success(), failure(), cancelled(), and always().

Syntax or workflow structure

The relevant fields live inside each entry under jobs:. The skeleton below shows where each piece belongs.

jobs:
  job_a:
    runs-on: ubuntu-latest
    outputs:
      some_value: ${{ steps.step_id.outputs.some_value }}
    steps:
      - id: step_id
        run: echo "some_value=hello" >> "$GITHUB_OUTPUT"

  job_b:
    needs: job_a
    if: needs.job_a.result == 'success'
    runs-on: ubuntu-latest
    steps:
      - run: echo "Got ${{ needs.job_a.outputs.some_value }}"

needs accepts a single job id or a list, and it does two things at once: it delays the job until its dependencies finish, and it exposes their outputs and result (a string: success, failure, cancelled, or skipped) through the needs context. if can be set at the job level (controls whether the whole job runs) or the step level (controls a single step within a job that has already started).

Examples

Example 1: independent jobs run in parallel. With no needs, GitHub Actions schedules both jobs as soon as runners are available.

name: Build and Test
on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install dependencies
        run: npm ci
      - name: Build
        run: npm run build

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install dependencies
        run: npm ci
      - name: Run tests
        run: npm test

Expected behavior: build and test start at nearly the same time on two separate runners. Each repeats its own checkout and npm ci because they do not share a filesystem. There is no guarantee about which finishes first, and a failure in one does not stop the other.

Example 2: adding a dependency and passing data between jobs. A deploy job should not start until both build and test succeed, and it needs the version string the build job computed.

name: Build, Test, Deploy
on:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.get_version.outputs.version }}
    steps:
      - uses: actions/checkout@v4
      - name: Install dependencies
        run: npm ci
      - name: Build
        run: npm run build
      - name: Read version
        id: get_version
        run: echo "version=$(node -p \"require('./package.json').version\")" >> "$GITHUB_OUTPUT"

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install dependencies
        run: npm ci
      - name: Run tests
        run: npm test

  deploy:
    needs: [build, test]
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Deploy version
        run: echo "Deploying version ${{ needs.build.outputs.version }}"

Expected behavior: build and test still run in parallel with each other, but deploy waits for both. If either one fails, deploy is skipped automatically. If both succeed, deploy prints the version number that build computed, proving the value crossed the job boundary through outputs.

Example 3: branch-scoped deploy plus a status report that always runs. Deploys should only happen from main, and you want a notification job that reports the outcome even when something upstream fails.

name: Build, Test, Deploy, Notify
on:
  push:
    branches: [main, develop]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.get_version.outputs.version }}
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run build
      - id: get_version
        run: echo "version=$(node -p \"require('./package.json').version\")" >> "$GITHUB_OUTPUT"

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

  deploy:
    needs: [build, test]
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Deploy
        run: echo "Deploying version ${{ needs.build.outputs.version }} to production"

  notify:
    needs: [build, test, deploy]
    if: always()
    runs-on: ubuntu-latest
    steps:
      - name: Report pipeline result
        run: |
          echo "Build: ${{ needs.build.result }}"
          echo "Test: ${{ needs.test.result }}"
          echo "Deploy: ${{ needs.deploy.result }}"

Expected behavior: on a push to develop, deploy is skipped because its if condition is false, but notify still runs because of if: always(), printing Deploy: skipped. On a push to main where test fails, deploy is skipped as a side effect of the failed dependency, and notify still runs and prints Test: failure and Deploy: skipped, giving you a single place to see the whole pipeline’s outcome.

Step by step

Trace what the scheduler actually does for Example 3 on a push to main:

  1. GitHub Actions parses the workflow and builds a dependency graph from every needs entry.
  2. build and test have no needs, so both are queued immediately on separate runners.
  3. As each step in build and test completes, the runner checks the implicit condition (success()) before starting the next step.
  4. Once both build and test finish, GitHub Actions evaluates whether deploy can start: its needs are satisfied only if both dependencies succeeded, and its explicit if also has to be true.
  5. deploy runs (or is marked skipped) and its result is recorded in the needs context for any job that depends on it.
  6. notify waits for all three jobs to reach a final state. Its if: always() overrides the default success-only condition, so it runs regardless of what happened upstream, then reads needs.build.result, needs.test.result, and needs.deploy.result to build its report.

You can confirm job outcomes after the fact from the command line as well, which is useful when debugging a conditional job that didn’t run as expected:

gh run view --json jobs -q '.jobs[] | {name: .name, conclusion: .conclusion}'

Common Mistakes

Mistake 1: expecting shell variables to survive between steps.

- name: Set version
  run: VERSION=1.2.3
- name: Use version
  run: echo "Deploying $VERSION"

This prints an empty string for $VERSION because each run: step is a separate shell invocation; a plain assignment disappears when the step ends. The fix is to write the value to $GITHUB_ENV so the runner injects it into every later step’s environment:

- name: Set version
  run: echo "VERSION=1.2.3" >> "$GITHUB_ENV"
- name: Use version
  run: echo "Deploying $VERSION"

Mistake 2: a cleanup or notification job silently never runs. A common pattern is a job that should always report status: needs: [test] with no if:. Because the default condition is success(), if test fails, the notify job is skipped too, exactly when you most need the notification. Add if: always() and read needs.test.result if you need to distinguish success from failure inside the job itself, rather than relying on the job running at all.

Best Practices

  • Only add needs where a real dependency exists. Unnecessary chains serialize work that could run in parallel and slow down every pipeline run.
  • Use job outputs for small values like version strings or image tags instead of uploading and downloading an artifact for a single line of text.
  • Put environment: production (or similar) on deploy jobs so branch protections and required reviewers gate the job, independent of the workflow’s own if logic.
  • Set timeout-minutes on every job so a hung step doesn’t consume runner minutes indefinitely.
  • When a job must run regardless of outcome, prefer if: always() combined with an explicit check on needs..result over failure() alone, since failure() does not cover the cancelled case.
  • Reserve continue-on-error: true for genuinely optional or known-flaky steps, and read the step’s outcome downstream rather than assuming the job is fully healthy.

Practice Exercises

  1. Take Example 1 and add a third job, lint, that also has no needs. Confirm in the Actions run view that all three jobs start together.
  2. Modify Example 2 so deploy also needs a new lint job, and change one test to fail on purpose. Observe that deploy is skipped rather than failed.
  3. Add a step-level if to the deploy job in Example 3 that only prints a Slack-style message when github.event_name == 'push', leaving the rest of the job unaffected.
  4. Change notify‘s condition to if: always() && needs.test.result != 'cancelled' and explain, in a comment, the difference in behavior from plain always().

Summary

Jobs run in parallel by default and are fully isolated from one another; steps inside a job run in order and share a filesystem but not shell state. needs builds an explicit dependency graph and exposes each dependency’s outputs and result. if conditions, evaluated at the job or step level, override the default success-only behavior — always(), failure(), and cancelled() give you precise control over what runs when something upstream doesn’t go as planned. Design the graph to keep independent work parallel, and reserve needs and conditional gates for the points where a real ordering or approval requirement exists.