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:
- GitHub Actions parses the workflow and builds a dependency graph from every
needsentry. buildandtesthave noneeds, so both are queued immediately on separate runners.- As each step in
buildandtestcompletes, the runner checks the implicit condition (success()) before starting the next step. - Once both
buildandtestfinish, GitHub Actions evaluates whetherdeploycan start: itsneedsare satisfied only if both dependencies succeeded, and its explicitifalso has to be true. deployruns (or is markedskipped) and itsresultis recorded in theneedscontext for any job that depends on it.notifywaits for all three jobs to reach a final state. Itsif: always()overrides the default success-only condition, so it runs regardless of what happened upstream, then readsneeds.build.result,needs.test.result, andneeds.deploy.resultto 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
needswhere a real dependency exists. Unnecessary chains serialize work that could run in parallel and slow down every pipeline run. - Use job
outputsfor 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 owniflogic. - Set
timeout-minuteson 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 onneeds.over.result failure()alone, sincefailure()does not cover thecancelledcase. - Reserve
continue-on-error: truefor genuinely optional or known-flaky steps, and read the step’soutcomedownstream rather than assuming the job is fully healthy.
Practice Exercises
- Take Example 1 and add a third job,
lint, that also has noneeds. Confirm in the Actions run view that all three jobs start together. - Modify Example 2 so
deployalso needs a newlintjob, and change one test to fail on purpose. Observe thatdeployis skipped rather than failed. - Add a step-level
ifto thedeployjob in Example 3 that only prints a Slack-style message whengithub.event_name == 'push', leaving the rest of the job unaffected. - Change
notify‘s condition toif: always() && needs.test.result != 'cancelled'and explain, in a comment, the difference in behavior from plainalways().
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.
