GitHub Actions in Depth
Topic 53

Building a CI Pipeline

CI/CD

A CI pipeline takes "it works on my machine" and turns it into "it works on a clean VM, every time." The job runs on a runner that starts empty: it checks out your source, installs the toolchain, restores dependencies, builds, runs tests, and exits non-zero the moment any gate fails. The shape is identical across languages — only the setup action changes.

The discipline that makes CI worth running is that a red build blocks the merge. A pipeline that always goes green because failures are swallowed is theatre. Every step below exists to make a real failure stop the run, and to make the run a required check on the branch.

A CI pipeline on a clean runner
checkout
setup toolchain
installnpm ci
build
testgate
required status check

Checkout

The runner has no idea what repository triggered it until you check it out. actions/checkout@v4 is the first step in almost every code job; skip it and every later step fails because there are no files on disk. It also configures the credentials the rest of the workflow uses for Git operations.

A minimal Node.js CI job
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - run: npm test

Toolchain Setup

The language setup-* actions — setup-node, setup-python, setup-go — install the requested runtime and, crucially, wire up dependency caching with a single cache: option. Pinning an explicit version here is what keeps the runner's behavior reproducible instead of drifting with whatever the image ships this month.

Prefer the built-in cache: over hand-rolling actions/cache; the setup action already knows the lockfile path and the cache key for its ecosystem, so you get correct invalidation for free.

Install and Build

Use a deterministic install. npm ci installs exactly what the lockfile specifies and fails if package-lock.json is out of sync; npm install can mutate the lockfile and quietly paper over dependency drift. The equivalents are pip install -r requirements.txt with hashes, or go mod download. The point is that the lockfile, not the installer's resolution, is authoritative.

Test Gates

A test step that exits non-zero fails the job, and any downstream job that declared needs: on it is skipped rather than run against a broken build. That chaining is the gate: build feeds test feeds deploy, and a failure upstream stops everything below it.

Resist the urge to make a flaky step green with || true or continue-on-error: true. Those turn a gate into a decoration — the run reports success while the thing it was supposed to verify is broken.

Status Checks and Branch Protection

A passing workflow only blocks bad merges if you make it a required status check in the branch protection rules for main. Without that, a pull request with a red build can still be merged. Splitting lint, type-check, and test into separate parallel jobs keeps the pipeline fast and makes each required check independently legible in the PR.

Common Mistakes
  • Omitting actions/checkout and then debugging why the build cannot find a single source file — the runner starts with an empty workspace.
  • Running npm install instead of npm ci in CI, which can rewrite the lockfile on the runner and hide the dependency drift CI exists to catch.
  • Adding || true or continue-on-error: true to a failing test step to force the run green, which silently disables the gate.
  • Never marking the build job as a required status check, so pull requests merge with a red CI run.
  • Reinstalling dependencies from scratch on every run with no cache, adding minutes of avoidable wall-clock time per build.
Best Practices
  • Start every code job with actions/checkout@v4 before any step that touches the source.
  • Use the language's setup-* action with its built-in cache: option instead of configuring actions/cache by hand.
  • Run a deterministic install — npm ci, hash-locked pip install, or go mod download — so the lockfile is the source of truth.
  • Make the build and test jobs required status checks in branch protection so red builds cannot merge.
  • Parallelize lint, type-check, and test into separate jobs to keep total pipeline time short.
Comparable toolsGitLab CI/CD script stages with needs for the DAGCircleCI checkout step plus orbs for toolchain setupJenkins checkout scm and declarative stagesAzure Pipelines checkout and tasks

Knowledge Check

Why is npm ci preferred over npm install in a CI job?

  • It installs exactly what the lockfile specifies and fails on drift, instead of mutating it
  • It is the only install command the npm registry will accept from a hosted GitHub-managed runner
  • It skips installing the listed dev dependencies to keep the install step faster
  • It automatically caches and restores node_modules between runs with no further config

What happens if you forget actions/checkout in a build job?

  • The job runs on an empty workspace, so later steps cannot find any source files
  • GitHub clones the source into the workspace automatically before the first step runs
  • Only the README at the repo root is staged; all other tracked files must be fetched by hand
  • The workflow fails to parse immediately and the job reports a YAML syntax error

What does adding continue-on-error: true to a test step do to the CI gate?

  • It lets the job report success even when the tests failed, disabling the gate
  • It re-runs the failed step up to three times until the tests finally pass
  • It pauses and blocks the merge until a reviewer approves the failing tests
  • It only annotates the logs and has no effect on the job's final outcome

How does a workflow become a check that actually blocks a merge?

  • By configuring it as a required status check in the branch protection rules
  • By naming the job required in the workflow file so GitHub treats it as a gate
  • Any workflow that passes on the head commit blocks the merge by default
  • By setting fail-fast: true on the job so a failure stops the merge

You got correct