3.1 Job Anatomy, Step Execution & Job Dependencies (needs)
Key Takeaways
- Workflows run multiple jobs concurrently by default; sequential ordering and dependency relationships are established using the needs keyword to construct a Directed Acyclic Graph (DAG).
- Each individual job executes on a fresh, isolated virtual runner instance with its own filesystem, environment, and network namespace; steps within the same job execute sequentially and share the local workspace.
- Upstream job data can be passed downstream only by explicitly declaring job-level outputs mapped to step outputs (steps.<step_id>.outputs.<output_key>) and reading them via ${{ needs.<job_id>.outputs.<output_name> }}.
- If an upstream job fails, is cancelled, or is skipped, all downstream jobs depending on it are skipped automatically by default unless an explicit status check condition (such as if: always() or if: failure()) is declared.
- Multi-job dependencies can be defined as an array (needs: [job1, job2]), requiring all specified upstream jobs to complete successfully before the dependent job starts execution.
Job Anatomy, Step Execution & Job Dependencies (needs)
In GitHub Actions, a workflow serves as the top-level automated container, but jobs are the fundamental units of execution where computational work actually takes place. Understanding how jobs are isolated, how individual steps within a job share resources, and how multiple jobs coordinate via dependency graphs is critical for designing scalable, resilient enterprise CI/CD pipelines and excelling on the GH-200 certification exam.
1. Job Anatomy & Runner Isolation
A GitHub Actions workflow consists of one or more jobs defined under the top-level jobs: key. Every job must define its target execution environment using runs-on and contains an ordered sequence of steps.
name: CI Pipeline
on: [push]
jobs:
compile:
name: Compile Application
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Build Binary
run: make build
The Isolation Boundary
The most important architectural rule in GitHub Actions execution is the job isolation boundary:
- Separate Virtual Machines / Containers: By default, each job runs on an independently provisioned virtual machine or container instance allocated from the runner pool.
- Isolated Filesystems: A file created or downloaded in Job A (
compile) is not accessible to Job B (test) through the local filesystem. Cross-job file sharing requires uploading and downloading build artifacts viaactions/upload-artifactandactions/download-artifact. - Isolated Environments: Environment variables set in Job A (even via
$GITHUB_ENV) do not persist to Job B. State sharing must occur through explicit joboutputsor external state stores. - Default Parallelism: Unless explicitly constrained by dependencies, all jobs in a workflow execute concurrently in parallel, bounded only by the runner concurrency limits of the repository, organization, or enterprise account.
2. Step Execution Lifecycle Within a Job
While jobs execute in parallel across separate runners, the steps inside a single job execute sequentially in order of appearance on that single runner instance.
+-----------------------------------------------------------------------------+
| SINGLE JOB RUNNER INSTANCE |
| |
| [ Runner Workspace: /home/runner/work/repository/repository ] |
| |
| Step 1: actions/checkout@v4 ---> Populates workspace on local disk |
| | |
| v |
| Step 2: Setup Runtime ---> Installs tool in PATH / runner cache |
| | |
| v |
| Step 3: Run Build Script ---> Produces /dist binary on local disk |
| | |
| v |
| Step 4: Run Unit Tests ---> Reads /dist binary from local disk |
+-----------------------------------------------------------------------------+
Key Step Characteristics:
- Shared Filesystem: All steps within the job operate on the same runner disk and share the
$GITHUB_WORKSPACEworking directory. Files created by Step 1 are immediately readable by Step 2. - Process Isolation: Each
run:step spawns a separate shell process (e.g.,bash,sh,pwsh). Environment variables exported via standard shellexport VAR=valare lost when that step's subshell terminates. Persistent variables across steps must be registered using$GITHUB_ENV. - Failure Halting: If any step in a job fails (exits with a non-zero exit code), GitHub Actions immediately halts execution of subsequent steps in that job by default, marking the entire job status as
failed.
3. Constructing Directed Acyclic Graphs (DAGs) with needs
To control the execution order of jobs and model complex delivery pipelines, GitHub Actions provides the needs keyword. Declaring needs establishes a Directed Acyclic Graph (DAG) of job dependencies.
+-----------------------------------------------------------------------------+
| WORKFLOW DAG EXECUTION GRAPH |
| |
| +-------------------+ |
| | lint-code | |
| +-------------------+ |
| | |
| v |
| +-------------------+ |
| | build-binary | |
| +-------------------+ |
| | |
| +----------------+----------------+ |
| | | |
| v v |
| +-------------------+ +-------------------+ |
| | unit-tests | | security-scan | |
| +-------------------+ +-------------------+ |
| | | |
| +----------------+----------------+ |
| | |
| v |
| +-------------------+ |
| | deploy-staging | |
| +-------------------+ |
+-----------------------------------------------------------------------------+
Dependency Patterns:
-
Sequential Chain (Single Dependency):
jobs: lint: runs-on: ubuntu-latest steps: [...] build: needs: lint runs-on: ubuntu-latest steps: [...]buildwill not start untillintcompletes with a status ofsuccess. -
Fan-Out (Multiple Downstream Jobs): Multiple jobs can depend on a single upstream job. Once
buildsucceeds, bothunit-testsandsecurity-scanstart running concurrently in parallel. -
Fan-In (Multi-Job Dependency Array):
jobs: deploy-staging: needs: [unit-tests, security-scan] runs-on: ubuntu-latest steps: [...]deploy-stagingrequires bothunit-testsandsecurity-scanto finish with asuccessstatus before it begins.
[!IMPORTANT] Acyclic Requirement: Dependencies must be strictly acyclic. If Job A needs Job B, and Job B needs Job A (or through an indirect loop: A -> B -> C -> A), GitHub Actions will reject the workflow file with a syntax validation error before execution begins.
4. Passing Outputs Across Jobs
Because jobs run on isolated machines, passing scalar values (such as image tags, build versions, commit hashes, or deployment URLs) from an upstream job to a downstream job requires a two-step mapping:
- Step-to-Job Output Mapping: The step writes a key-value pair to
$GITHUB_OUTPUT. The job declares a top-leveloutputs:block that references that step's output using${{ steps.<step_id>.outputs.<key> }}. - Downstream Job Access: The downstream job declares
needs: <upstream_job>and references the value via${{ needs.<upstream_job>.outputs.<output_key> }}.
Complete Worked YAML Implementation:
name: Enterprise Build & Deploy DAG
on:
push:
branches: [main]
jobs:
build-and-package:
name: Build & Generate Version
runs-on: ubuntu-latest
# 1. Declare job-level outputs mapped to step outputs
outputs:
artifact-version: ${{ steps.version-gen.outputs.version }}
build-digest: ${{ steps.build.outputs.digest }}
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Generate Semantic Version
id: version-gen
run: |
CALC_VER="v2.4.${{ github.run_number }}-${{ github.sha }}"
echo "version=${CALC_VER}" >> "$GITHUB_OUTPUT"
- name: Compile and Hash
id: build
run: |
mkdir dist
echo "Compiled binary content" > dist/app.bin
DIGEST=$(sha256sum dist/app.bin | awk '{print $1}')
echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
- name: Upload Build Artifact
uses: actions/upload-artifact@v4
with:
name: application-binary
path: dist/
test-unit:
name: Run Unit Tests
needs: build-and-package
runs-on: ubuntu-latest
steps:
- name: Access Upstream Output
run: |
echo "Testing Version: ${{ needs.build-and-package.outputs.artifact-version }}"
echo "Expected Digest: ${{ needs.build-and-package.outputs.build-digest }}"
deploy-production:
name: Deploy to Production
needs: [build-and-package, test-unit]
runs-on: ubuntu-latest
steps:
- name: Download Binary Artifact
uses: actions/download-artifact@v4
with:
name: application-binary
path: dist/
- name: Execute Deployment
run: |
echo "Deploying version ${{ needs.build-and-package.outputs.artifact-version }} to Production"
5. Job Failure Propagation & Custom Status Conditions
By default, if an upstream job fails, is cancelled, or is skipped, GitHub Actions automatically skips all downstream jobs that list it under needs.
[Job A: Failed] ---> [Job B: needs: Job A] (SKIPPED by default)
Overriding Default Failure Behavior
You can alter this behavior by attaching conditional if: expressions using status check functions:
| Function | Behavior in if: Condition |
|---|---|
success() | (Default) Executes only when all upstream dependencies have succeeded. |
always() | Forces the job to run regardless of whether upstream dependencies succeeded, failed, or were cancelled. |
failure() | Executes if any upstream job dependency has failed. |
cancelled() | Executes only if the workflow execution was explicitly cancelled. |
Handling Upstream Failures (Teardown & Notification Pattern):
jobs:
build:
runs-on: ubuntu-latest
steps: [...]
test:
needs: build
runs-on: ubuntu-latest
steps: [...]
notify-failure:
name: Send Alert on Failure
needs: [build, test]
if: failure() # Runs only if build or test failed
runs-on: ubuntu-latest
steps:
- name: Post Incident to Slack
run: curl -X POST -H 'Content-type: application/json' --data '{"text":"CI Failed!"}' ${{ secrets.SLACK_WEBHOOK }}
cleanup-resources:
name: Always Clean Ephemeral Infrastructure
needs: [build, test]
if: always() # Runs unconditionally even after cancellations or failures
runs-on: ubuntu-latest
steps:
- name: Teardown Cloud Testbeds
run: ./scripts/teardown-cloud.sh
[!CAUTION] Exam Trap on
if: always()vs Skipped Upstreams: If Job C hasneeds: [Job A, Job B]andif: always(), Job C will execute even if Job A failed and Job B was skipped. Inside Job C, referencing${{ needs.JobA.outputs.key }}will be safe (evaluating to an empty string if not produced), but attempting to access artifacts that failed to upload will result in step failures unless handled defensively.
6. Real-World Troubleshooting & Best Practices
- Explicit Job IDs vs Display Names: Job keys (e.g.,
build-and-package:) must start with a letter or_and contain only alphanumeric characters,-, or_. Thename:field is purely visual in the GitHub UI. - Setting Job Timeouts: Always declare
timeout-minutesat the job level (default is 360 minutes / 6 hours). Settingtimeout-minutes: 15prevents hung network requests or stuck test processes from exhausting your organization's runner concurrency capacity and billing limits. - Concurrency Group Locks: Use
concurrency:blocks at the job level to prevent simultaneous deployments to the same physical or cloud environment.
A workflow defines four jobs without any needs keys specified. How does GitHub Actions execute these jobs by default on a standard GitHub-hosted runner pool?
A workflow has a job named build that calculates a binary digest in a step with id: calc-hash using echo "digest=$HASH" >> "$GITHUB_OUTPUT". A downstream job deploy specifies needs: build and tries to access ${{ needs.build.outputs.digest }}, but the value is empty. What is the root cause?
In a workflow DAG, job deploy specifies needs: [lint, test]. During execution, the lint job succeeds, but the test job fails with exit code 1. No if: conditional expression is specified on deploy. What happens to the deploy job?