Tech Guides
Continuous Integration & Delivery

CI/CD PIPELINES

Automate everything. Build fast. Deploy faster. Never stop moving.

3 Major Platforms 12 Sections 100+ Commands & Patterns
01

Quick Reference

The most common pipeline operations across all three major platforms — at a glance.

Pipeline Config Files

Platform Config File Trigger Runs On
GitHub .github/workflows/*.yml Events (push, PR, schedule, manual) GitHub-hosted or self-hosted runners
GitLab .gitlab-ci.yml Push, merge request, API, schedule GitLab shared or self-managed runners
Jenkins Jenkinsfile SCM poll, webhook, cron, manual Controller + agent nodes

Minimal Pipeline — Side by Side

GitHub Actions

name: CI
on: [push]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm install
      - run: npm test

GitLab CI

stages:
  - build
  - test

build:
  stage: build
  script:
    - npm install

test:
  stage: test
  script:
    - npm test

Jenkinsfile

pipeline {
  agent any
  stages {
    stage('Build') {
      steps {
        sh 'npm install'
      }
    }
    stage('Test') {
      steps {
        sh 'npm test'
      }
    }
  }
}
02

Core Concepts

The fundamental building blocks of every CI/CD system — pipelines, stages, jobs, and the runners that execute them.

Pipeline Anatomy

Concept Description GitHub GitLab Jenkins
Pipeline The full automation run triggered by an event Workflow Pipeline Pipeline
Stage A logical group of jobs that run together Implicit (via needs) Stage Stage
Job A unit of work on a single runner/agent Job Job Stage (single)
Step A single command or action within a job Step Script line Step
Runner / Agent Machine that executes jobs Runner Runner Agent / Node
Artifact Build output passed between jobs actions/upload-artifact artifacts: archiveArtifacts
Cache Reused dependencies across runs actions/cache cache: Plugin-based

CI vs CD vs CD

Continuous Integration

Automatically build and test every commit. Catch bugs early. Keep main green. Run on every push and pull request.

Continuous Delivery

Every commit that passes CI is deployable. Artifacts are built, tested, and staged. A human clicks "deploy" to production.

Continuous Deployment

Every commit that passes CI goes to production automatically. No human gate. Requires rock-solid test coverage and monitoring.

Pipeline Triggers

Trigger Type When Use Case
Push Code pushed to a branch CI builds on every commit
Pull/Merge Request PR opened, updated, or synchronized Validate before merge
Schedule (cron) Timed interval Nightly builds, dependency checks
Manual Human clicks a button Production deploys, releases
Tag Git tag created Release builds, publishing
API / Webhook External HTTP call Cross-repo triggers, external events
03

GitHub Actions

Event-driven CI/CD natively integrated into GitHub. YAML workflows, a massive marketplace of reusable actions, and matrix builds out of the box.

Workflow Structure

name: Build & Deploy
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
  schedule:
    - cron: '0 6 * * 1'          # Every Monday 6am
  workflow_dispatch:               # Manual trigger
    inputs:
      environment:
        description: 'Deploy target'
        required: true
        default: 'staging'
        type: choice
        options: [staging, production]

permissions:
  contents: read
  packages: write

env:
  NODE_VERSION: '20'
  REGISTRY: ghcr.io

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
      - run: npm ci
      - run: npm run lint

  test:
    needs: lint
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      - run: npm ci
      - run: npm test -- --coverage
      - uses: actions/upload-artifact@v4
        with:
          name: coverage-${{ matrix.node-version }}
          path: coverage/

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - run: echo "Deploying to production"

Key Expressions & Contexts

Conditional Execution

if: github.event_name == 'push'
if: contains(github.event.head_commit.message, '[skip ci]') == false
if: always() / failure() / success()

Environment Variables

${{ secrets.API_KEY }}
${{ github.sha }}
${{ runner.os }}
${{ matrix.node-version }}

Job Outputs

echo "ver=1.0" >> $GITHUB_OUTPUT
Reference: ${{ needs.build.outputs.ver }}

Path Filters

on: push:
  paths: ['src/**', '*.json']
  paths-ignore: ['docs/**']

Reusable Workflows

# .github/workflows/release.yml
jobs:
  call-build:
    uses: ./.github/workflows/build.yml
    with:
      environment: production
    secrets: inherit

  call-deploy:
    needs: call-build
    uses: org/shared-workflows/.github/workflows/deploy.yml@main
    with:
      target: aws
    secrets:
      AWS_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY }}
# .github/workflows/build.yml
on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
    secrets:
      AWS_ACCESS_KEY:
        required: false

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "Building for ${{ inputs.environment }}"

Essential Marketplace Actions

Action Purpose
actions/checkout@v4Check out repository code
actions/setup-node@v4Install Node.js with caching
actions/cache@v4Cache dependencies between runs
actions/upload-artifact@v4Save build artifacts
actions/download-artifact@v4Retrieve artifacts from prior jobs
docker/build-push-action@v5Build and push Docker images
docker/login-action@v3Authenticate to container registries
github/codeql-action@v3Security / code scanning
softprops/action-gh-release@v2Create GitHub releases
04

GitLab CI/CD

Built-in CI/CD with powerful DAG pipelines, Auto DevOps, container registry, and environments — all in one platform.

Full Pipeline Example

variables:
  NODE_VERSION: "20"
  DOCKER_HOST: tcp://docker:2376

stages:
  - install
  - test
  - build
  - deploy

default:
  image: node:${NODE_VERSION}-alpine
  cache:
    key: ${CI_COMMIT_REF_SLUG}
    paths:
      - node_modules/
      - .npm/

install:
  stage: install
  script:
    - npm ci --cache .npm --prefer-offline
  artifacts:
    paths:
      - node_modules/
    expire_in: 1 hour

lint:
  stage: test
  needs: [install]
  script:
    - npm run lint

unit-tests:
  stage: test
  needs: [install]
  script:
    - npm test -- --coverage
  coverage: '/Lines\s*:\s*(\d+\.?\d*)%/'
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml
      junit: junit.xml

build:
  stage: build
  needs: [lint, unit-tests]
  script:
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 1 week

deploy-staging:
  stage: deploy
  needs: [build]
  environment:
    name: staging
    url: https://staging.example.com
  script:
    - echo "Deploy to staging"
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

deploy-production:
  stage: deploy
  needs: [build]
  environment:
    name: production
    url: https://example.com
  script:
    - echo "Deploy to production"
  rules:
    - if: $CI_COMMIT_TAG
  when: manual

Rules & Conditions

# Run only on main branch
rules:
  - if: $CI_COMMIT_BRANCH == "main"

# Run on merge requests only
rules:
  - if: $CI_PIPELINE_SOURCE == "merge_request_event"

# Run when files change
rules:
  - changes:
      - src/**/*
      - package.json

# Never run on scheduled pipelines
rules:
  - if: $CI_PIPELINE_SOURCE == "schedule"
    when: never
  - when: on_success

# Manual with optional auto
rules:
  - if: $CI_COMMIT_BRANCH == "main"
    when: manual
    allow_failure: true

Include & Extend

# Include templates from other files/repos
include:
  - local: '/.gitlab/ci/test.yml'
  - project: 'devops/templates'
    ref: main
    file: '/docker-build.yml'
  - template: Security/SAST.gitlab-ci.yml

# Reusable job templates
.node-base:
  image: node:20-alpine
  before_script:
    - npm ci

test:
  extends: .node-base
  script:
    - npm test

GitLab-Specific Features

Environments

Track deployments per environment with rollback. Protected environments require approvals.

DAG Pipelines

Use needs: to define job dependencies independent of stage ordering for faster execution.

Multi-Project Pipelines

Trigger pipelines in other repos via trigger: project: for cross-repo orchestration.

Review Apps

Spin up per-branch preview environments automatically. Tear down on merge.

05

Jenkins

The veteran CI server. Self-hosted, infinitely extensible with plugins, supports both declarative and scripted Groovy pipelines.

Declarative Pipeline

pipeline {
    agent {
        docker {
            image 'node:20-alpine'
            args '-v $HOME/.npm:/root/.npm'
        }
    }

    environment {
        CI = 'true'
        REGISTRY = credentials('docker-registry')
    }

    options {
        timeout(time: 30, unit: 'MINUTES')
        disableConcurrentBuilds()
        buildDiscarder(logRotator(numToKeepStr: '10'))
    }

    stages {
        stage('Install') {
            steps {
                sh 'npm ci'
            }
        }

        stage('Parallel Checks') {
            parallel {
                stage('Lint') {
                    steps { sh 'npm run lint' }
                }
                stage('Unit Tests') {
                    steps {
                        sh 'npm test -- --ci --reporters=jest-junit'
                    }
                    post {
                        always {
                            junit 'junit.xml'
                        }
                    }
                }
                stage('Security Scan') {
                    steps { sh 'npm audit --audit-level=high' }
                }
            }
        }

        stage('Build') {
            steps {
                sh 'npm run build'
                archiveArtifacts artifacts: 'dist/**', fingerprint: true
            }
        }

        stage('Deploy') {
            when {
                branch 'main'
            }
            input {
                message 'Deploy to production?'
                ok 'Yes, deploy'
            }
            steps {
                sh './scripts/deploy.sh'
            }
        }
    }

    post {
        success { echo 'Pipeline succeeded!' }
        failure {
            mail to: 'team@example.com',
                 subject: "FAILED: ${env.JOB_NAME} #${env.BUILD_NUMBER}",
                 body: "Check: ${env.BUILD_URL}"
        }
        always { cleanWs() }
    }
}

Scripted vs Declarative

Feature Declarative Scripted
Syntax Structured pipeline { } block Freeform Groovy with node { }
Flexibility Opinionated, less flexible Full Groovy power, any logic
Error handling post { failure { } } try/catch/finally
When to use Most pipelines (recommended) Complex conditional logic

Shared Libraries

// vars/buildPipeline.groovy (in shared library repo)
def call(Map config = [:]) {
    pipeline {
        agent any
        stages {
            stage('Build') {
                steps {
                    sh "npm ci"
                    sh "npm run build"
                }
            }
            stage('Test') {
                steps {
                    sh "npm test"
                }
            }
            stage('Deploy') {
                when { branch config.get('deployBranch', 'main') }
                steps {
                    sh "./deploy.sh ${config.environment}"
                }
            }
        }
    }
}

// Jenkinsfile (consuming project)
@Library('my-shared-lib') _
buildPipeline(environment: 'production', deployBranch: 'main')
06

Pipeline Patterns

Reusable strategies for caching, parallel execution, conditional logic, and monorepo workflows.

Dependency Caching

GitHub Actions

- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ hashFiles('**/package-lock.json') }}
    restore-keys: npm-

# Or built-in with setup-node:
- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: 'npm'

GitLab CI

cache:
  key:
    files:
      - package-lock.json
  paths:
    - node_modules/
    - .npm/
  policy: pull-push    # pull, push, or pull-push

Matrix Builds

jobs:
  test:
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        node: [18, 20, 22]
        exclude:
          - os: macos-latest
            node: 18
        include:
          - os: ubuntu-latest
            node: 22
            experimental: true
    runs-on: ${{ matrix.os }}
    continue-on-error: ${{ matrix.experimental || false }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
      - run: npm ci && npm test

Fan-Out / Fan-In

# Fan-out: parallel jobs, then fan-in to deploy
jobs:
  lint:
    runs-on: ubuntu-latest
    steps: [...]

  test-unit:
    runs-on: ubuntu-latest
    steps: [...]

  test-integration:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: test
    steps: [...]

  test-e2e:
    runs-on: ubuntu-latest
    steps: [...]

  # Fan-in: wait for all test jobs
  deploy:
    needs: [lint, test-unit, test-integration, test-e2e]
    runs-on: ubuntu-latest
    steps: [...]

Monorepo — Path-Based Triggers

# Only run when specific package changes
on:
  push:
    paths:
      - 'packages/api/**'
      - 'packages/shared/**'

# Or use dorny/paths-filter for conditional jobs
jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      api: ${{ steps.filter.outputs.api }}
      web: ${{ steps.filter.outputs.web }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            api:
              - 'packages/api/**'
            web:
              - 'packages/web/**'

  build-api:
    needs: changes
    if: needs.changes.outputs.api == 'true'
    runs-on: ubuntu-latest
    steps: [...]

  build-web:
    needs: changes
    if: needs.changes.outputs.web == 'true'
    runs-on: ubuntu-latest
    steps: [...]

Concurrency Control

# Cancel in-progress runs for same branch
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

# For deploy jobs, wait instead of cancelling
concurrency:
  group: deploy-production
  cancel-in-progress: false
07

Docker in CI/CD

Build images, push to registries, cache layers, and run integration tests with Docker Compose — all inside your pipeline.

Build & Push Image

jobs:
  docker:
    runs-on: ubuntu-latest
    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 }}

      - uses: docker/metadata-action@v5
        id: meta
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=sha
            type=ref,event=branch
            type=semver,pattern={{version}}

      - uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Multi-Stage Dockerfile for CI

# Stage 1: Dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --production=false

# Stage 2: Build
FROM deps AS build
COPY . .
RUN npm run build
RUN npm prune --production

# Stage 3: Test (used in CI only)
FROM build AS test
RUN npm test

# Stage 4: Production image
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json .
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
Layer Caching

Use cache-from: type=gha in GitHub Actions or --cache-from in GitLab to reuse Docker layers across builds. Order Dockerfile commands from least to most frequently changed.

Docker Compose for Integration Tests

jobs:
  integration:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Start services
        run: docker compose -f docker-compose.test.yml up -d
      - name: Wait for healthy
        run: |
          timeout 60 bash -c 'until docker compose -f docker-compose.test.yml ps | grep -q healthy; do sleep 2; done'
      - name: Run tests
        run: docker compose -f docker-compose.test.yml exec -T app npm run test:integration
      - name: Teardown
        if: always()
        run: docker compose -f docker-compose.test.yml down -v
08

Testing Strategies

Test pyramids, parallel execution, coverage gates, and test splitting for fast feedback loops.

The Test Pyramid in CI

Layer Speed Count When to Run
Unit Tests Fast (seconds) Many (hundreds+) Every push, every PR
Integration Tests Medium (minutes) Moderate (dozens) Every PR, post-merge
E2E Tests Slow (5-30 min) Few (critical paths) Pre-deploy, nightly
Smoke Tests Fast (seconds) Handful Post-deploy (verify live)

Parallel Test Splitting

# Split tests across N parallel runners
jobs:
  test:
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - name: Run test shard
        run: |
          # Split test files evenly across shards
          FILES=$(find src -name '*.test.ts' | sort | awk "NR % 4 == ${{ matrix.shard }} - 1")
          npx jest $FILES --ci
test:
  stage: test
  parallel: 4
  script:
    - FILES=$(find src -name '*.test.ts' | sort | awk "NR % $CI_NODE_TOTAL == $CI_NODE_INDEX")
    - npx jest $FILES --ci

Coverage Gates

- name: Check coverage threshold
  run: |
    COVERAGE=$(npx jest --coverage --coverageReporters=json-summary \
      | jq '.total.lines.pct')
    echo "Coverage: ${COVERAGE}%"
    if (( $(echo "$COVERAGE < 80" | bc -l) )); then
      echo "::error::Coverage ${COVERAGE}% is below 80% threshold"
      exit 1
    fi

Service Containers for Tests

jobs:
  integration:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: testdb
        ports: ['5432:5432']
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
      redis:
        image: redis:7-alpine
        ports: ['6379:6379']
    env:
      DATABASE_URL: postgresql://test:test@localhost:5432/testdb
      REDIS_URL: redis://localhost:6379
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run test:integration
09

Deployment Strategies

Blue-green, canary, rolling updates, feature flags, and GitOps — choose the right strategy for your risk tolerance.

Strategy Comparison

Strategy Downtime Rollback Speed Risk Best For
Rolling Update Zero Minutes Medium Stateless services, K8s default
Blue-Green Zero Instant (swap) Low Critical services, easy rollback
Canary Zero Fast Lowest High-traffic, gradual validation
Recreate Yes Slow (redeploy) High Dev/staging, DB migrations

Blue-Green Deploy

# Blue-green with AWS ALB target groups
CURRENT=$(aws elbv2 describe-rules ... | jq -r '.TargetGroupArn')
if [[ "$CURRENT" == *"blue"* ]]; then
  DEPLOY_TARGET="green"
else
  DEPLOY_TARGET="blue"
fi

# Deploy to inactive environment
aws ecs update-service --cluster prod \
  --service "api-${DEPLOY_TARGET}" \
  --task-definition "api:${NEW_VERSION}"

# Wait for healthy
aws ecs wait services-stable --cluster prod \
  --services "api-${DEPLOY_TARGET}"

# Run smoke tests against new env
curl -f "https://${DEPLOY_TARGET}.internal/health"

# Switch traffic
aws elbv2 modify-rule --rule-arn $RULE_ARN \
  --actions Type=forward,TargetGroupArn=$NEW_TG_ARN

GitOps with ArgoCD

# CI pipeline updates manifests, ArgoCD syncs
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          repository: org/k8s-manifests
          token: ${{ secrets.DEPLOY_TOKEN }}

      - name: Update image tag
        run: |
          cd apps/api/overlays/production
          kustomize edit set image \
            api=ghcr.io/org/api:${{ github.sha }}

      - name: Commit & push
        run: |
          git config user.name "ci-bot"
          git config user.email "ci@example.com"
          git add .
          git commit -m "deploy: api ${{ github.sha }}"
          git push
GitOps Principle

Git is the single source of truth for desired state. The CI pipeline pushes image tags to a manifests repo. ArgoCD or Flux watches that repo and reconciles the cluster to match.

Canary with Kubernetes

# Canary: route 10% of traffic to new version
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: api
spec:
  hosts: [api.example.com]
  http:
    - route:
        - destination:
            host: api
            subset: stable
          weight: 90
        - destination:
            host: api
            subset: canary
          weight: 10
10

Secrets & Security

Manage credentials safely, scan for vulnerabilities, sign artifacts, and harden your supply chain.

Secret Management

GitHub Secrets

Repo/org/environment secrets. Access via ${{ secrets.NAME }}. Masked in logs. Environment secrets require approval.

GitLab Variables

Project/group CI variables. Mark as Masked and Protected. Limit to specific environments or branches.

External Vaults

HashiCorp Vault, AWS Secrets Manager, Azure Key Vault. Fetch at runtime with OIDC — no stored credentials.

OIDC Authentication

Passwordless auth to cloud providers. GitHub/GitLab issue short-lived JWT tokens. No long-lived keys to rotate.

OIDC with AWS

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789:role/github-deploy
          aws-region: us-east-1
          # No access keys needed! Uses OIDC token

      - run: aws s3 sync dist/ s3://my-bucket/

Supply Chain Security

Practice Tool What It Does
Dependency Scanning npm audit, Dependabot, Renovate Find known CVEs in dependencies
SAST CodeQL, Semgrep, SonarQube Static analysis for bugs and vulnerabilities
Container Scanning Trivy, Grype, Snyk Scan Docker images for CVEs
Secret Detection Gitleaks, TruffleHog Find accidentally committed secrets
SBOM Generation Syft, CycloneDX Software Bill of Materials for audit
Artifact Signing Cosign (Sigstore) Cryptographically sign images and artifacts

Security Scanning Pipeline

jobs:
  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Dependency audit
        run: npm audit --audit-level=high

      - name: Secret detection
        uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Container scan
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: 'ghcr.io/org/app:${{ github.sha }}'
          format: 'sarif'
          output: 'trivy-results.sarif'
          severity: 'CRITICAL,HIGH'

      - name: Upload SARIF
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: 'trivy-results.sarif'
Never Do This

Never echo secrets, pass them as CLI arguments (visible in ps), or store them in build artifacts. Use --mount=type=secret in Docker builds instead of ARG for secrets.

11

Performance & Optimization

Cut build times, reduce costs, and keep pipelines running at maximum velocity.

Speed Optimization Checklist

Cache Everything

Dependencies (node_modules), Docker layers, build outputs. Use lock file hashes as cache keys.

Parallelize

Run lint, test, and build concurrently. Split test suites across runners. Use matrix builds.

Skip Unnecessary Work

Path filters, change detection, [skip ci] commits. Don't rebuild what hasn't changed.

Use Smaller Images

Alpine-based images. Pre-built CI images with tools installed. Avoid installing system packages at runtime.

Self-Hosted Runners

Persistent caches, faster hardware, no queue wait. Use for high-volume repos. Warm caches save minutes.

Incremental Builds

Turborepo, Nx, or Bazel for monorepos. Only rebuild affected packages. Remote caching across team.

Measuring Pipeline Performance

# Add timing to each step
- name: Install dependencies
  run: |
    START=$(date +%s)
    npm ci
    END=$(date +%s)
    echo "::notice::Install took $((END-START))s"

# GitHub provides built-in workflow run analytics
# Settings > Actions > General > view usage

# GitLab: CI/CD > Pipelines > Analytics
# Shows pipeline duration trends over time

Cost Optimization

Strategy Savings Trade-off
Use ubuntu-latest over macOS/Windows macOS is 10x, Windows is 2x cost Cross-platform coverage
Cancel superseded runs Stop paying for stale builds None
Path-filtered triggers Only run affected pipelines May miss cross-cutting bugs
Self-hosted for high volume Fixed cost vs. per-minute billing Maintenance overhead
Spot/preemptible runners 60-90% cheaper compute Jobs may be interrupted
12

Troubleshooting

Debug failing pipelines, fix common errors, and recover from broken CI.

Common Failures & Fixes

Symptom Cause Fix
npm ci fails with lock file mismatch package-lock.json out of sync Run npm install locally, commit lock file
Tests pass locally, fail in CI Environment differences, timing, missing env vars Check Node version, OS, timezone. Add debug logging.
"Resource not accessible by integration" Missing permissions: in workflow Add required permissions block (contents, packages, etc.)
Docker build slow (no layer cache) Cache not configured or invalidated Use cache-from: type=gha or registry cache
Pipeline hangs indefinitely Interactive prompt or waiting for input Add --non-interactive flags, set CI=true
Secret is empty / not available Secret not set for environment/branch Check secret scope. Fork PRs can't access secrets.
Out of disk space Large Docker images or dependencies Clean workspace, use docker system prune, smaller base images

Debugging Techniques

GitHub: Debug Logging

ACTIONS_RUNNER_DEBUG=true
ACTIONS_STEP_DEBUG=true

Set as repo secrets for verbose logs. Or re-run with debug logging enabled.

GitHub: SSH Debug

- uses: mxschmitt/action-tmate@v3
  if: failure()

SSH into a failed runner to inspect state interactively.

GitLab: Local Runner

gitlab-runner exec docker test

Run a job locally with the GitLab Runner CLI to reproduce failures.

Act — Local GitHub Actions

act push
act -j build --secret-file .env

Run GitHub Actions workflows locally with nektos/act.

Retry Strategies

# Retry flaky steps
- name: Run flaky integration tests
  uses: nick-fields/retry@v3
  with:
    max_attempts: 3
    timeout_minutes: 10
    command: npm run test:integration

# Manual retry in GitLab
# Click "Retry" on failed job in pipeline view
# Or use API:
# curl --request POST "https://gitlab.com/api/v4/projects/:id/pipelines/:id/retry"
Flaky Tests Are Tech Debt

Retries are a band-aid. Track flaky test frequency. Fix or quarantine tests that fail more than 2% of the time. A consistently-green pipeline builds team trust in CI.