---
title: "CI/CD Pipelines: Best Practices"
slug: "ci-cd"
category: "ops"
tags: ["ci-cd", "github-actions", "ops", "automation", "testing", "deployment"]
status: "stable"
last_updated: 2026-08-14
summary: "Order CI gates cheapest-first, cache dependencies, enforce required status checks, and mirror CI locally with pre-commit hooks."
related: ["[[howto/set-up-github-actions-cache]]", "[[coding/git]]", "[[coding/testing]]", "[[ops/error-tracking]]", "[[tooling/github-secrets]]", "[[ops/pre-launch-checklist]]"]
---

## 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 [[coding/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.

```yaml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: "npm"
      - run: npm ci
      - run: npm run lint

  typecheck:
    needs: lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: "npm"
      - run: npm ci
      - run: npm run typecheck

  test:
    needs: typecheck
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: "npm"
      - run: npm ci
      - run: npm test

  build:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: "npm"
      - run: npm ci
      - run: npm run build
      - uses: actions/upload-artifact@v7
        with:
          name: build-output
          path: dist/

  deploy:
    needs: build
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v8
        with:
          name: build-output
          path: dist/
      - run: npm run deploy
```

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

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

## Cache dependencies to cut job time

`actions/setup-node@v7` 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@v6` 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 [[ops/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 [[ops/error-tracking]] once the pipeline ships to production. For version control discipline that keeps the pipeline useful, see [[coding/git]].

## Related

- [[howto/set-up-github-actions-cache]]
- [[coding/git]]
- [[coding/testing]]
- [[ops/error-tracking]]
- [[tooling/github-secrets]]
- [[ops/pre-launch-checklist]]
