Skip to main content

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:

ConditionRuns 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; needs makes 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.
  • concurrency keeps one running and one pending; a third arrival replaces the pending one. That is why rollback inverts cancel-in-progress.
  • environment: scopes secrets. A job that does not declare it reads them as empty strings.

Next: 18 — Deploying from CI.