iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

Stages & Jobs

A CI pipeline stage is a logical group of jobs that runs as a unit. Jobs within a stage run in parallel; stages run in sequence. The pattern: check → test → build → deploy.

Stage design + dependencies + fail-fast

EXAMPLE
# === Why stages? ===
# - Group jobs that should pass before moving on
# - Fail-fast: don't build if tests fail
# - Parallelise within each stage; serialise across stages
# - Clear UX in CI dashboards (green/yellow/red per stage)

# === Canonical 4-stage pipeline ===
#
# 1. CHECK     — lint, format, typecheck (fast feedback under 2 min)
# 2. TEST      — unit, integration, e2e
# 3. BUILD     — compile / bundle / build Docker image
# 4. DEPLOY    — staging → smoke → production (manual gate)
#
# Each stage runs ONLY if the previous one passes.

# === GitHub Actions example ===
name: ci
on:
    push: { branches: [main] }
    pull_request: {}

concurrency:
    group: ${{ github.workflow }}-${{ github.ref }}
    cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

jobs:
    # ===== Stage 1: check =====
    lint:
        runs-on: ubuntu-latest
        steps:
            - uses: actions/checkout@v4
            - uses: actions/setup-node@v4
              with: { node-version: 20, cache: npm }
            - run: npm ci
            - run: npm run lint

    typecheck:
        runs-on: ubuntu-latest
        steps:
            - uses: actions/checkout@v4
            - uses: actions/setup-node@v4
              with: { node-version: 20, cache: npm }
            - run: npm ci
            - run: npm run typecheck

    # ===== Stage 2: test =====
    test-unit:
        runs-on: ubuntu-latest
        needs: [lint, typecheck]                # waits for stage 1 to pass
        steps:
            - uses: actions/checkout@v4
            - uses: actions/setup-node@v4
              with: { node-version: 20, cache: npm }
            - run: npm ci
            - run: npm test -- --coverage
            - uses: codecov/codecov-action@v4

    test-integration:
        runs-on: ubuntu-latest
        needs: [lint, typecheck]                # parallel with unit
        services:
            postgres:
                image: postgres:16
                env: { POSTGRES_PASSWORD: test }
                ports: ['5432:5432']
        steps:
            - uses: actions/checkout@v4
            - uses: actions/setup-node@v4
              with: { node-version: 20, cache: npm }
            - run: npm ci
            - run: npm run test:integration

    test-e2e:
        runs-on: ubuntu-latest
        needs: [lint, typecheck]
        steps:
            - uses: actions/checkout@v4
            - uses: actions/setup-node@v4
              with: { node-version: 20, cache: npm }
            - run: npm ci
            - run: npx playwright install --with-deps
            - run: npm run test:e2e
            - uses: actions/upload-artifact@v4
              if: failure()
              with: { name: playwright-traces, path: test-results/ }

    # ===== Stage 3: build =====
    build:
        runs-on: ubuntu-latest
        needs: [test-unit, test-integration, test-e2e]
        if: github.ref == 'refs/heads/main'
        outputs:
            image-tag: ${{ steps.meta.outputs.tags }}
        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 }} }
            - id: meta
              uses: docker/metadata-action@v5
              with: { images: ghcr.io/${{ github.repository }} }
            - uses: docker/build-push-action@v6
              with:
                  push: true
                  tags: ${{ steps.meta.outputs.tags }}
                  cache-from: type=gha
                  cache-to:   type=gha,mode=max

    # ===== Stage 4a: deploy to staging =====
    deploy-staging:
        runs-on: ubuntu-latest
        needs: build
        environment:
            name: staging
            url:  https://staging.example.com
        steps:
            - run: ./deploy.sh staging ${{ needs.build.outputs.image-tag }}

    # ===== Stage 4b: smoke tests =====
    smoke-staging:
        runs-on: ubuntu-latest
        needs: deploy-staging
        steps:
            - uses: actions/checkout@v4
            - run: npm ci
            - run: npm run smoke -- https://staging.example.com

    # ===== Stage 4c: deploy to production (manual approval gate) =====
    deploy-prod:
        runs-on: ubuntu-latest
        needs: smoke-staging
        environment:
            name: production              # GitHub UI: 'environment requires approval'
            url:  https://example.com
        steps:
            - run: ./deploy.sh prod ${{ needs.build.outputs.image-tag }}

# === GitLab CI example ===
stages:
    - check
    - test
    - build
    - deploy

variables:
    NODE_VERSION: '20'

default:
    image: node:${NODE_VERSION}
    cache:
        key: { files: [package-lock.json] }
        paths: [.npm/, node_modules/]

# Stage 1
lint:
    stage: check
    script: [npm ci, npm run lint]

typecheck:
    stage: check
    script: [npm ci, npm run typecheck]

# Stage 2 — depends on stage 1 implicitly
unit:
    stage: test
    script: [npm ci, npm test -- --coverage]

integration:
    stage: test
    services: [postgres:16]
    script: [npm ci, npm run test:integration]

# Stage 3
build:
    stage: build
    rules:
        - if: '$CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH'
    image:
        name: gcr.io/kaniko-project/executor:v1.23.2-debug
        entrypoint: ['']
    script:
        - /kaniko/executor --context . --destination ghcr.io/$REPO:$CI_COMMIT_SHA

# Stage 4 (with manual gate)
deploy-prod:
    stage: deploy
    when: manual
    environment: { name: production, url: https://example.com }
    rules:
        - if: $CI_COMMIT_TAG
    script: [./deploy.sh prod $CI_COMMIT_SHA]

# === Stage design principles ===

# 1. Fail fast
#    - Order stages from CHEAP / FAST to EXPENSIVE / SLOW
#    - Lint < typecheck < unit tests < integration < e2e < build < deploy
#    - If lint fails, don't run e2e

# 2. Parallel within stage
#    - Multiple lint jobs (ESLint + Prettier + custom)
#    - Multiple test shards (test-shard-1, test-shard-2, ...)
#    - Multiple targets (Linux, macOS, Windows)

# 3. Serial across stages
#    - 'needs:' (GHA) or implicit (GitLab) creates the dependency graph
#    - Use 'needs:' to skip stages when downstream is what failed

# 4. Conditional stages
#    - Build + deploy only on main
#    - E2E only on PRs touching frontend code
#    - Use 'rules:' / 'if:' to gate

# 5. Manual gates
#    - Production deploy as manual approval
#    - Rollback button in the same pipeline
#    - Optional: paused-job pattern for chaos engineering experiments

# 6. Reusable stage definitions (GitLab includes / GHA composite actions)
include:
    - project: shared/ci-templates
      file: /node-app.yml

# === Anti-patterns ===
#   ❌ One mega-job that does everything → no parallelism, slow feedback
#   ❌ All stages required for every change → frontend-only PR runs backend tests
#   ❌ Deploy from PRs without approval → accidents
#   ❌ Hard-coded SHA / version in deploy → can't rerun the same pipeline
#   ❌ Flaky tests in test stage → blocks merges; quarantine + fix
#   ❌ No timeout on jobs → runaway loops eat CI minutes

# === Good defaults ===
#   ✅ Cancel-in-progress on PR pushes
#   ✅ Per-job timeout (5-30 min depending on type)
#   ✅ Retry on transient failures (network, flaky deps) — max 2-3 retries
#   ✅ Cache deps aggressively per stage
#   ✅ Surface artifacts (test reports, screenshots, coverage)
#   ✅ Use job dependencies (needs:) to express the DAG explicitly
#   ✅ Tag jobs by purpose (lint, test, e2e, deploy) for filtering

# === Stage timing benchmarks (typical web app) ===
#   check     : 1-2 min   (lint + typecheck)
#   test      : 3-8 min   (unit + integration + e2e parallel)
#   build     : 2-5 min   (Docker build with cache)
#   deploy    : 1-3 min   (script-driven push + smoke)
#
# Total: ~10-15 min on a healthy pipeline. If you're at 30+, look at:
#   - Caching (deps, Docker layers, build output)
#   - Parallelising tests via sharding
#   - Cutting redundant work (the same lint twice, etc.)

Why it matters

Stages express the dependency graph: cheap before expensive, parallel within, sequential across, manual gate before prod. Use needs: or stages: to make the order explicit; cancel-in-progress on PR pushes to save minutes.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
stages:
    - test
    - build
    - deploy

test_job:
    stage: test
    script: npm test

build_job:
    stage: build
    script: docker build -t app .
    needs: [test_job]
Try it Yourself »

Discussion

Loading…