Automate everything. Build fast. Deploy faster. Never stop moving.
The most common pipeline operations across all three major platforms — at a glance.
| 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 |
name: CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm install
- run: npm test
stages:
- build
- test
build:
stage: build
script:
- npm install
test:
stage: test
script:
- npm test
pipeline {
agent any
stages {
stage('Build') {
steps {
sh 'npm install'
}
}
stage('Test') {
steps {
sh 'npm test'
}
}
}
}
The fundamental building blocks of every CI/CD system — pipelines, stages, jobs, and the runners that execute them.
| 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 |
Automatically build and test every commit. Catch bugs early. Keep main green. Run on every push and pull request.
Every commit that passes CI is deployable. Artifacts are built, tested, and staged. A human clicks "deploy" to production.
Every commit that passes CI goes to production automatically. No human gate. Requires rock-solid test coverage and monitoring.
| 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 |
Event-driven CI/CD natively integrated into GitHub. YAML workflows, a massive marketplace of reusable actions, and matrix builds out of the box.
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"
if: github.event_name == 'push'
if: contains(github.event.head_commit.message, '[skip ci]') == false
if: always() / failure() / success()
${{ secrets.API_KEY }}
${{ github.sha }}
${{ runner.os }}
${{ matrix.node-version }}
echo "ver=1.0" >> $GITHUB_OUTPUT
${{ needs.build.outputs.ver }}
on: push:
paths: ['src/**', '*.json']
paths-ignore: ['docs/**']
# .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 }}"
| Action | Purpose |
|---|---|
actions/checkout@v4 | Check out repository code |
actions/setup-node@v4 | Install Node.js with caching |
actions/cache@v4 | Cache dependencies between runs |
actions/upload-artifact@v4 | Save build artifacts |
actions/download-artifact@v4 | Retrieve artifacts from prior jobs |
docker/build-push-action@v5 | Build and push Docker images |
docker/login-action@v3 | Authenticate to container registries |
github/codeql-action@v3 | Security / code scanning |
softprops/action-gh-release@v2 | Create GitHub releases |
Built-in CI/CD with powerful DAG pipelines, Auto DevOps, container registry, and environments — all in one platform.
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
# 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 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
Track deployments per environment with rollback. Protected environments require approvals.
Use needs: to define job dependencies independent of stage ordering for faster execution.
Trigger pipelines in other repos via trigger: project: for cross-repo orchestration.
Spin up per-branch preview environments automatically. Tear down on merge.
The veteran CI server. Self-hosted, infinitely extensible with plugins, supports both declarative and scripted Groovy pipelines.
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() }
}
}
| 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 |
// 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')
Reusable strategies for caching, parallel execution, conditional logic, and monorepo workflows.
- 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'
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
- .npm/
policy: pull-push # pull, push, or pull-push
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: 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: [...]
# 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: [...]
# 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
Build images, push to registries, cache layers, and run integration tests with Docker Compose — all inside your pipeline.
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
# 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"]
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.
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
Test pyramids, parallel execution, coverage gates, and test splitting for fast feedback loops.
| 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) |
# 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
- 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
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
Blue-green, canary, rolling updates, feature flags, and GitOps — choose the right strategy for your risk tolerance.
| 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 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
# 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
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: 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
Manage credentials safely, scan for vulnerabilities, sign artifacts, and harden your supply chain.
Repo/org/environment secrets. Access via ${{ secrets.NAME }}. Masked in logs. Environment secrets require approval.
Project/group CI variables. Mark as Masked and Protected. Limit to specific environments or branches.
HashiCorp Vault, AWS Secrets Manager, Azure Key Vault. Fetch at runtime with OIDC — no stored credentials.
Passwordless auth to cloud providers. GitHub/GitLab issue short-lived JWT tokens. No long-lived keys to rotate.
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/
| 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 |
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 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.
Cut build times, reduce costs, and keep pipelines running at maximum velocity.
Dependencies (node_modules), Docker layers, build outputs. Use lock file hashes as cache keys.
Run lint, test, and build concurrently. Split test suites across runners. Use matrix builds.
Path filters, change detection, [skip ci] commits. Don't rebuild what hasn't changed.
Alpine-based images. Pre-built CI images with tools installed. Avoid installing system packages at runtime.
Persistent caches, faster hardware, no queue wait. Use for high-volume repos. Warm caches save minutes.
Turborepo, Nx, or Bazel for monorepos. Only rebuild affected packages. Remote caching across team.
# 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
| 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 |
Debug failing pipelines, fix common errors, and recover from broken CI.
| 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 |
ACTIONS_RUNNER_DEBUG=true
ACTIONS_STEP_DEBUG=true
Set as repo secrets for verbose logs. Or re-run with debug logging enabled.
- uses: mxschmitt/action-tmate@v3
if: failure()
SSH into a failed runner to inspect state interactively.
gitlab-runner exec docker test
Run a job locally with the GitLab Runner CLI to reproduce failures.
act push
act -j build --secret-file .env
Run GitHub Actions workflows locally with nektos/act.
# 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"
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.