Skip to content

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, and typecheck in parallel, then deploy with needs: [lint, test, typecheck]
  • Build once in a build job, upload the output as an artifact, deploy it in a later job
  • Fan out across environments: one build job, then parallel deploy-staging and deploy-prod
  • A final notify job that reports results regardless of upstream success, using if: 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

jobs:
  test:
    runs-on: ubuntu-latest
    steps: [...]
  deploy:
    runs-on: ubuntu-latest
    steps: [...]
Both start immediately. Total duration ≈ the longer job plus setup overhead.

jobs:
  test:
    runs-on: ubuntu-latest
    steps: [...]
  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps: [...]
deploy starts only after test succeeds. If test fails, deploy is skipped entirely.

jobs:
  lint:
    runs-on: ubuntu-latest
    steps: [...]
  test:
    runs-on: ubuntu-latest
    steps: [...]
  deploy:
    needs: [lint, test]      # waits for both; they run in parallel with each other
    runs-on: ubuntu-latest
    steps: [...]
  notify:
    needs: [test, deploy]
    if: always()             # without this, a failed dependency skips the job
    runs-on: ubuntu-latest
    steps:
      - run: echo "Result: ${{ needs.test.result }} / ${{ needs.deploy.result }}"

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 needs on your test job.
  • Keep independent checks parallel; only add needs where a real dependency exists. Each unnecessary needs lengthens the critical path.
  • Build once and pass artifacts forward instead of repeating install-and-build in every job.
  • Enable cache: 'npm' in setup-node so repeated jobs don't re-download the dependency tree.
  • Name the workflow file after what it does (deployment.yml beats test.yml once it deploys) — the file name shows in the Actions list and in status checks.
  • Set timeout-minutes per job; a hung deploy job holds the whole pipeline.
  • Add concurrency with cancel-in-progress: true so 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.
    jobs:
      test:
        permissions:
          contents: read
      deploy:
        permissions:
          contents: read
          id-token: write     # only where OIDC is actually used
    
  • 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 .env files, build-time secrets, or source maps containing credentials.
  • needs is a sequencing tool, not an authorization control. A deploy job is still triggerable by any event listed in on:; restrict it with if: 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-on and steps one 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 needs implies shared state. It only orders execution; it shares nothing but job outputs you explicitly declare.
  • needs pointing at a job's name: instead of its key. It must be the identifier under jobs:.
  • Circular dependencies. Two jobs that each needs the other fail validation immediately.
  • Wondering why a job is "skipped". Its needs target failed or was itself skipped; add if: 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 needs dependency 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 if expressions, matrix strategies, reusable workflows with workflow_call, and deployment environments with required reviewers.