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…