17 — Jobs that depend on jobs
Read this first: one job is a script. Several jobs with dependencies between them is a
pipeline. This lesson covers needs, passing values between jobs, if: conditions, concurrency,
and the environment: setting whose real behaviour surprises everyone — including, once, this
project's production deploy.
Time: about 50 minutes. Assumes lesson 16.
Jobs are parallel until you say otherwise
jobs:
test:
runs-on: ubuntu-latest
steps: [...]
build:
needs: test
runs-on: ubuntu-latest
steps: [...]
deploy:
needs: build
runs-on: ubuntu-latest
steps: [...]
Jobs run in parallel by default. needs: creates the ordering, and the result is a graph rather
than a list — which is what you want, because two independent builds should not wait for each other.
The real pipeline builds backend and frontend simultaneously, both gated on tests:
test ──┬── build-backend ──┐
└── build-frontend ─┴── deploy-staging ── deploy-production
Each job gets a fresh runner. Nothing carries over — not files, not environment variables, not the checkout. That is the single most common surprise, and it is why passing values needs an explicit mechanism.
Passing values between jobs
build:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.tag.outputs.tag }}
steps:
- id: tag
run: echo "tag=sha-${GITHUB_SHA:0:12}" >> "$GITHUB_OUTPUT"
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- run: echo "deploying ${{ needs.build.outputs.tag }}"
Three hops: a step writes to $GITHUB_OUTPUT, the job republishes it under outputs:, and the
downstream job reads needs.<job>.outputs.<name>. Miss the middle hop and you get an empty string
with no error — the same silent-empty failure as a missing secret in
lesson 16.
For files rather than strings, use actions/upload-artifact and actions/download-artifact. The
real docs workflow does exactly that: a spec job boots the backend, exports its OpenAPI document,
and uploads it; a later build job downloads it. It also sets if-no-files-found: error, so an
empty upload fails loudly instead of producing a site with no API reference.
if: — deciding whether a job runs
deploy-production:
needs: [build, deploy-staging]
if: github.ref == 'refs/heads/main'
Common conditions:
| Condition | Runs when |
|---|---|
github.ref == 'refs/heads/main' | Only on main |
github.event_name == 'workflow_dispatch' | Only when the button was pressed |
success() | All needs succeeded — the default |
always() | Even if a dependency failed |
failure() | Only if a dependency failed |
Predict: job c has needs: [a, b], and a fails. Does b run? Does c?
b runs — it does not depend on a. c is skipped, because the default condition is
success() and one of its dependencies failed. Add if: always() and c runs regardless, which is
how you attach a step that uploads logs or reports a failure.
if: ${{ !cancelled() }} is a useful middle ground: run on success or failure, but not when someone
hit cancel. The Playwright workflow uses it to upload its report.
There is also the expression form GitHub uses instead of a ternary:
tags: |
ghcr.io/you/greeter-api:${{ steps.tag.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'ghcr.io/you/greeter-api:latest' || '' }}
A && B || C evaluates to B when A is true and C otherwise. Here it adds a :latest tag only
on main, and contributes an empty line otherwise — which the tags: list ignores. It is the
repository's trickiest single expression, and now it is readable.
concurrency — and what it actually guarantees
concurrency:
group: deploy-production
cancel-in-progress: false
Runs in the same group do not execute simultaneously. cancel-in-progress: false lets a running
deploy finish rather than being killed halfway through — obviously right for something that mutates
a server.
But read the actual semantics, because they are not a queue. GitHub keeps one running job and at most one pending job per group. A third arrival does not join a line — it replaces the pending one, which is silently cancelled.
That has a consequence the repository documents. In rollback.yml the setting is inverted:
concurrency:
group: deploy-production
cancel-in-progress: true
The reasoning, from the file's own comment: a rollback queued behind a running deploy could be bumped out of the pending slot by the next push to main and never run at all. During an incident that is unacceptable, so a rollback is allowed to preempt. Same group, opposite setting, and the difference is deliberate.
Note also that concurrency controls workflow runs, not what happens on the server. A cancelled
workflow can leave its SSH-spawned script still executing. That is why deploy.sh takes its own
flock on the server — belt and braces, protecting against something GitHub cannot see.
Break it on purpose: the secret that reads as empty
This is the one that cost this project a production deploy, and it is worth reproducing.
environment: looks like a label:
deploy-staging:
environment: Production
It is also a scope selector for secrets. A secret attached to a GitHub Environment is readable only by a job that declares that environment. A job without the declaration sees an empty string — not an error.
Predict: you store VPS_HOST in an environment named Production, and your deploy job does not
declare environment:. What does the SSH step report?
Error: missing server host
Nothing about permissions, nothing about environments. Just a tool complaining about an argument it was handed empty.
The real history was worse: the first symptom was that one error, and after a partial fix every secret read as empty. Hence the length-checking preflight step from lesson 16.
The fix has two valid shapes — put the secrets at repository scope, or declare the environment on
every job that needs them. This repository does the second, which is why its staging deploy job
declares environment: Production:
deploy-staging:
environment:
name: Production
url: https://stage.motorphenterprise.com
That looks like a bug and is not. Staging needs the same server credentials, so it declares the same environment. A 14-line comment in the file explains it — and "why does your staging job declare a Production environment?" is a genuinely good thing to be able to answer in an interview.
Note that promote.yml declares no environment, which is why the manual promote button needs those secrets at repository scope to work at all.
Reusable workflows
test:
uses: ./.github/workflows/tests.yml
A whole workflow can be called as a job, if it declares on: workflow_call. The real deploy.yml
does this rather than copying the test steps, so the suite is defined once and cannot drift between
the pull-request run and the deploy run.
Notice what is missing from tests.yml's triggers: there is no push:. It runs on pull requests
and when called. Without that omission, merging would run the whole suite twice.
Recap
- Jobs are parallel;
needsmakes a graph. Each job is a fresh machine — nothing carries over. - Pass strings with
$GITHUB_OUTPUT→outputs:→needs.<job>.outputs.<x>, files with artifacts. Miss a hop and you get an empty string, silently. concurrencykeeps one running and one pending; a third arrival replaces the pending one. That is why rollback invertscancel-in-progress.environment:scopes secrets. A job that does not declare it reads them as empty strings.
Next: 18 — Deploying from CI.