1.04 Multiple Jobs: Parallel Execution and needs
Overview
A workflow can hold as many jobs as you want. By default they all start at once; the needs keyword
is what turns that into an ordered pipeline.
Abstract
Job identifiers sit at the same indentation level under jobs:, and each job defines its own
runs-on runner and its own steps — jobs do not share a machine. Multiple jobs run in
parallel by default, which is why a run's total duration is less than the sum of its jobs.
Adding needs: <job-id> makes a job wait for another to finish successfully; if the
dependency fails, the dependent job never starts. That is how you stop a deploy from shipping
code whose tests just failed.
Why It Matters in Production
Parallel jobs are how you keep pipeline wall-clock time low: lint, unit tests, and type checks have no reason to wait for each other.
needs is how you build a gate. A deploy job without needs: test runs alongside the tests and
will happily publish a broken build — the tests become decoration rather than a check. Sequencing
costs you time (the run takes roughly the sum of both jobs instead of the longer one), and that
trade is almost always worth making before a deploy.
The other consequence of separate runners is that nothing carries over between jobs. A build produced in one job does not exist in the next unless you upload it as an artifact.
Key Concepts
| Concept | Meaning |
|---|---|
| Job identifier | The key under jobs: (test, deploy) — yours to choose, must be unique |
| Indentation level | All job IDs sit at the same level; runs-on and steps indent one level under each |
| Per-job runner | Every job declares its own runs-on; different jobs may use different runners |
| Default behavior | Jobs run in parallel |
needs |
Names the job(s) that must complete successfully first |
| Skipped job | A dependent job whose needs target failed — it never starts |
| Artifacts | The mechanism for passing files (e.g. a build output) between jobs |
Note
A job's key is the identifier used by needs and by branch-protection status checks. The
optional name: field only changes the label in the UI. Rename the key and every needs
reference to it must change too.
Common Use Cases
- Run
lint,test, andtypecheckin parallel, thendeploywithneeds: [lint, test, typecheck] - Build once in a
buildjob, upload the output as an artifact, deploy it in a later job - Fan out across environments: one
buildjob, then paralleldeploy-staginganddeploy-prod - A final
notifyjob that reports results regardless of upstream success, usingif: always() - Split a slow suite into parallel jobs (unit vs integration) to cut total run time
Example Configuration or Commands
Two jobs in one workflow — as written here they run in parallel:
# .github/workflows/deployment.yml
name: Deploy Project
on: push
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Get code
uses: actions/checkout@v4
- name: Install NodeJS
uses: actions/setup-node@v4
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
deploy:
runs-on: ubuntu-latest
steps:
- name: Get code
uses: actions/checkout@v4
- name: Install NodeJS
uses: actions/setup-node@v4
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Build project
run: npm run build # runs the build script from package.json
- name: Deploy
run: echo "Deploying..." # stand-in for a real upload step
Adding one keyword turns it into a gate:
deploy:
needs: test # wait for test to succeed before starting
runs-on: ubuntu-latest
steps:
# ...unchanged
Avoid rebuilding — pass the output as an artifact
Duplicating checkout, setup-node, and npm ci in both jobs works, but it repeats minutes of
install time and rebuilds code the test job already validated. Build once, hand the output over:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
- run: npm ci
- run: npm run build
- name: Upload build output
uses: actions/upload-artifact@v4
with:
name: dist-files
path: dist
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download build output
uses: actions/download-artifact@v4
with:
name: dist-files
path: dist
- run: echo "Deploying..."
Confirming the behavior from the CLI:
# Per-job timings — parallel jobs overlap, so the sum exceeds the run's total duration
gh run view --json jobs --jq '.jobs[] | {name, startedAt, completedAt, conclusion}'
# Was the deploy skipped because tests failed?
gh run view --json jobs --jq '.jobs[] | select(.conclusion == "skipped") | .name'
Best Practices
Best Practices
- Gate anything that changes the outside world — deploys, publishes, releases — behind
needson your test job. - Keep independent checks parallel; only add
needswhere a real dependency exists. Each unnecessaryneedslengthens the critical path. - Build once and pass artifacts forward instead of repeating install-and-build in every job.
- Enable
cache: 'npm'insetup-nodeso repeated jobs don't re-download the dependency tree. - Name the workflow file after what it does (
deployment.ymlbeatstest.ymlonce it deploys) — the file name shows in the Actions list and in status checks. - Set
timeout-minutesper job; a hung deploy job holds the whole pipeline. - Add
concurrencywithcancel-in-progress: trueso a newer push supersedes an in-flight run. - Use a final
if: always()notification job rather than checking results manually.
Security Best Practices
Security
- Scope permissions per job, not globally. The test job needs
contents: read; only the deploy job should get anything more. - Put deployment credentials in a GitHub Environment with required reviewers, so a job that can deploy cannot be triggered silently by anyone with write access.
- Never echo secrets in a deploy step.
run: echo "$DEPLOY_TOKEN"lands in the run log, which is visible to anyone who can read the repository's Actions tab. - Prefer OIDC federation over long-lived cloud keys stored as secrets.
- Remember that artifacts are downloadable by anyone with repository read access — never upload
.envfiles, build-time secrets, or source maps containing credentials. needsis a sequencing tool, not an authorization control. A deploy job is still triggerable by any event listed inon:; restrict it withif:conditions on branch or actor as well.
Do and Don't
| ✅ Do | ❌ Don't |
|---|---|
Add needs: test before a deploy job |
Let deploy race the tests in parallel |
| Keep job IDs at the same indentation level | Nest a second job under the first job's keys |
| Build once, pass artifacts forward | Re-run npm ci and npm run build in every job |
Use needs: [a, b] for multiple dependencies |
Chain jobs one-by-one when they're independent |
| Rename the file when its purpose changes | Keep test.yml for a workflow that deploys |
Scope permissions per job |
Grant write scopes workflow-wide |
Use if: always() on notification jobs |
Expect a reporting job to run after a failure by default |
Common Mistakes
Common Mistakes
- Broken indentation when copying steps. The most common failure in multi-job files: the
second job's key must align with the first's, with
runs-onandstepsone level deeper. - Expecting files to persist between jobs. Each job gets a clean runner —
dist/from the build job does not exist in the deploy job without artifacts. - Assuming
needsimplies shared state. It only orders execution; it shares nothing but job outputs you explicitly declare. needspointing at a job'sname:instead of its key. It must be the identifier underjobs:.- Circular dependencies. Two jobs that each
needsthe other fail validation immediately. - Wondering why a job is "skipped". Its
needstarget failed or was itself skipped; addif: always()only where running anyway is genuinely correct. - Reading total duration as the sum of jobs. For parallel jobs the total is roughly the longest job plus setup overhead — that discrepancy is the proof they ran concurrently.
Breaks in production
A deploy job without needs runs simultaneously with test. When a test breaks, the pipeline
shows a red run and a completed deployment — the broken build is already live. Recovering
means a revert commit and a second full pipeline run.
Troubleshooting
# Did the jobs overlap or queue? Compare startedAt values
gh run view <run-id> --json jobs --jq '.jobs[] | {name, startedAt, conclusion}'
# Why did a job not run at all?
gh run view <run-id> --log | grep -i "skipped\|requires"
# Validate structure locally before pushing — catches indentation errors
yq '.jobs | keys' .github/workflows/deployment.yml
# Revert a commit that broke the pipeline
git log --oneline -5
git revert <commit-id>
git push origin main
| Symptom | Likely cause |
|---|---|
| Second job missing from the run | Its key is indented under the first job instead of beside it |
| "Invalid workflow file: unexpected value 'runs-on'" | Job key/indentation mismatch |
| Deploy ran despite failing tests | needs not set on the deploy job |
| Deploy shows as "skipped" | Its needs dependency failed or was skipped |
| "Job X depends on unknown job Y" | needs references a name: or a typo, not the job key |
| Build files missing in the deploy job | No artifact upload/download; runners don't share a filesystem |
Pipeline much slower after adding needs |
Expected — sequential jobs sum instead of overlap |
Tip
The run summary page draws the dependency graph. Parallel jobs appear side by side; needs
relationships appear as arrows, which is the quickest way to confirm the ordering you intended.
Quick Recap
- One workflow can contain many jobs; job IDs sit at the same indentation under
jobs:. - Each job declares its own runner and its own steps — runners are not shared.
- Jobs run in parallel by default; total run duration is less than the sum of job durations.
needs: <job-id>makes a job wait for another to finish successfully.needs: [a, b]waits for several jobs.- If a
needsdependency fails, the dependent job is skipped, not failed. - Sequential pipelines are slower but prevent deploying a broken build.
- Pass files between jobs with
upload-artifact/download-artifact, never by assuming the filesystem persists.
Interview / Revision Notes
Quick revision
Q: How do jobs run by default? In parallel.
Q: How do you make one job wait for another?
needs: <job-id> on the waiting job.
Q: How do you wait for several jobs?
needs: [job-a, job-b].
Q: What happens to a dependent job if its needs target fails?
It is skipped and never starts.
Q: How do you run a job even when an upstream job failed?
Add if: always() (or a condition on needs.<job>.result).
Q: Do jobs in the same workflow share a machine or filesystem? No. Every job gets its own runner; use artifacts or job outputs to pass data.
Q: Why is total run duration shorter than the sum of job durations? Because parallel jobs overlap — this is how you verify they ran concurrently.
Q: What does needs reference — the job key or its name?
The job key under jobs:.
Q: Can different jobs in one workflow use different runners?
Yes — runs-on is per job.
This topic can be expanded into sub-pages: artifacts and job outputs, conditional jobs and
ifexpressions, matrix strategies, reusable workflows withworkflow_call, and deployment environments with required reviewers.