Skip to content

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) or run: (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, deploy could fire at the same time as test and run against code that hasn't even passed its tests yet — every build → test → deploy pipeline relies on needs: 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.