Skip to content

Phase 2 · CONTAINERS & CI/CD

GitHub Actions 2 — jobs, matrices, secrets

Day 33 of 90 ~55 min 0/25 in phase Builds on Day 32

By the end of today

  • Split a workflow into jobs and order them with needs
  • Run a build matrix across versions with strategy.matrix
  • Pass data with job outputs and inject repository secrets safely

Jobs, matrices and secrets: run work in parallel, in order, and in secret

Section 1 of 5 · ~3 min

Yesterday you wrote a single-job workflow that runs on push. Today you split that one job into several, decide what runs in parallel and what waits its turn, fan a job out across many versions at once, and feed it values that must never appear in your YAML.

A workflow is a set of jobs, and by default every job runs in parallel on its own fresh runner. That is fast, but end to end it is rarely what you want — you don’t want to deploy before you’ve tested. needs: draws the dependency edges. Putting needs: test on the deploy job means “wait for test to finish successfully, then start.” List several — needs: [lint, test] — and the job waits for all of them; jobs with no needs relationship keep running at the same time. So lint and test race in parallel, build waits for both, and deploy waits for build: a diamond, not a straight line.

Because each job is a separate machine, jobs share nothing by default — no files, no variables. To pass a value forward you declare a job output: a step writes echo "tag=v1.0.3" >> "$GITHUB_OUTPUT", the job exposes it under outputs:, and a downstream job reads it as ${{ needs.build.outputs.tag }}. Keep env: and outputs straight: env: sets plain environment variables for a step, a job, or the whole workflow — reach for it for non-secret config, and for a job output when you need to hand a computed value to the next job.

A GitHub Actions job graph: lint and test run in parallel (test fans out across a Node 20/22/24 matrix), build declares needs on both, and deploy declares needs on build and reads a secret — ordering enforced by needs, not by file order. job: lint job: test strategy.matrix 20·22·24 run in parallel job: build needs: [lint, test] job: deploy needs: build + secret
needs draws the edges: lint and test run at once, build waits for both, deploy waits for build — order comes from needs, never from where a job sits in the file.

Real world: Think of a restaurant kitchen on a busy night. Two prep cooks — salads and grill — work side by side at the same time; that’s your parallel lint and test. Plating can’t start until both are done, and the waiter won’t leave the pass until the plate is up: build needs both cooks, deploy needs the plate. Everyone works as early as they can, but the order that actually matters is enforced.

One job, many versions — and secrets it can’t leak

A matrix stamps out copies of the same job, one per value you list. strategy.matrix.node: [20, 22, 24] runs the job three times in parallel, each with ${{ matrix.node }} set to a different version — the standard way to prove your code works on everything you support without copy-pasting the job. Add a second key and the matrix multiplies: a node × os matrix becomes six jobs.

GitHub’s own actions/setup-node action is built to be matrix-driven: feed it node-version: ${{ matrix.node }} and each copy of the job installs its own version, so one short job tests Node 20, 22 and 24 at once instead of three near-identical jobs.

Some jobs need a token, a password, or an API key. You never paste those into YAML — anyone who can read the repo would read the secret, and git would keep it in history forever. Instead you store it once under the repo’s Settings → Secrets and reference it as ${{ secrets.DEPLOY_TOKEN }}. GitHub injects the value at run time and masks it in logs, where it prints as ***. Repository secrets are visible to every workflow in the repo; environment secrets attach to a named environment such as production and can sit behind a required approval before they unlock.

Three independent dials, then: needs decides what runs when, a matrix decides how many times, and secrets decide what the job is trusted with — you’ll turn all three in the lab below.

Hands-On Lab

Section 2 of 5 · ~3 min

Budget about 25 minutes. Open your WSL2 Ubuntu 24.04 terminal with the GitHub CLI installed and logged in (gh auth login). You’ll push a tiny repo, wire up a four-job workflow, and read the run back with gh. Run IDs, job IDs, durations, SHAs and timestamps are unique to each run — yours will differ from the samples below.

# 1. Make a repo with one tiny Node file and push it to GitHub (identity: user pushkar).
mkdir -p ~/gha-jobs-lab && cd ~/gha-jobs-lab
echo '{"name":"gha-jobs-lab","version":"1.0.0"}' > package.json
git init -q -b main && git add -A && git commit -q -m "init"
gh repo create gha-jobs-lab --private --source=. --push
# Output (URL and object counts are yours-will-differ):
# ✓ Created repository pushkar/gha-jobs-lab on GitHub
# ✓ Pushed commits to https://github.com/pushkar/gha-jobs-lab.git
# 2. Write .github/workflows/ci.yml — lint + test run in parallel, build waits for
#    both, deploy waits for build and consumes a secret. (Note the current syntax:
#    actions/checkout@v4, actions/setup-node@v4.)
name: ci
on:
  push:
    branches: [main]
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "linting" && test -f package.json
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node: [20, 22, 24]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
      - run: node --version
  build:
    needs: [lint, test]
    runs-on: ubuntu-latest
    outputs:
      tag: ${{ steps.meta.outputs.tag }}
    steps:
      - id: meta
        run: echo "tag=v1.0.${{ github.run_number }}" >> "$GITHUB_OUTPUT"
      - run: echo "built ${{ steps.meta.outputs.tag }}"
  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: production
    steps:
      - env:
          TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: |
          test -n "$TOKEN" || { echo "no deploy token provided"; exit 1; }
          echo "deploying ${{ needs.build.outputs.tag }} with a ${#TOKEN}-char token"
# 3. Store the secret ONCE (never in YAML), then commit and push the workflow.
echo -n "s3cr3t-deploy-token-value" | gh secret set DEPLOY_TOKEN
git add .github/workflows/ci.yml && git commit -q -m "Add CI workflow"
git push -q
# Output:
# ✓ Set Actions secret DEPLOY_TOKEN for pushkar/gha-jobs-lab
# 4. The push triggered the workflow. List the run (ID, timings and AGE are yours-will-differ).
gh run list --limit 1
# Output:
# STATUS  TITLE            WORKFLOW  BRANCH  EVENT  ID           ELAPSED  AGE
# ✓       Add CI workflow  ci        main    push   16823904715  52s      1m
# 5. View the run — see lint and test race in parallel, then build, then deploy.
gh run view 16823904715
# Output (run/job IDs and durations are yours-will-differ):
# ✓ main ci · 16823904715
# Triggered via push about 1 minute ago
#
# JOBS
# ✓ lint in 7s (ID 47120558831)
# ✓ test (20) in 13s (ID 47120558955)
# ✓ test (22) in 14s (ID 47120558902)
# ✓ test (24) in 15s (ID 47120559011)
# ✓ build in 6s (ID 47120561204)
# ✓ deploy in 4s (ID 47120562388)
# 6. Read one matrix leg's log — each copy installed a different Node version.
gh run view 16823904715 --log --job 47120558955 | grep "node --version"
# Output (the version matches THIS leg — test (20) — yours will differ):
# test (20)  Run node --version  node --version
# test (20)  Run node --version  v20.19.0
# 7. Read the deploy log — it consumed build's output and the masked secret.
gh run view 16823904715 --log --job 47120562388 | grep deploying
# Output (the token value is masked to *** in logs; only its length prints):
# deploy  Run test -n ...  deploying v1.0.1 with a 25-char token

Read those last three outputs back: one push fanned into six jobs, lint and test ran together while build and deploy waited their turn, a matrix tested three Node versions from one short job, build handed its tag to deploy through an output, and the deploy step used a secret that never appears in your YAML or its logs. That is every dial on today’s workflow, turned once.

Common Errors & Fixes

Section 3 of 5 · ~3 min

These three catch almost everyone in their first week wiring multi-job workflows. Read the error text slowly — learning to parse it is the actual skill.

Common error: Mis-indenting a key so the workflow no longer parses — here matrix: is nested one level too shallow under strategy::

Invalid workflow file: .github/workflows/ci.yml#L13
The workflow is not valid. .github/workflows/ci.yml (Line: 13, Col: 7): A mapping was not expected

Why: YAML is whitespace-significant — indentation, not braces, defines nesting. strategy: expects a matrix: key indented beneath it; put it at the wrong depth and the parser sees a mapping where it expected a value (or vice versa) and rejects the whole file. GitHub never runs a workflow it can’t parse, so nothing triggers.

Fix: Fix the indentation (two spaces per level, spaces never tabs) so matrix: sits under strategy: and node: under matrix:. Paste the file into a YAML linter or the repo’s Actions tab, which points at the exact line and column from the error.

How you’d spot it in prod: A commit that “should have” triggered CI produces no run at all, and the repo’s Actions tab shows a red “Invalid workflow file” banner instead of a run. No run starting is the tell — a parse error stops the workflow before any job exists.

Common error: A needs: that names a job which doesn’t exist — usually a typo like buld for build:

Invalid workflow file: .github/workflows/ci.yml#L32
The workflow is not valid. .github/workflows/ci.yml (Line: 32, Col: 5): Job 'deploy' depends on unknown job 'buld'.

Why: needs references other jobs by their id — the map key under jobs: (build), not the job’s display name. A mistyped id matches no job, so the dependency graph can’t be built and the whole workflow is rejected before it runs.

Fix: Make the string in needs: match the job key exactly, character for character. If you renamed a job, update every needs: and every ${{ needs.<id>.outputs.* }} that referenced the old id.

How you’d spot it in prod: Right after someone renames or reorders jobs, the workflow stops triggering and the Actions tab reports “depends on unknown job.” Grep the workflow for needs: and confirm each name is a real job key.

Common error: Referencing a secret that was never created (or whose name is misspelled) — the expression expands to an empty string, and the step that relies on it fails:

Run test -n "$TOKEN" || { echo "no deploy token provided"; exit 1; }
no deploy token provided
Error: Process completed with exit code 1.

Why: A missing secret is not a workflow error — ${{ secrets.DEPLOY_TOKEN }} silently becomes an empty string, so TOKEN is blank and the guard exits non-zero. The same happens for workflows triggered by a pull request from a fork, where secrets are deliberately withheld so untrusted code can’t read them.

Fix: Create the secret with the exact name and case (gh secret set DEPLOY_TOKEN, or Settings → Secrets), and confirm the reference matches. For fork PRs, gate secret-using steps behind an environment with approval rather than expecting the secret on every trigger.

How you’d spot it in prod: A job that passes on main fails with “Process completed with exit code 1” right where a token is used — after a repo migration, a secret rename, or on a fork PR. An empty-looking value in the failing command is the tell; the secret didn’t reach the run.

Jobs, Matrices and Secrets Interview Questions

Section 4 of 5 · ~1 min

These four are the multi-job GitHub Actions questions a CI/CD screening round actually asks — ordering, matrices, cross-job data and secret handling. Cover each answer, say your own version out loud first, then compare — recalling before revealing is what makes it stick for interview day. The four questions and answers render right after this note.

Go Deeper

Section 5 of 5 · ~1 min

Optional extras if you have ~30 more minutes today:

  • 5 min — Re-run the workflow with gh workflow run ci.yml (or push an empty commit) and watch it live with gh run watch; see the matrix legs and dependent jobs light up in real time.
  • 10 min — Add strategy.fail-fast: false plus one deliberately failing matrix leg, re-run, and watch the other legs finish instead of being cancelled — then read GitHub’s matrix docs.
  • 15 min — Read GitHub’s Using secrets in GitHub Actions and Using jobs in a workflow, which lay out needs, outputs and environments end to end.
How do you make one GitHub Actions job run only after another? Both

By default jobs in a workflow run in parallel on separate runners, so to force an order I add needs: to the dependent job. needs: test on a deploy job holds it until test succeeds; needs: [lint, test] waits for both. Anything without a needs relationship keeps running concurrently, so I get parallelism where it's safe and ordering where it matters — lint and test together, build after both, deploy last. A failed dependency skips the jobs that need it, which stops a broken build from ever reaching deploy. It's the single mechanism for expressing 'this must happen before that' across jobs.

What is a build matrix and when would you use one? Both

A matrix runs the same job many times with different inputs, defined under strategy.matrix. matrix.node: [20, 22, 24] expands into three parallel jobs, each with the matrix.node expression set to one version. I reach for it whenever I support more than one version or platform — testing across Node or Python versions, or Linux and Windows runners — because it replaces three near-identical copied jobs with one definition. Add a second key and it multiplies: node times os becomes six jobs. It keeps the workflow short and guarantees every combination is actually tested, and I can drop unwanted pairs with exclude or turn fail-fast off for full coverage.

How do you pass a value from one job to another in GitHub Actions? Both

Jobs run on separate machines and share no filesystem, so I use job outputs. In the producing job a step writes to the special file — echo tag=v1.2.3 >> $GITHUB_OUTPUT — gives itself an id, and the job maps that under outputs:. The consuming job declares needs: on the producer and reads it as needs.build.outputs.tag. That needs link is required — without it the output isn't visible. I use this for things like a computed version tag or an image digest that build produces and deploy consumes. For values inside a single job I'd use env or step outputs instead; job outputs are specifically the cross-job channel.

How does GitHub Actions handle secrets, and why not put them in the YAML? Service

Secrets are stored in the repo's settings, not the code, and referenced as secrets.NAME; GitHub injects the value at run time and masks it in logs as three asterisks. You never hard-code a token in YAML because the file is readable by anyone with repo access and lives in git history forever — a leak you can't take back. Repository secrets are available to every workflow; environment secrets attach to an environment like production and can require an approval to unlock, which adds a gate before a deploy. One caveat I mention: secrets aren't passed to workflows triggered by pull requests from forks, so fork CI can't exfiltrate them.

Mark Day 33 complete

Tomorrow you put it all together — build your Docker image inside Actions and push it to the GitHub Container Registry.

Stuck on today’s lab? Ask in Mission 90 Q&A