Jobs, Steps, and Runners
A job is a unit of work that runs on one runner; the steps inside it execute in order on that same runner. The fact that surprises people: jobs run in parallel by default. Two jobs in the same workflow start at the same time unless you explicitly say otherwise, and needs: is the only thing that forces one to wait for another.
Understanding what is shared and what is not — steps share a filesystem, jobs do not — explains both the speed of Actions and the most common "why did this break" moments. This page is about controlling order, isolation, and the runner each job lands on.
The Runner
The runs-on: key picks the runner: a GitHub-hosted Ubuntu, Windows, or macOS VM with a large set of preinstalled tooling. Every job gets its own fresh runner, so the toolchain a previous job installed is not there for the next one. Tags like ubuntu-latest are convenient but track whatever GitHub currently calls "latest," which moves over time.
Parallel by Default
Every job listed under jobs: starts as soon as the workflow run begins, all at once, unless a constraint holds it back. This parallelism is the speed advantage of Actions — independent lint, test, and build jobs finish in the time of the slowest, not the sum. It is also the source of "why did deploy run before test": without a declared dependency, nothing stops it.
Dependencies with needs
Use needs: to impose order. A job with needs: [build, test] waits until both finish successfully before it starts. By default a failed dependency skips the dependent job entirely, which is usually what you want for a deploy gated behind tests — but a cleanup or report job that must run regardless needs if: always() to override that skip.
jobs:
build:
runs-on: ubuntu-latest
steps: [{ run: make build }]
test:
runs-on: ubuntu-latest
steps: [{ run: make test }]
deploy:
needs: [build, test]
runs-on: ubuntu-latest
steps: [{ run: make deploy }]
report:
needs: [build, test]
if: always()
runs-on: ubuntu-latest
steps: [{ run: make report }]
Sharing Data Between Jobs
Jobs share nothing by default — separate VMs, separate disks. To move data you choose the mechanism by size and kind: outputs for small string values, artifacts (uploaded and downloaded) for files like build output or test reports, and the dependency cache for restoring installed packages. Writing a file in one job and reading it in another over the filesystem simply does not work.
Job Outputs
A job exposes values through outputs:, each mapped from a step that wrote to the $GITHUB_OUTPUT file. A downstream job that needs it reads the value as needs.<job>.outputs.<key>. This is the right channel for a computed version number or a deployment URL — small, structured values, not files.
jobs:
version:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.gen.outputs.tag }}
steps:
- id: gen
run: echo "tag=v1.2.3" >> "$GITHUB_OUTPUT"
release:
needs: version
runs-on: ubuntu-latest
steps:
- run: echo "Releasing ${{ needs.version.outputs.tag }}"
- Assuming jobs run top-to-bottom in file order, so
deploystarts beforetestfinishes because noneeds:gates it. - Passing data between jobs through the filesystem — separate VMs mean the file is not there; use
outputs, artifacts, or cache instead. - A dependent job silently skipping because an upstream
needsjob failed, when you wanted the cleanup to run anyway — addif: always()orif: success() || failure(). - Pinning
runs-on: ubuntu-lateston a toolchain-sensitive build, then being broken when "latest" rolls to a new Ubuntu version; pinubuntu-24.04when stability matters. - Cramming 30 sequential steps into one giant job when splitting independent work into parallel jobs would cut wall-clock time.
- Split independent work into separate jobs so they run in parallel and finish in the time of the slowest, not the sum.
- Use
needs:to gatedeploybehindbuildandtestso a deploy never ships untested code. - Pass small values via job
outputsand$GITHUB_OUTPUTrather than smuggling them through files. - Pin
runs-on:to a specific OS version such asubuntu-24.04on builds sensitive to the toolchain. - Add
if: always()to cleanup or report jobs that must run regardless of an upstream failure.
stages and the needs DAGCircleCI requires in workflowsJenkins stage and parallelAzure Pipelines dependsOnBuildkite depends_onKnowledge Check
Two jobs have no needs: between them. When do they run?
- In parallel — both start at once, since
needs:is the only thing that imposes order - Sequentially, in the top-to-bottom order they appear under
jobs: - Sequentially, alphabetically by job id, so
aruns beforeb - Only one of them runs, since the scheduler treats the other as a redundant duplicate job
How do you make a job wait for both build and test to finish first?
- Declare
needs: [build, test]on the job - List its block after theirs under
jobs: - Set
runs-on:to the same label as theirs - Add
if: always()to the job
Why can't job B read a file that job A wrote to disk?
- Each job has its own VM and disk, so crossing jobs needs
outputs, artifacts, or cache - Files written on a runner are deleted at the end of every single step
- Job B lacks the filesystem read permission that job A holds on the files it wrote to that disk
- It can, as long as both jobs use the same
ubuntu-latestlabel
An upstream needs job fails. What happens to a downstream job by default, and how do you override it?
- It is skipped by default;
if: always()makes it run regardless - It runs anyway by default, and that behavior cannot be changed
- Actions retries the failed upstream job automatically before continuing
- It fails the whole run immediately, with no way to override it
You got correct