Pipelines That Tell You What's Wrong

A CI/CD pipeline that takes 40 minutes to run isn't a pipeline β€” it's a deployment tax. Developers stop running it locally, stop reading the output, and merge with crossed fingers. Good pipeline design is about fast feedback: fail early, fail clearly, and only block on things that actually matter.

πŸ€” Sound familiar?
  • Your pipeline runs everything sequentially when most jobs could run in parallel
  • A flaky E2E test blocks deployments randomly, so the team ignores it
  • You have no idea if your pipeline is getting faster or slower over time
  • Your β€œquality gate” is β€œit compiles and tests pass” β€” nothing more

DORA metrics give you a quantified baseline. Pipeline topology and quality gate design give you a path to improve them.

DORA Metrics: The Baseline

DORA (DevOps Research and Assessment) defined four metrics that correlate with engineering performance. They're not vanity metrics β€” they're leading indicators of reliability and throughput.


quadrantChart
    title DORA Performance Tiers
    x-axis Slow Deployment Frequency --> High Deployment Frequency
    y-axis High Change Failure Rate --> Low Change Failure Rate
    quadrant-1 Elite
    quadrant-2 High
    quadrant-3 Low
    quadrant-4 Medium
    Elite: [0.9, 0.9]
    High: [0.65, 0.75]
    Medium: [0.4, 0.45]
    Low: [0.2, 0.25]

Elite performers (from the 2023 State of DevOps Report):

  • Deployment Frequency: Multiple deploys per day
  • Lead Time for Changes: Less than 1 hour from commit to production
  • Change Failure Rate: 0–5% of deploys cause a production failure
  • Time to Restore Service: Less than 1 hour when something breaks

Pipeline Topology

# Principles:
# 1. Fail fast β€” cheapest checks first
# 2. Parallelise where possible β€” lint and test run independently
# 3. Cache aggressively β€” don't reinstall deps on every run
# 4. Artifact handoff β€” build once, deploy that exact artifact

# Optimal job graph for a Node.js service:
#
# [pr-check] ─┬─ [lint + typecheck] (2 min)
#             β”œβ”€ [unit tests + coverage] (3 min)
#             └─ [build] (4 min)
#
# On main merge:
# [integration tests] (5 min)
#     └─ [docker build + push] (3 min)
#          └─ [deploy staging] (2 min)
#               └─ [smoke tests] (1 min)
#                    └─ [deploy production] (2 min, manual approval gate)

jobs:
  # Stage 1: Fast static checks (no runtime needed)
  lint-typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'pnpm' }
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint && pnpm typecheck

  # Stage 1 (parallel): Tests with coverage gate
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'pnpm' }
      - run: pnpm install --frozen-lockfile
      - run: pnpm test --coverage
      - name: Enforce coverage threshold
        run: node -e "
          const c = require('./coverage/coverage-summary.json').total;
          const fail = (n, t) => { if (c[n].pct < t) { console.error(n + ': ' + c[n].pct + '% < ' + t + '%'); process.exit(1); } };
          fail('lines', 80); fail('branches', 75);"

  # Stage 2: Build (only after stage 1 passes)
  build:
    needs: [lint-typecheck, test]
    runs-on: ubuntu-latest
    outputs:
      image: ${{ steps.push.outputs.imageid }}
    steps:
      - uses: actions/checkout@v4
      - uses: docker/build-push-action@v5
        id: push
        with:
          push: true
          tags: ghcr.io/myorg/api:${{ github.sha }}

Quality Gates

A quality gate is a hard stop: the pipeline fails if a metric falls below a threshold. Soft warnings that don't block deploys are ignored within a week.

# Example quality gates in CI:

# 1. Test coverage
- run: pnpm test --coverage --coverageThreshold='{"global":{"lines":80,"branches":75}}'

# 2. Bundle size (prevent accidental bloat)
- name: Check bundle size
  uses: andresz1/size-limit-action@v1
  with:
    github_token: ${{ secrets.GITHUB_TOKEN }}
    # .size-limit.js configures thresholds β€” fails if exceeded

# 3. Dependency audit (no high/critical CVEs)
- run: pnpm audit --audit-level=high

# 4. Performance budget (Lighthouse CI)
- uses: treosh/lighthouse-ci-action@v10
  with:
    budgetPath: ./budget.json
    uploadArtifacts: true

Flaky Test Management

Flaky tests are the silent killers of pipeline trust. Once developers learn a failure might be flaky, they start assuming everything is flaky β€” and stop taking failures seriously.

# Quarantine pattern: move flaky tests to a separate job
# that doesn't block the merge, but still tracks failure rate

jobs:
  test:
    steps:
      - run: pnpm test         # Only stable tests β€” blocks merge

  test-flaky:
    continue-on-error: true   # Doesn't fail the workflow
    steps:
      - run: pnpm test:flaky   # Quarantined flaky tests
      # But: alert/notify if failure rate > threshold
      - if: failure()
        run: |
          curl -X POST ${{ secrets.SLACK_WEBHOOK }}           -d '{"text": "Flaky test failure: ${{ github.run_id }}"}'

Measuring Your Pipeline

# Record job timings and publish to your observability stack
# GitHub Actions exposes duration via the API, or use this pattern:

- name: Record pipeline duration
  if: always()  # Run even if previous steps failed
  run: |
    DURATION=$(($(date +%s) - ${{ steps.start_time.outputs.time }}))
    
    # Push metric to your TSDB (Datadog, InfluxDB, etc.)
    curl -X POST "https://api.datadoghq.com/api/v1/series"       -H "DD-API-KEY: ${{ secrets.DD_API_KEY }}"       -d '{
        "series": [{
          "metric": "ci.pipeline.duration_seconds",
          "points": [['$(date +%s)', '$DURATION']],
          "tags": ["job:${{ github.job }}", "repo:${{ github.repository }}"]
        }]
      }'

Pitfalls

Caching test artifacts between runs

Cache node_modulesby lockfile hash. Don't cache test output or build artifacts between runs of the same job β€” they should be reproducible from source. The exception: test coverage history (for trending) should persist to an external store.

Coverage thresholds without baseline

Enforcing 80% coverage on a codebase that has 40% coverage today will make the pipeline red immediately. Start below your current level and ratchet up over time using a coverage database (e.g., Codecov ratchet mode).

E2E tests in the hot path

E2E tests belong after staging deploy β€” not before. They're slow, environment-dependent, and often flaky. Running them before merge adds 15 minutes to every PR cycle. Test the integration contract in unit/integration tests; validate end-to-end behaviour on the staging environment.