Jobs, Steps & Runners¶
Once a workflow is triggered, it has to actually run something. That "something" decomposes into two levels — jobs and steps — executing on a runner, and the two levels behave very differently: one is parallel by default, the other never is.
TL;DR — in 30 seconds:
- Jobs run in parallel by default, each on its own fresh runner — add
needs: <job-id>to make one job wait for another instead. - Steps inside a job always run sequentially and share that one runner, including its filesystem — no artifact hand-off needed between steps in the same job.
- A step is either
uses:(a pre-built, version-pinned action) orrun:(a raw shell command); most real jobs mix both.
1. Jobs run in parallel, by default¶
A workflow can define more than one job. Unless you say otherwise, every job in a workflow starts at the same time, each on its own fresh runner — a disposable virtual machine that exists only for that job's duration and is thrown away when it finishes.
flowchart TB
T["Trigger fires"] --> J1["Job: build"]
T --> J2["Job: lint"]
J1 -.->|no needs: between them| J2
That's efficient when jobs are independent (a build job and a lint job don't need each other's output), but wrong when one job's output is required by another.
2. needs: turns parallel into ordered¶
Add needs: <job-id> to a job and it will wait for that job to succeed before it starts:
flowchart LR
B["build"] --> Te["test<br/>needs: build"] --> D["deploy<br/>needs: test"]
This is the mechanism behind every build → test → deploy pipeline: test doesn't run until build
succeeds, and deploy doesn't run until test succeeds. Without needs:, all three would fire at once —
deploy could run against code that hadn't even passed its tests yet.
3. Steps: always sequential, always the same runner¶
Inside one job, steps are different from jobs in every way that matters:
| Jobs | Steps | |
|---|---|---|
| Order | Parallel unless needs: |
Always sequential |
| Runner | Each gets its own fresh runner | All steps in a job share one runner |
| Filesystem | Not shared between jobs | Shared between steps in the same job |
Because steps share a runner and its filesystem, a step can write a file that a later step in the same job reads directly — no artifact hand-off needed. That hand-off is only required between jobs, since each job's runner starts empty (see Build → test → deploy).
4. uses: vs run:¶
A step is one of two things:
uses:— runs a pre-built, reusable action (a packaged unit someone else wrote — checking out your repo's code, setting up a language toolchain, publishing a package). Actions are typically pinned to a version tag so a workflow doesn't silently pick up a breaking change.run:— runs a raw shell command directly on the runner (a test command, a build script, anything you'd type at a terminal).
Most real jobs mix both: uses: to check out the repo and set up tooling, then run: for the actual
build/test/deploy commands.
5. How this connects¶
- The
needs:chain in §2 is exactly the mechanism that shapes Build → test → deploy — that page covers the full pipeline pattern this page only introduces the primitive for. - Which secrets a job's steps can see, and whether a job's
run:/uses:steps are even allowed to touch a deploy target, is covered in Secrets & environments.
Common gotchas
- Assuming jobs run top-to-bottom in the order they're listed in the file. Fix: jobs run in
parallel by default — order in the file means nothing without
needs:. - Leaving out
needs:between jobs that depend on each other. Fix: without it,deploycould fire at the same time astestand run against code that hasn't even passed its tests yet — every build → test → deploy pipeline relies onneeds:for that ordering. - Expecting a file written in one job to be visible to another job's steps. Fix: each job gets its own fresh runner with an empty filesystem — sharing a file directly only works between steps inside the same job; crossing jobs needs an artifact hand-off (see Build → test → deploy).
- Using a
uses:action without pinning it to a version tag. Fix: pin the action's version so the workflow doesn't silently pick up a breaking change the next time it runs.
Check yourself — a workflow defines build and deploy jobs with no needs: between them. What actually happens, and how do you make deploy wait for build to succeed?
Both start at the same time, each on its own fresh runner — jobs run in parallel by default, so
deploy would not wait for build. Add needs: build to the deploy job to make it wait for build
to succeed first.
Check yourself — a step in the build job writes a file. Can a later step in the same job read it directly? Can a step in a separate test job?
Yes for the later step in the same job — steps in one job share that job's single runner and its filesystem. No for the separate job — each job starts on its own fresh, empty runner, so passing data across jobs requires an artifact hand-off instead.
You can defend this when you can explain, for any two jobs in a workflow, whether they'd run in parallel or in sequence — and why steps inside one job never need that same question asked.