Skip to content
Back to Quality Assurance

Build Gates

Ensure the code compiles and builds in CI before it can merge, catching integration errors that local environments miss.

Why

"It works on my machine" is the most famous phrase in software engineering for a reason — the developer's local environment has a specific Node version, a specific OS, environment variables that are not in the repo, and dependencies that were installed months ago. CI has a clean environment, the exact Node version from .nvmrc, the exact dependencies from package-lock.json, and no local state. A build gate runs the build in this clean environment and catches the integration errors that the local environment missed: the import that works locally because of a stale cache, the dependency that is in devDependencies but used in production, the environment variable that is set locally but not in CI. The build gate is the first proof that the code actually compiles and integrates — without it, the team discovers the build failure in production (or in the release pipeline, which is too late). A build gate that takes 3 minutes and runs on every PR is the cheapest insurance against "it works on my machine" bugs.

When

On every PR, before merge — the build gate is the minimum proof that the code compiles and integrates.

How

1. Run the build in a clean CI environment
   - Use the exact Node/Python/Java version from .nvmrc/.python-version
   - Install dependencies from the lockfile (npm ci, pip install -r requirements.txt)
   - Do not use cached node_modules from the developer's machine
   - This catches "works on my machine" bugs

2. Build the production artifact, not just the dev build
   - npm run build (production build, not dev server)
   - This catches production-only issues (tree-shaking, env vars)
   - If the dev build works but the production build fails, that is a bug

3. Run the build on multiple platforms (if you deploy to multiple)
   - Matrix build: [ubuntu-latest, windows-latest, macos-latest]
   - This catches platform-specific issues (path separators, native modules)
   - If you only deploy to Linux, only build on Linux

4. Cache the build dependencies (but not the build output)
   - Cache node_modules (speeds up npm ci)
   - Do not cache dist/ (the build must run fresh to catch errors)
   - Cache .next/cache or target/ (incremental build artifacts)

5. Fail fast on build errors
   - The build gate should fail immediately on the first error
   - Do not continue running tests on a broken build
   - The error message should be clear: which file, which line, what error

6. Require the build gate to pass before merge
   - Branch protection: require the build job to pass
   - Do not allow merge if the build fails
   - This prevents broken builds from reaching main

Example

# .github/workflows/ci.yml — build gate
name: CI
on: [pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20]  # test multiple Node versions
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: npm  # cache node_modules
      - run: npm ci  # install from lockfile (not npm install)
      - run: npm run build  # production build
      - run: npm run typecheck  # tsc --noEmit

# .nvmrc — pin the Node version
20

# package.json — build scripts
{
  "scripts": {
    "build": "next build",  // production build
    "typecheck": "tsc --noEmit",
    "dev": "next dev"  // dev server (not used in CI)
  }
}

# Common build errors the gate catches:
# 1. Import error: works locally (stale cache) but fails in CI
#    Error: Cannot find module './utils' or its corresponding type declarations
#    Fix: the import path is wrong, or the file is not committed

# 2. Type error: works locally (tsc not run) but fails in CI
#    Error: Property 'name' does not exist on type 'User'
#    Fix: the type is wrong, or the property is optional

# 3. Env var error: works locally (set in .env) but fails in CI
#    Error: Missing environment variable: DATABASE_URL
#    Fix: add the env var to the CI workflow or to the repo secrets

# 4. Dependency error: works locally (in node_modules) but fails in CI
#    Error: Cannot find package 'lodash'
#    Fix: the dependency is not in package.json, or it is in devDependencies
#         but used in production code

# Branch protection: require the build gate
# Settings > Branches > Branch protection rules:
# - Require status checks to pass: build
# - Require branches to be up to date before merging

Common Problems

  • ⚠Using npm install instead of npm ci in CI — npm install updates the lockfile and installs different versions than the developer, and the build passes in CI but fails in production (or vice versa) because the dependencies are different.
  • ⚠Caching the build output (dist/) instead of just the dependencies — the build is skipped because the output is cached, and a build error that would be caught by a fresh build is missed, and the broken code merges.
  • ⚠Only building on one platform when the app deploys to multiple — the build passes on Linux but fails on Windows because of a path separator issue, and the team discovers it in production.
  • ⚠Not running the production build in CI, only the dev build — the dev build skips tree-shaking, env var validation, and minification, and the production build fails on a issue that the dev build did not catch.

Best Practices

  • ✓Run the production build (npm run build) in a clean CI environment with npm ci (not npm install) — this catches the "works on my machine" bugs that local environments miss.
  • ✓Cache dependencies (node_modules) but not the build output (dist/) — the build must run fresh to catch errors, but the dependency install can be cached for speed.
  • ✓Build on all target platforms (Linux, Windows, macOS) if you deploy to multiple — this catches platform-specific issues like path separators and native module compatibility.
  • ✓Require the build gate to pass before merge with branch protection, and do not allow bypass — a broken build on main blocks the entire team and is 10x more expensive to fix than catching it in the PR.