Overview

A CI/CD pipeline is a sequence of automated gates that run on every push and pull request, catching failures before they reach production. Order gates cheapest first so the pipeline fails fast; send developers the signal in seconds, not minutes.

Run gates in this order: lint, typecheck, test, build, deploy

The correct sequence is lint, typecheck, test, build, then deploy. Each gate costs more than the previous one. A lint failure caught in two seconds wastes far less compute than a lint failure discovered at the end of a five-minute build.

  • Lint: static analysis and style enforcement. Runs in under 10 seconds.
  • Typecheck: compile-time type errors. Runs in 10 to 30 seconds for most projects.
  • Test: unit and integration tests. See testing for scope decisions.
  • Build: asset compilation, bundling, static generation. Minutes for large sites.
  • Deploy: publish the artifact. Should only run on main, not on PRs.

Never skip a gate to “save time.” A passing build with a suppressed lint step gives false confidence.

Use a single workflow file with dependent jobs

GitHub Actions supports needs to chain jobs. Declare them so each gate runs only after the previous one passes. This structure also lets you read the failure reason at a glance in the Actions UI.

name: CI
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "npm"
      - run: npm ci
      - run: npm run lint
 
  typecheck:
    needs: lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "npm"
      - run: npm ci
      - run: npm run typecheck
 
  test:
    needs: typecheck
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "npm"
      - run: npm ci
      - run: npm test
 
  build:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "npm"
      - run: npm ci
      - run: npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: dist/
 
  deploy:
    needs: build
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: build-output
          path: dist/
      - run: npm run deploy

The cache: "npm" key inside actions/setup-node@v4 is built-in caching that hashes package-lock.json and restores the npm cache automatically. For more complex cache strategies, see set-up-github-actions-cache.

Store deployment credentials as repository secrets rather than hardcoded values. See github-secrets for the scoping rules between repository, environment, and organization secrets.

Cache dependencies to cut job time

actions/setup-node@v4 with cache: "npm" handles the common case. For monorepos or workspaces, set cache-dependency-path: "**/package-lock.json" to include nested lockfiles. The cache key invalidates automatically when any matched lockfile changes.

For build output caching (compiled assets, generated types), use actions/cache@v4 directly with a key that includes a hash of the source files. A stale build cache that produces wrong output is harder to debug than a slow build; prefer narrow cache keys over broad ones.

Require status checks before merge

In the repository settings under Branches, create a protection rule for main. Enable “Require status checks to pass before merging” and type the exact job names from the workflow file (lint, typecheck, test, build). Do not add deploy as a required check; it only runs on main, not on PRs.

Enable “Require branches to be up to date before merging.” This prevents a scenario where two PRs pass tests independently but break when merged together. For high-volume repositories, enable the merge queue so GitHub serializes merges automatically.

Per the GitHub docs, the status check name must match the job name exactly, not the workflow name. A mismatch silently skips the requirement.

Deploy on merge to main; preview on PRs

The deploy job above gates on github.ref == 'refs/heads/main'. This ensures PRs never trigger a production deploy.

For preview deployments per PR, providers like Vercel and Cloudflare Pages integrate with GitHub Actions to post a preview URL as a PR check. If you build to a static artifact, you can also upload it to a staging bucket per PR and post the URL with actions/github-script. Add preview URL checks to pre-launch-checklist before promoting to production.

Mirror CI gates locally with pre-commit hooks

Pre-commit hooks catch issues before a push reaches CI. Use Husky to wire lint and typecheck to the pre-commit hook, and commit-msg validation to the commit-msg hook. Run only staged files with lint-staged to keep the hook under two seconds.

The hook does not replace CI. A developer can bypass hooks with git commit --no-verify. CI is the enforcing gate; local hooks are the fast-feedback loop. Mirror the same commands in both places so developers never see a surprise failure on push that passes locally.

Track deploy failures and regressions in error-tracking once the pipeline ships to production. For version control discipline that keeps the pipeline useful, see git.