CI/CD Pipeline

You are designing or reviewing the CI/CD pipeline for: $ARGUMENTS

A CI/CD pipeline is a quality gate, not a deployment script. Every stage must have a clear pass/fail condition. A pipeline that always passes is not a pipeline — it is a deployment button.


Principles

  1. Fail fast. Run the fastest, cheapest checks first (lint, type-check) before slow ones (tests, build, deploy).
  2. Every stage is a gate. A failing stage stops the pipeline. No bypass without an explicit documented override.
  3. Pipelines are code. Version-controlled, reviewed like application code, never edited in the UI.
  4. Reproducible builds. The same commit always produces the same artefact.
  5. Secrets are never logged. Use masked secrets; never echo $SECRET or let tools print them.

1. Pipeline Stages (in order)

Stage What runs Fail mode
Lint & format ESLint, Prettier, PHPStan, Black, etc. Any violation → fail
Type check TypeScript, mypy, Psalm Any type error → fail
Unit tests Fast, isolated tests Any failing test → fail
Build Compile, bundle, package Build error → fail
Security scan Dependency audit, SAST, secret detection CRITICAL finding → fail
Integration tests Tests needing DB, external services Any failing test → fail
E2E tests Browser/API smoke tests against built artefact Critical path failure → fail
Deploy (staging) Deploy to staging environment Deploy failure → fail
Smoke test (staging) Post-deploy health check Health check failure → fail
Deploy (production) On main/tag only; manual approval for high-risk Deploy failure → alert + rollback

2. GitHub Actions

Full Pipeline Example

# .github/workflows/ci.yml
name: CI / CD

on:
  push:
    branches: [main, 'release/**']
  pull_request:
    branches: [main]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

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
      - run: npm run typecheck

  test:
    runs-on: ubuntu-latest
    needs: lint
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: testdb
        options: >-
          --health-cmd pg_isready
          --health-interval 5s
          --health-timeout 3s
          --health-retries 5
        ports: ['5432:5432']
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22', cache: 'npm' }
      - run: npm ci
      - run: npm test -- --coverage
        env:
          DATABASE_URL: postgres://test:test@localhost:5432/testdb
      - uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage/

  security:
    runs-on: ubuntu-latest
    needs: lint
    steps:
      - uses: actions/checkout@v4
      - name: Detect secrets
        uses: trufflesecurity/trufflehog@main
        with: { path: ./ }
      - name: Dependency audit
        run: npm audit --audit-level=high
      - name: SAST scan
        uses: github/codeql-action/init@v3
        with: { languages: javascript }
      - uses: github/codeql-action/analyze@v3

  build:
    runs-on: ubuntu-latest
    needs: [test, security]
    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: dist
          path: dist/

  docker:
    runs-on: ubuntu-latest
    needs: build
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          push: ${{ github.ref == 'refs/heads/main' }}
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
      - name: Scan image
        run: |
          docker pull ghcr.io/${{ github.repository }}:${{ github.sha }}
          trivy image --exit-code 1 --severity CRITICAL \
            ghcr.io/${{ github.repository }}:${{ github.sha }}

  deploy-staging:
    runs-on: ubuntu-latest
    needs: docker
    if: github.ref == 'refs/heads/main'
    environment: staging
    steps:
      - name: Deploy to staging
        run: |
          kubectl set image deployment/myapp \
            app=ghcr.io/${{ github.repository }}:${{ github.sha }} \
            -n staging
          kubectl rollout status deployment/myapp -n staging --timeout=5m

  deploy-production:
    runs-on: ubuntu-latest
    needs: deploy-staging
    if: github.ref == 'refs/heads/main'
    environment:
      name: production
      url: https://myapp.example.com
    steps:
      - name: Deploy to production
        run: |
          kubectl set image deployment/myapp \
            app=ghcr.io/${{ github.repository }}:${{ github.sha }} \
            -n production
          kubectl rollout status deployment/myapp -n production --timeout=10m

3. GitLab CI Equivalent

# .gitlab-ci.yml
stages: [lint, test, build, security, deploy]

default:
  image: node:22-alpine
  cache:
    key: $CI_COMMIT_REF_SLUG
    paths: [node_modules/]
  before_script:
    - npm ci

lint:
  stage: lint
  script:
    - npm run lint
    - npm run typecheck

test:
  stage: test
  services:
    - name: postgres:16-alpine
      alias: postgres
  variables:
    POSTGRES_USER: test
    POSTGRES_PASSWORD: test
    DATABASE_URL: postgres://test:test@postgres:5432/testdb
  script:
    - npm test -- --coverage
  coverage: '/Lines\s*:\s*(\d+\.?\d*)%/'
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml

security:
  stage: security
  script:
    - npm audit --audit-level=high
  allow_failure: false

deploy-staging:
  stage: deploy
  environment: staging
  only: [main]
  script:
    - kubectl set image deployment/myapp app=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA -n staging
    - kubectl rollout status deployment/myapp -n staging --timeout=5m

deploy-production:
  stage: deploy
  environment: production
  only: [main]
  when: manual
  script:
    - kubectl set image deployment/myapp app=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA -n production
    - kubectl rollout status deployment/myapp -n production --timeout=10m

4. Pipeline Checklist

  • [ ] Lint and type-check run before tests
  • [ ] Tests run in parallel where possible
  • [ ] Secrets never echoed or logged
  • [ ] Docker image scanned for CVEs before deploy
  • [ ] Secrets detected (TruffleHog or GitLeaks)
  • [ ] Dependency audit runs (npm audit, pip-audit, composer audit)
  • [ ] Staging deploy required before production
  • [ ] Production deploy requires manual approval or is tag-triggered
  • [ ] On failure: alert team, do not silently fail
  • [ ] Pipeline runs complete in < 15 minutes (split or optimise if longer)
  • [ ] Flaky tests are quarantined, not re-tried indefinitely

5. Common Anti-Patterns to Fix

Anti-pattern Fix
continue-on-error: true on security steps Remove it — security failures must block
Hard-coded credentials in YAML Use masked CI secrets
actions/checkout without pinned SHA Pin to @v4 or a specific commit SHA
Deploying on every PR Deploy to staging only on main; use preview envs for PRs
Single long job with 20 steps Split into parallel jobs with needs dependencies
No cache on npm ci / pip install Add cache: step to avoid 3-minute reinstalls