A workflow contains jobs; jobs contain steps.
The distinction matters because of what each layer shares. Steps in a job share a runner — the same machine, the same filesystem, the same environment. Jobs share nothing. Each gets its own runner, and anything passing between them must be moved deliberately.
Jobs are parallel by default
Section titled “Jobs are parallel by default”jobs: lint: runs-on: ubuntu-latest steps: [{ run: echo linting }]
test: runs-on: ubuntu-latest steps: [{ run: echo testing }]
build: runs-on: ubuntu-latest steps: [{ run: echo building }]All three start simultaneously. That is usually what you want — lint and test have no reason to wait for each other — and it is the first thing that surprises people coming from a shell script mindset.
Parallelism is bounded by your plan’s concurrency limit; beyond it jobs queue.
needs creates order
Section titled “needs creates order”jobs: lint: runs-on: ubuntu-latest steps: [{ run: make lint }]
test: runs-on: ubuntu-latest steps: [{ run: make test }]
build: needs: [lint, test] runs-on: ubuntu-latest steps: [{ run: make build }]
deploy: needs: build runs-on: ubuntu-latest steps: [{ run: make deploy }]lint and test run together; build waits for both; deploy waits for build. The result is a
dependency graph rather than a list, which the interface renders as one.
A failed dependency skips its dependants. If test fails, build and deploy do not run and are
reported as skipped rather than failed — a distinction worth knowing when reading a run summary.
Jobs share nothing
Section titled “Jobs share nothing”jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - run: mkdir -p dist && echo built > dist/app.txt
test: needs: build runs-on: ubuntu-latest steps: - run: cat dist/app.txt # fails — different machineneeds: build sequences them and shares nothing else. The test job gets a fresh runner with no
dist/, no checkout, and no environment from build.
Two mechanisms bridge the gap:
Artifacts move files.
Outputs move values.
jobs: build: runs-on: ubuntu-latest outputs: version: ${{ steps.meta.outputs.version }} steps: - uses: actions/checkout@v7 - id: meta run: echo "version=1.4.0" >> "$GITHUB_OUTPUT" - run: mkdir -p dist && echo built > dist/app.txt - uses: actions/upload-artifact@v7 with: name: dist path: dist/
test: needs: build runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v8 with: name: dist - run: | echo "Testing version ${{ needs.build.outputs.version }}" cat app.txtThe needs.build.outputs.version reference is how a job reads another’s output, and it works only
for jobs it declares in needs.
Conditional jobs
Section titled “Conditional jobs”if: at job level decides whether the whole job runs.
jobs: deploy: needs: build if: github.ref == 'refs/heads/main' && github.event_name == 'push' runs-on: ubuntu-latest steps: [{ run: make deploy }]Note there are no ${{ }} braces — in an if: they are optional, because the value is always an
expression. Both forms work; without them is conventional.
By default, if: is evaluated only when all dependencies succeeded. To run a job regardless —
a cleanup or notification step — you need an explicit status function:
notify: needs: [build, test, deploy] if: always() runs-on: ubuntu-latest steps: - run: ./notify.sh "${{ needs.build.result }}" "${{ needs.test.result }}"The status functions are success(), failure(), cancelled() and always(). always() runs even
if the workflow was cancelled, which is occasionally not what you want — !cancelled() is often the
better choice for a notification.
Each dependency’s outcome is readable as needs.<job>.result, one of success, failure,
cancelled or skipped.
Job-level permissions
Section titled “Job-level permissions”permissions can be set per job, which is how one workflow can contain a read-only build and a job
that needs to write.
permissions: contents: read # default for every job
jobs: build: runs-on: ubuntu-latest steps: [{ run: make build }]
release: needs: build permissions: contents: write # only this job can write id-token: write # and request an OIDC token runs-on: ubuntu-latest steps: [{ run: ./publish.sh }]A job-level block replaces the workflow-level one rather than merging with it, so a job declaring
contents: write gets exactly that and nothing else — packages, id-token and the rest revert to
none.
That replacement behaviour is what makes job-level permissions genuinely useful: the narrow default applies broadly, and the one job that needs more declares precisely what it needs. See Least-Privilege Permissions.
Timeouts
Section titled “Timeouts”jobs: test: runs-on: ubuntu-latest timeout-minutes: 15 steps: - run: make test timeout-minutes: 10Jobs have a platform maximum, and it is long. A hung test suite consuming it is expensive and slow to notice.
Setting timeout-minutes to something close to your job’s real duration — plus headroom — turns a
hang into a fast, clear failure. It is one line and it is worth adding to anything that runs
regularly.
Runners per job
Section titled “Runners per job”Each job chooses its own runner, which is what makes cross-platform testing possible:
jobs: linux: runs-on: ubuntu-latest steps: [{ run: make test }]
windows: runs-on: windows-latest steps: [{ run: make test }]
macos: runs-on: macos-latest steps: [{ run: make test }]Three nearly identical jobs is exactly what a matrix replaces, and the matrix version is both shorter and easier to extend.
Containers and services
Section titled “Containers and services”A job can run inside a container rather than directly on the runner:
jobs: test: runs-on: ubuntu-latest container: image: python:3.13-slim steps: - uses: actions/checkout@v7 - run: python --versionEvery step then executes inside that image. Useful when your toolchain is already containerised, and it removes the need for a setup action entirely.
Service containers run alongside the job — a database, a cache, a message broker:
jobs: test: runs-on: ubuntu-latest services: postgres: image: postgres:17 env: POSTGRES_PASSWORD: postgres options: >- --health-cmd pg_isready --health-interval 10s --health-retries 5 ports: - 5432:5432 steps: - uses: actions/checkout@v7 - run: pytest env: DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgresThe health options matter. Without them the job may start testing before the database accepts connections, producing an intermittent failure that looks like a flaky test and is a race.
Reading job structure
Section titled “Reading job structure”gh run view --json jobs \ --jq '.jobs[] | {name, conclusion, started: .startedAt, completed: .completedAt}'Comparing start times is how you confirm parallelism is actually happening — a needs you did not
intend serialises a workflow and the only visible symptom is that it takes longer.
Jobs that call reusable workflows
Section titled “Jobs that call reusable workflows”A job can delegate entirely to a reusable workflow instead of defining steps:
jobs: ci: uses: my-org/shared-workflows/.github/workflows/node-ci.yml@v2 with: node-version: "22" secrets: inherit
deploy: needs: ci uses: ./.github/workflows/deploy.yml with: environment: productionSuch a job has uses: at job level and cannot also have steps:, runs-on or most other job
keys — the called workflow defines its own jobs, each with its own runner.
That is the clearest practical difference from a composite action, which runs inside the caller’s job and therefore shares its runner.
Matrix jobs
Section titled “Matrix jobs”A strategy.matrix generates several jobs from one definition:
jobs: test: strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] version: ["3.12", "3.13"] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v7 - run: echo "Testing ${{ matrix.version }} on ${{ matrix.os }}"Six jobs from nine lines. fail-fast: false is worth setting deliberately — the default cancels every
remaining variant when one fails, which is efficient and hides whether the failure is universal or
specific to one combination.
Each variant is a separate job with its own runner, so everything about job isolation applies: they share nothing, and their status checks are named per variant. Matrix Builds covers the full syntax.
Job status and required checks
Section titled “Job status and required checks”Each job produces a status check named after it, which is what makes CI enforceable.
gh api "repos/OWNER/REPO/commits/main/check-runs" --jq '.check_runs[] | [.name, .conclusion] | @tsv'For a matrix job, each variant reports separately — test (ubuntu-latest, 3.13) and so on — which
means requiring a matrix job as a check requires naming each variant, or requiring a single downstream
job that depends on all of them:
test-all: needs: test if: always() runs-on: ubuntu-latest steps: - run: | if [ "${{ needs.test.result }}" != "success" ]; then echo "matrix failed"; exit 1 fiThat “gate job” pattern is the standard solution: one stable check name that succeeds only when every
matrix variant did. Requiring test-all is stable across matrix changes, where requiring individual
variants breaks whenever the matrix does.
Note needs.test.result for a matrix dependency is the aggregate — success only if every variant
succeeded.
Common mistakes
Section titled “Common mistakes”Expecting jobs to share files. They do not; use artifacts.
Chaining everything with needs. Serialises work that could be parallel.
Forgetting if: always() on cleanup. It is skipped when a dependency fails — exactly when you
wanted it.
Assuming job-level permissions merges. It replaces.
No timeout-minutes. A hang runs to the platform maximum.
Service containers without health checks. Intermittent connection failures.
localhost from inside a job container. Use the service name.
Job-level environments
Section titled “Job-level environments”A job can declare a deployment environment, which changes three things about it:
jobs: deploy: runs-on: ubuntu-latest environment: name: production url: https://example.com steps: - run: ./deploy.sh env: TOKEN: ${{ secrets.DEPLOY_TOKEN }} # the environment's secretIts secrets and variables come from the environment, shadowing repository-level ones of the same name.
Its protection rules apply — if production requires a reviewer, the job waits with a
waiting status until someone approves.
It appears in deployment history, with the URL surfaced in the interface.
That combination is why deployment jobs should declare an environment even when no protection rules are configured yet: adding them later then requires no workflow change.
Reading the job graph
Section titled “Reading the job graph”For a workflow with many jobs, the dependency structure is easier to see as data than in the interface:
gh run view RUN_ID --json jobs \ --jq '.jobs[] | {name, conclusion, started: .startedAt[11:19], done: .completedAt[11:19]}'{"conclusion":"success","done":"09:14:22","name":"lint","started":"09:12:04"}{"conclusion":"success","done":"09:16:51","name":"test","started":"09:12:04"}{"conclusion":"success","done":"09:19:02","name":"build","started":"09:16:53"}Identical start times confirm parallelism; a staggered start reveals a needs you may not have
intended. On a workflow that feels slower than it should be, this is the first thing to check — an
accidental dependency serialises work and produces no other symptom.
Job design
Section titled “Job design”A few principles that hold across most pipelines.
Separate what fails for different reasons. Lint and test in one job means a formatting error hides every test result.
Keep the critical path short. Anything not needed by a later job should not be a dependency of one.
A docs job that nothing depends on can run alongside everything else.
Use a gate job for required checks. One stable check name depending on everything is more maintainable than requiring each job individually — and it survives adding a job to the pipeline.
Do not split for its own sake. Each job pays runner provisioning and, usually, a fresh checkout and dependency install. Two jobs sharing an expensive setup may be slower than one job doing both.
That last point is the counterweight to the first. The right granularity is “things that fail for different reasons and do not share expensive setup” — which for most projects is three to five jobs, not fifteen.
Exercise
Section titled “Exercise”- Write a workflow with three jobs and no
needs:; confirm from the timestamps that they overlap. - Add
needs:to serialise two of them and compare. - Have one job write a file, and a dependent job try to read it. Observe the failure.
- Fix it with
upload-artifactanddownload-artifact. - Add a job output and read it from a dependant with
needs.<job>.outputs. - Add a
notifyjob withif: always()and make an earlier job fail. Confirm it still runs.
Job naming
Section titled “Job naming”The name: field changes what appears in the interface and in required status checks:
jobs: test-py: name: Test (Python ${{ matrix.version }})The job ID (test-py) is what needs: references and what the API reports. The name is the
display label and what the status check is called.
That distinction matters for required checks. Adding a name: to a job that is already a required
check renames the check, and the old name stays required while never reporting again — blocking every
pull request. Set names before requiring checks, or update the requirement at the same time.
For matrix jobs, including the matrix values in the name is worth doing: Test (Python 3.13) is
readable where test-py (3.13) is the default and less so.
What you learned
Section titled “What you learned”- Jobs are parallel by default;
needscreates a dependency graph. - Jobs share nothing — separate runners, filesystems and environments.
- Artifacts move files between jobs; outputs move values.
if:at job level defaults to running only when dependencies succeeded — usealways()or!cancelled()for cleanup.- Job-level
permissionsreplaces the workflow default rather than merging. - Containers change where steps run; service containers need health checks.
Concurrency at job level
Section titled “Concurrency at job level”concurrency limits how many runs of something proceed at once, and it can be set per job rather than
per workflow.
jobs: test: runs-on: ubuntu-latest steps: [{ run: make test }]
deploy: needs: test concurrency: group: deploy-${{ github.ref }} cancel-in-progress: false runs-on: ubuntu-latest steps: [{ run: ./deploy.sh }]Tests can run concurrently across branches; deployments to the same ref queue.
cancel-in-progress is the setting to think about rather than copy:
true cancels the running instance when a new one starts. Right for CI, where a superseded run’s
result is worthless.
false queues the new one behind the running one. Right for deployments, where cancelling
half-finished work leaves a system in a partial state.
Getting this backwards on a deployment is a genuine hazard — the failure mode is a cancelled migration or a half-applied infrastructure change, and it will not be obvious from the run log which is why it happened.
Job summaries
Section titled “Job summaries”A job can write Markdown to the run’s summary page, which is where output a human should read belongs:
- name: Summarise if: always() run: | { echo "## Build ${{ job.status }}" echo "" echo "- Commit: \`${GITHUB_SHA:0:7}\`" echo "- Runner: $RUNNER_OS / $RUNNER_ARCH" } >> "$GITHUB_STEP_SUMMARY"For a workflow with several jobs, each contributes a section — which turns the summary page into a readable report rather than requiring someone to open four job logs.
Reusable job patterns
Section titled “Reusable job patterns”Three structures cover most non-trivial workflows.
Fan-out, fan-in — parallel work, then something that needs all of it:
jobs: lint: { runs-on: ubuntu-latest, steps: [{ run: make lint }] } test: { runs-on: ubuntu-latest, steps: [{ run: make test }] } scan: { runs-on: ubuntu-latest, steps: [{ run: make scan }] } package: needs: [lint, test, scan] runs-on: ubuntu-latest steps: [{ run: make package }]Conditional continuation — later jobs only in some circumstances:
deploy: needs: package if: github.ref == 'refs/heads/main' && github.event_name == 'push'Always-run reporting — something that must happen whatever the outcome:
notify: needs: [lint, test, scan, package] if: '!cancelled()' runs-on: ubuntu-latest steps: - run: ./notify.sh "${{ needs.test.result }}"Note the quoting on '!cancelled()'. A YAML value beginning with ! is a tag indicator, so it must be
quoted — an unquoted !cancelled() is a parse error, and it is a common one.
Jobs and cost
Section titled “Jobs and cost”Each job pays a fixed overhead: provisioning a runner, checking out the repository, restoring dependencies. On a fast pipeline that overhead can exceed the work itself.
The practical guidance is to split jobs where the reasons for failure differ or where genuine parallelism helps, and not otherwise. Three jobs each spending ninety seconds on setup to run a ten-second task is slower and more expensive than one job doing all three.
A useful check: if two jobs would both start by doing the same expensive setup and neither needs a different runner, they probably want to be one job with two steps.
Summary
Section titled “Summary”Jobs are parallel by default and isolated completely. needs creates ordering, artifacts move files,
outputs move values, and job-level permissions replaces rather than merges.
The judgement call is granularity: split where failures have different causes, and combine where jobs would repeat the same expensive setup.