skip to content

In a GitHub Actions workflow, what does a job's needs: key change about execution?

level: middleimportance: must knowfreq 80%

answer

  1. file order is not execution order
  2. default is maximum parallelism
  3. one key turns jobs into a graph
  4. cycles are rejected outright
  5. failed dependency skips the dependent

basics

~20 s

Jobs in a GitHub Actions workflow run in parallel by default. Adding needs: makes a job wait for the listed jobs and, by default, skip entirely if any of them fails, turning the job list into a dependency graph.

solid answer

~40 s

Without `needs:`, every job in a GitHub Actions workflow starts as soon as the run does — file order means nothing. `needs: [lint, test]` makes that job wait until both named jobs finish, and by default it is **skipped** if either one fails or is itself skipped. The set of `needs:` edges forms a directed acyclic graph; GitHub rejects a workflow whose edges form a cycle, and `needs:` can only name jobs in the same workflow file. The graph is also the data channel: a dependent job reads `needs.<job_id>.outputs.<name>` for values and `needs.<job_id>.result` for the dependency's status (`success`, `failure`, `cancelled`, `skipped`). To run despite a failed dependency, you must add an explicit `if:` such as `if: always()` or `if: !cancelled()`, because the default gate is success of all dependencies.

code

yaml · 19 lines
yaml
jobs:
  lint:
    runs-on: ubuntu-latest
    steps: [{ uses: actions/checkout@v4 }, { run: ./gradlew ktlintCheck }]
  test:
    runs-on: ubuntu-latest
    steps: [{ uses: actions/checkout@v4 }, { run: ./gradlew test }]
  package:
    needs: [lint, test]
    runs-on: ubuntu-latest
    steps: [{ uses: actions/checkout@v4 }, { run: ./gradlew bootJar }]
  report:
    needs: [lint, test, package]
    if: always()
    runs-on: ubuntu-latest
    steps:
      - run: |
          echo "lint=${{ needs.lint.result }} test=${{ needs.test.result }}"
          [ "${{ needs.package.result }}" = "success" ] || exit 1

go deeper

for a junior

Recall that GitHub Actions jobs run in parallel unless a job declares needs:, and that needs: names other job ids from the same workflow file. Be able to add a deploy job that waits for a test job.

for a middle

Explain the default skip behaviour when a dependency fails, that the edges must be acyclic, and that needs.<job_id>.outputs and .result are how a dependent job reads state. Know that transitive edges are implied.

for a senior

Diagnose a pipeline from its graph: identify the critical path, spot the edge added only for tidiness that is costing minutes, and describe the always-running gate job that inspects needs.*.result so a required check keeps one stable name.

for a principal

Own the pipeline shape across many repositories: where fan-out ends and fan-in begins, which stages deserve to block deployment, and how to keep the graph legible as teams add jobs without letting wall-clock time drift upward.

## Default: everything at once The `jobs:` map in a GitHub Actions workflow is a *map*, not a script. When a run starts, every job with no `needs:` key is dispatched immediately and they execute concurrently on separate runners, each with its own fresh filesystem. Their order in the YAML file is decoration; two jobs written one after another have no relationship at all. That default is the point: the whole reason CI on GitHub Actions is fast or slow is how much of the work you allowed to happen simultaneously. ## `needs:` adds edges ``` jobs: lint: { runs-on: ubuntu-latest, steps: [...] } test: { runs-on: ubuntu-latest, steps: [...] } package: needs: [lint, test] runs-on: ubuntu-latest steps: [...] ``` `needs:` takes a single job id or a list of them. The named jobs must exist in the *same* workflow file. The resulting edges must form a directed acyclic graph — a cycle (A needs B, B needs A) makes the workflow invalid and it will not run. The scheduler is transitive-aware: if `deploy` needs `package` and `package` needs `test`, `deploy` waits for the whole chain. You do not repeat transitive dependencies, and doing so adds nothing. ## What happens when a dependency does not succeed This is the part interviews probe. The implicit condition on a job with `needs:` is that **all** of its dependencies succeeded. If any one fails, the dependent job's status becomes `skipped` — it is not "failed", it never starts, and its steps never run. A skipped dependency propagates the same way: a job downstream of a skipped job is itself skipped by default. To change that you write an explicit condition: - `if: always()` — run no matter what, including when the run is being cancelled. - `if: !cancelled()` — run after success or failure of dependencies, but let cancellation stop it. GitHub's documentation recommends this over `always()` for long tasks, because `always()` keeps work running after a cancellation is requested. - `if: failure()` — run only when something the job depends on failed. Useful for a notify-on-broken-build job. A subtlety worth stating out loud: adding any `if:` that evaluates to true removes the automatic success gate, so a job with `if: always()` and `needs: [test]` runs *after* `test` regardless of whether `test` passed. If you want it to run only when specific dependencies behaved a certain way, inspect their results explicitly. ## Reading dependency state The `needs` context exposes each dependency: - `needs.<job_id>.result` — one of `success`, `failure`, `cancelled`, `skipped`. - `needs.<job_id>.outputs.<name>` — values the dependency published through its own `outputs:` map. So a summary job can say: ``` if: always() steps: - run: exit 1 if: needs.test.result != 'success' ``` This pattern — one always-running gate job that inspects `needs.*.result` — is how teams get a single stable job name for a required check while the jobs underneath it change shape. ## What `needs:` does *not* give you It does not share a filesystem. Each job gets its own runner and its own empty workspace, so a file built by `test` is invisible to `package` unless you upload it as an artifact and download it, or the job rebuilds it. `needs:` moves *ordering and small values*, not gigabytes of build output. It also does not reduce cost by itself. Serialising jobs makes the run longer while consuming the same total runner minutes; it is worth doing only when there is a genuine dependency, or when you want to avoid spending minutes on a package/deploy job that a failed test has already invalidated. ## Designing the graph The practical shape most pipelines converge on is a diamond: a cheap fan-out of independent checks (lint, unit tests, a matrix of versions), a fan-in job that packages or aggregates, and a deploy job gated behind it. The critical path — the longest chain of `needs:` edges plus each job's own duration — is the wall-clock time of your pipeline, and shortening it is almost always a matter of deleting an unnecessary edge rather than making a single job faster.

  • In GitHub Actions, how do you make a job run even when the job it needs failed?
    Give it an explicit condition: `if: always()` runs it in every case including cancellation, and `if: !cancelled()` runs it after success or failure but honours cancellation. Any truthy `if:` removes the implicit "all dependencies succeeded" gate, so if the job should still care about the outcome, inspect `needs.<job_id>.result` inside the job and fail deliberately.
  • What does needs.<job_id>.result return in GitHub Actions, and when is it 'skipped'?
    It returns the dependency's conclusion: `success`, `failure`, `cancelled`, or `skipped`. It is `skipped` when that job's own `if:` evaluated false, or when one of *its* dependencies failed or was skipped so it never started. This is why an aggregate gate job must treat `skipped` as not-success rather than testing only for `failure`.
  • Does needs: let a dependent job read files produced by its dependency?
    No. Each job runs on its own runner with an empty workspace, so nothing on disk carries over. Small string values travel through the dependency's `outputs:` map, read as `needs.<job_id>.outputs.<name>`. Files must be uploaded with `actions/upload-artifact` and fetched with `actions/download-artifact`, or simply rebuilt in the dependent job.

saying these in an interview costs you the question

  • Thinks jobs run top to bottom in file order
  • Believes needs: shares the workspace between jobs
  • Says a dependent job fails when its dependency fails
  • Adds if: always() and expects the success gate to remain
  • Repeats transitive dependencies believing order requires it

context