GitHub Actions Fundamentals
Topic 50

Jobs, Steps, and Runners

Actions

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.

How a workflow decomposes into work
Workflow
Jobsparallel by default
needs:gates order
Stepssequential, one runner

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.

deploy waits for build and test; report always runs
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.

Produce a version in one job, consume it in another
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 }}"
Common Mistakes
  • Assuming jobs run top-to-bottom in file order, so deploy starts before test finishes because no needs: 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 needs job failed, when you wanted the cleanup to run anyway — add if: always() or if: success() || failure().
  • Pinning runs-on: ubuntu-latest on a toolchain-sensitive build, then being broken when "latest" rolls to a new Ubuntu version; pin ubuntu-24.04 when stability matters.
  • Cramming 30 sequential steps into one giant job when splitting independent work into parallel jobs would cut wall-clock time.
Best Practices
  • 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 gate deploy behind build and test so a deploy never ships untested code.
  • Pass small values via job outputs and $GITHUB_OUTPUT rather than smuggling them through files.
  • Pin runs-on: to a specific OS version such as ubuntu-24.04 on builds sensitive to the toolchain.
  • Add if: always() to cleanup or report jobs that must run regardless of an upstream failure.
Comparable toolsGitLab CI/CD stages and the needs DAGCircleCI requires in workflowsJenkins stage and parallelAzure Pipelines dependsOnBuildkite depends_on

Knowledge 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 a runs before b
  • 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-latest label

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