Skip to content

GitHub Actions Jobs: Parallelism, Dependencies and Outputs

Lesson 5 of 11Beginner11 min readGitHub Actions & CI/CD · Actions FundamentalsVerified: GitHub Actions workflow syntax documentation, August 2026

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:
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.

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:
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 machine

needs: 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.txt

The needs.build.outputs.version reference is how a job reads another’s output, and it works only for jobs it declares in needs.

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.

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.

jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- run: make test
timeout-minutes: 10

Jobs 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.

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.

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 --version

Every 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/postgres

The 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.

Terminal window
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.

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: production

Such 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.

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.

Each job produces a status check named after it, which is what makes CI enforceable.

Terminal window
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
fi

That “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.

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.

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 secret

Its 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.

For a workflow with many jobs, the dependency structure is easier to see as data than in the interface:

Terminal window
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.

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.

  1. Write a workflow with three jobs and no needs:; confirm from the timestamps that they overlap.
  2. Add needs: to serialise two of them and compare.
  3. Have one job write a file, and a dependent job try to read it. Observe the failure.
  4. Fix it with upload-artifact and download-artifact.
  5. Add a job output and read it from a dependant with needs.<job>.outputs.
  6. Add a notify job with if: always() and make an earlier job fail. Confirm it still runs.

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.

  • Jobs are parallel by default; needs creates 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 — use always() or !cancelled() for cleanup.
  • Job-level permissions replaces the workflow default rather than merging.
  • Containers change where steps run; service containers need health checks.

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.

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.

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.

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.

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.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

The CI starter template — least-privilege permissions, pinned actions, correct checkout — is in the Professional Toolkit.