Continuous Integration: The Bulletproof Pipeline

Continuous Integration: Building a Bulletproof Pipeline
Continuous Integration (CI) is the practice of automatically building, testing, and validating every code change before it can be merged. A bulletproof CI pipeline catches bugs before they reach production, enforces code quality standards, and gives developers confidence to ship frequently.
This guide builds a complete CI pipeline from scratch: linting and formatting checks, unit and integration tests, security scanning, build verification, artifact management, branch protection, and the discipline of the 10-minute rule. All examples use GitHub Actions.
What a Complete CI Pipeline Covers
Every pull request triggers:
┌─────────────┠┌─────────────┠┌─────────────┠┌─────────────â”
│ Lint + │ │ Unit + │ │ Security │ │ Build │
│ Format │──▶│ Integration │──▶│ Scan │──▶│ Verify │
│ │ │ Tests │ │ │ │ │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
~1 min ~3 min ~2 min ~2 min
â–¼
Merge allowed| Stage | What it checks | Blocks merge? |
|---|---|---|
| Lint | Code style, unused imports, formatting | Yes |
| Type check | TypeScript compiler errors | Yes |
| Unit tests | Individual function correctness | Yes |
| Integration tests | Database, API contract tests | Yes |
| Security scan | Dependency CVEs, secret leakage | Yes |
| Build | Production bundle compiles | Yes |
| Coverage | Test coverage threshold | Configurable |
The 10-Minute Rule
A CI pipeline that takes 30+ minutes gives developers no fast feedback loop. The rule: the entire CI suite must complete in under 10 minutes. Engineers waiting longer will start skipping CI or working around it.
Time budget:
Lint + type check: 30 seconds
Unit tests: 2–3 minutes
Integration tests: 3–4 minutes
Security scan: 1 minute
Build: 1 minute
Total target: < 10 minutes
Techniques to stay within budget:
- Run lint/type-check/tests in parallel jobs
- Cache node_modules, pip packages, Go modules
- Skip unchanged packages in monorepos (Nx, Turborepo)
- Run integration tests only on PR (not on every push)Complete GitHub Actions CI Pipeline
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
# Cancel in-progress runs for the same branch
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
NODE_VERSION: '20'
jobs:
# ── Job 1: Lint and type check ──────────────────────────────────────
lint:
name: Lint & Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: ESLint
run: npm run lint
- name: TypeScript type check
run: npm run type-check
- name: Prettier format check
run: npm run format:check
# ── Job 2: Unit tests ───────────────────────────────────────────────
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests with coverage
run: npm run test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/lcov.info
fail_ci_if_error: true
# ── Job 3: Integration tests ────────────────────────────────────────
integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-retries 5
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run database migrations
env:
DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
run: npm run db:migrate
- name: Run integration tests
env:
DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
REDIS_URL: redis://localhost:6379
JWT_SECRET: test-secret-for-ci
run: npm run test:integration
# ── Job 4: Security scan ────────────────────────────────────────────
security:
name: Security Scan
runs-on: ubuntu-latest
permissions:
security-events: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Audit npm dependencies
run: npm audit --audit-level=high
- name: Run CodeQL analysis
uses: github/codeql-action/init@v3
with:
languages: javascript, typescript
- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@v3
# ── Job 5: Build verification ───────────────────────────────────────
build:
name: Build
runs-on: ubuntu-latest
needs: [lint, unit-tests] # Only build if lint and tests pass
outputs:
artifact-name: ${{ steps.artifact.outputs.name }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build production bundle
run: npm run build
env:
NODE_ENV: production
- name: Set artifact name
id: artifact
run: echo "name=build-${{ github.sha }}" >> "$GITHUB_OUTPUT"
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: ${{ steps.artifact.outputs.name }}
path: .next/
retention-days: 7Caching: The Biggest Speed-Up
Without caching, npm ci downloads and installs all dependencies on every run. With proper caching, it completes in seconds:
# Optimised dependency caching
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # Built-in npm cache with actions/setup-node
# For more control, use actions/cache directly
- name: Cache node_modules
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
# Python projects
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
# Go modules
- name: Cache Go modules
uses: actions/cache@v4
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('go.sum') }}The cache key includes a hash of the lock file. When package-lock.json changes, the cache is invalidated and dependencies are reinstalled fresh.
Linting and Formatting Setup
// package.json scripts
{
"scripts": {
"lint": "eslint src --ext .ts,.tsx --max-warnings 0",
"lint:fix": "eslint src --ext .ts,.tsx --fix",
"type-check": "tsc --noEmit",
"format": "prettier --write .",
"format:check": "prettier --check ."
}
}// .eslintrc.json
{
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"next/core-web-vitals"
],
"rules": {
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-explicit-any": "error",
"no-console": ["warn", { "allow": ["error", "warn"] }]
}
}// .prettierrc
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100
}The --max-warnings 0 flag fails the lint check if any ESLint warning exists — not just errors. This prevents warning accumulation over time.
Test Coverage Enforcement
// jest.config.ts
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.tsx',
'!src/migrations/**',
],
coverageThresholds: {
global: {
branches: 80,
functions: 85,
lines: 85,
statements: 85,
},
// Stricter thresholds for critical modules
'./src/auth/**': {
branches: 95,
functions: 95,
lines: 95,
},
},
coverageReporters: ['lcov', 'text-summary'],
};
export default config;If coverage drops below the threshold, npm run test:coverage exits with a non-zero code, failing the CI job.
Integration Tests with Real Services
GitHub Actions services spin up real Docker containers alongside your job:
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432The options: --health-cmd pg_isready makes the job wait until PostgreSQL is ready to accept connections before running your steps.
// src/__tests__/integration/users.test.ts
import { Pool } from 'pg';
import { createUser, getUserById } from '../../repositories/users';
describe('User repository integration', () => {
let pool: Pool;
beforeAll(async () => {
pool = new Pool({ connectionString: process.env.DATABASE_URL });
await pool.query('BEGIN');
});
afterAll(async () => {
await pool.query('ROLLBACK'); // Clean up — leave database unchanged
await pool.end();
});
it('creates and retrieves a user', async () => {
const user = await createUser(pool, { email: 'test@example.com', name: 'Test User' });
expect(user.id).toBeDefined();
const found = await getUserById(pool, user.id);
expect(found?.email).toBe('test@example.com');
});
});Wrapping each test suite in a transaction and rolling back ensures tests never leave persistent state in the test database.
Branch Protection: The Final Wall
Branch protection rules enforce that CI must pass before merging:
Settings → Branches → Branch protection rules → Add rule
Branch name pattern: main
☑ Require a pull request before merging
☑ Require approvals: 1
☑ Dismiss stale pull request approvals when new commits are pushed
☑ Require status checks to pass before merging
☑ Require branches to be up to date before merging
Required status checks:
✓ Lint & Type Check
✓ Unit Tests
✓ Integration Tests
✓ Security Scan
✓ Build
☑ Require conversation resolution before merging
☑ Do not allow bypassing the above settingsWith Do not allow bypassing enabled, even repository admins cannot push directly to main without passing CI. This is the most important setting — it prevents the "I'll just push directly in an emergency" pattern that erodes CI discipline.
Artifact Management: Build Once, Deploy Everywhere
# Build job uploads the artifact
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: build-${{ github.sha }}
path: |
.next/
public/
retention-days: 30
# Deploy job downloads the same artifact (no rebuild)
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: build-${{ needs.build.outputs.artifact-name }}
path: .next/
- name: Deploy to staging
run: ./scripts/deploy.sh stagingBuilding once and deploying the same artifact to staging and production guarantees that what you tested is what you deployed. Rebuilding in the deploy job risks environmental differences changing the output.
Matrix Testing: Cross-Platform Verification
jobs:
test:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: ['18', '20', '22']
fail-fast: false # Let all matrix jobs run; see all failures at once
runs-on: ${{ matrix.os }}
name: Test (Node ${{ matrix.node }} on ${{ matrix.os }})
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: 'npm'
- run: npm ci
- run: npm testFor libraries published to npm, cross-platform testing catches Windows path separator bugs and Node.js version compatibility issues before users report them.
Detecting Secret Leakage
# Add to the security job
- name: Scan for leaked secrets
uses: trufflesecurity/trufflehog@main
with:
path: ./
base: ${{ github.event.repository.default_branch }}
head: HEAD
extra_args: --debug --only-verifiedTruffleHog scans your commit diff for patterns matching API keys, tokens, and credentials. Running it on the diff (base to HEAD) rather than the full repo keeps it fast.
Additionally, add a .gitignore pre-commit hook:
# .husky/pre-commit
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
# Block commits that accidentally include .env files
if git diff --cached --name-only | grep -q "\.env$"; then
echo "Error: .env file detected in staged changes. Remove it before committing."
exit 1
fiMonorepo CI: Only Test What Changed
For monorepos with multiple packages, running the full test suite on every commit wastes time:
# .github/workflows/ci.yml (monorepo with Nx)
jobs:
affected:
runs-on: ubuntu-latest
outputs:
apps: ${{ steps.affected.outputs.apps }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history needed for Nx affected detection
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Get affected projects
id: affected
run: |
AFFECTED=$(npx nx show projects --affected --base=origin/main --head=HEAD)
echo "apps=$AFFECTED" >> "$GITHUB_OUTPUT"
test:
needs: affected
if: needs.affected.outputs.apps != ''
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- name: Test affected
run: npx nx affected --target=test --base=origin/main --head=HEADNx computes a dependency graph and only runs tests for packages whose code (or dependencies) changed.
Frequently Asked Questions
Q: Should CI run on every push or only on pull requests?
Run the full test suite on pull requests. For pushes to main (after merge), run the full suite plus additional steps like deployment. For pushes to feature branches, you can optionally run a lighter suite (lint + unit tests only) to give faster feedback during development — saving the full integration test suite for when a PR is opened. The important invariant: the full suite must pass before any commit lands on main.
Q: How do I handle flaky tests that randomly fail CI?
Flaky tests undermine trust in CI. Fix them rather than retrying. Use --retry sparingly and only as a temporary measure. Identify flakiness patterns: network-dependent tests without proper mocking, tests with shared mutable state, timing-sensitive tests using setTimeout. For integration tests, ensure each test cleans up after itself (wrap in transactions, seed fresh data per test). Track flakiness rates and treat any test with >1% flake rate as a bug.
Q: What is the difference between npm test and npm run test:coverage?
npm test runs tests and reports pass/fail. npm run test:coverage additionally instruments the code, collects which lines were executed, and generates a coverage report. Coverage collection adds ~20-30% to test runtime. Run npm run test:coverage in CI (to enforce thresholds and upload reports) and npm test locally for fast iteration. Some teams use --coverage --coverageThreshold only on the main branch CI and skip thresholds on feature branches.
Q: How do I prevent CI from becoming a bottleneck as the codebase grows?
Keep jobs under 3 minutes each by parallelising work. Use caching aggressively — cached npm ci takes 10 seconds vs 90 seconds uncached. For slow integration tests, consider splitting by domain (auth tests, payment tests, search tests) and running them in parallel matrix jobs. Profile which steps take the most time with the GitHub Actions timing view and optimise the top offenders first. The 10-minute total target should be a hard limit: if CI exceeds it, it is an engineering priority to bring it back.
Key Takeaway
A bulletproof CI pipeline catches bugs at the source: every pull request runs lint, type checks, unit tests, integration tests with real service dependencies, security scans, and build verification before merge is allowed. Cache dependencies to stay within the 10-minute target. Enforce branch protection so CI cannot be bypassed — even by admins. Upload build artifacts once and reuse them across deploy jobs to guarantee build-deploy consistency. Treat a failing CI run as a higher priority than new feature work — a broken pipeline stops the whole team.
Read next: GitHub Actions: Mastering YAML Syntax and Triggers →
Part of the GitHub Mastery Course — engineering the quality gate.
