Aller au contenu principal
Intermédiaire

⚙️ CI/CD — Pipelines de A à Z

Formation complète CI/CD : GitHub Actions, GitLab CI, testing, GitOps. Des fondamentaux aux pipelines de production avec exemples concrets.

Introduction — CI/CD en 2026, c’est non négociable

En 2026, si tu déploies encore à la main, tu vis dans le passé. Le CI/CD n’est plus un luxe ou un “nice to have” — c’est le socle de toute équipe qui livre du logiciel sérieusement.

Les faits sont là :

  • Les équipes avec du CI/CD mature déploient 100x plus souvent que celles sans (DORA metrics)
  • Le temps de recovery après incident chute de jours à minutes
  • Les bugs sont détectés en minutes au lieu de semaines
  • Le coût d’un déploiement tend vers zéro

Cette formation te donne tout ce qu’il faut pour construire des pipelines solides, de zéro à la prod. Pas de théorie creuse — du YAML, du code, des patterns qui marchent.

Ce que tu vas apprendre :

  • Les fondamentaux du CI/CD et du trunk-based development
  • GitHub Actions en profondeur avec des workflows complets
  • GitLab CI avec des pipelines multi-stages
  • Les stratégies de test automatisé (unit → E2E)
  • Le GitOps avec ArgoCD et Flux

Module 1 — Fondamentaux CI/CD

CI vs CD vs CD — Mettons les choses au clair

Trois acronymes, deux qui partagent les mêmes lettres. Clarifions :

CI — Continuous Integration Chaque commit déclenche un build + tests. Le code est intégré en continu dans la branche principale. L’objectif : détecter les conflits et bugs le plus tôt possible.

CD — Continuous Delivery Le code est toujours dans un état déployable. Chaque commit qui passe le pipeline peut aller en production. Le déploiement reste un acte manuel (un clic, une approbation).

CD — Continuous Deployment Tout commit qui passe le pipeline va automatiquement en production. Zéro intervention humaine. C’est le graal, mais ça demande une confiance totale dans tes tests.

Commit → Build → Test → [Continuous Integration]
                      → Package → Staging → [Continuous Delivery]
                                          → Production → [Continuous Deployment]

Trunk-Based Development

Oublie les branches feature/JIRA-1234-refacto-du-service-machin qui vivent 3 semaines. Le trunk-based development, c’est :

  • Une branche principale (main ou trunk)
  • Des branches courtes (< 24h idéalement, 48h max)
  • Des feature flags pour le code pas encore prêt
  • Des merges fréquents vers main

Pourquoi ? Parce que plus une branche vit longtemps, plus le merge est douloureux. Et plus le merge est douloureux, moins tu merges. C’est un cercle vicieux.

# Workflow trunk-based typique
git checkout -b feat/add-health-endpoint
# ... travail de quelques heures ...
git add -A && git commit -m "feat: add /healthz endpoint"
git push origin feat/add-health-endpoint
# PR → review → merge → delete branch
# Tout ça dans la journée

Les 7 principes d’un bon pipeline

  1. Rapide — Moins de 10 minutes pour le feedback. Si ton pipeline prend 45 minutes, personne ne va attendre.

  2. Fiable — Un pipeline flaky (qui échoue aléatoirement) est pire qu’un pipeline absent. Les devs vont ignorer les échecs.

  3. Reproductible — Même commit = même résultat. Pas de “ça marchait sur ma machine”.

  4. Incrémental — Ne rebuild que ce qui a changé. Cache les dépendances, les layers Docker, les artefacts.

  5. Parallélisé — Les tests unitaires n’ont pas besoin d’attendre le linting. Exécute en parallèle tout ce qui peut l’être.

  6. Sécurisé — Les secrets sont dans un vault, pas dans le code. Les images sont scannées. Les dépendances sont auditées.

  7. Observable — Tu dois savoir pourquoi ça a cassé en 30 secondes. Logs structurés, artefacts de test, notifications ciblées.

Anatomie d’un pipeline moderne

graph LR
    A[Lint + Format] --> B[Build + Cache]
    B --> C[Test Unit/Int]
    C --> D[Security Scan]
    D --> E[Deploy Stage]
    E --> F[Deploy Prod - approval]

Module 2 — GitHub Actions en profondeur

Structure d’un workflow

Les workflows GitHub Actions vivent dans .github/workflows/. Chaque fichier YAML = un workflow.

# .github/workflows/ci.yml
name: CI Pipeline

# Déclencheurs
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
  # Possibilité de déclencher manuellement
  workflow_dispatch:
    inputs:
      environment:
        description: 'Target environment'
        required: true
        default: 'staging'
        type: choice
        options:
          - staging
          - production

# Variables d'environnement globales
env:
  NODE_VERSION: '20'
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

# Les jobs
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm run format:check

Concepts clés

Runners — Les machines qui exécutent tes jobs :

  • ubuntu-latest — Le plus courant, Linux
  • macos-latest — Pour les builds iOS/macOS
  • windows-latest — Pour .NET et consorts
  • Self-hosted — Tes propres machines pour plus de contrôle

Contextes — Les variables disponibles :

  • ${{ github.sha }} — Le SHA du commit
  • ${{ github.ref_name }} — Le nom de la branche
  • ${{ secrets.MY_SECRET }} — Un secret du repo
  • ${{ vars.MY_VAR }} — Une variable de configuration
  • ${{ needs.job_id.outputs.value }} — Output d’un job précédent

Cache — Crucial pour la performance :

- name: Cache node_modules
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node-

Workflow complet — Build Node.js + Tests

# .github/workflows/ci.yml
name: Node.js CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  NODE_VERSION: '20'

jobs:
  # ─── Lint & Format ───
  quality:
    name: Code Quality
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: ESLint
        run: npm run lint -- --format=json --output-file=eslint-report.json
        continue-on-error: true

      - name: Prettier check
        run: npm run format:check

      - name: Upload lint report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: eslint-report
          path: eslint-report.json

  # ─── Tests unitaires ───
  test-unit:
    name: Unit Tests
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20, 22]
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'

      - run: npm ci
      - run: npm run test:unit -- --coverage

      - name: Upload coverage
        if: matrix.node-version == 20
        uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage/

  # ─── Tests d'intégration ───
  test-integration:
    name: Integration Tests
    runs-on: ubuntu-latest
    needs: [quality]
    services:
      postgres:
        image: postgres:16-alpine
        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
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - run: npm ci

      - name: Run integration tests
        run: npm run test:integration
        env:
          DATABASE_URL: postgresql://test:test@localhost:5432/testdb
          REDIS_URL: redis://localhost:6379

  # ─── Build ───
  build:
    name: Build
    runs-on: ubuntu-latest
    needs: [test-unit, test-integration]
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - run: npm ci
      - run: npm run build

      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/
          retention-days: 7

Workflow complet — Docker Build + Push

# .github/workflows/docker.yml
name: Docker Build & Push

on:
  push:
    branches: [main]
    tags: ['v*']
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  docker:
    name: Build & Push Docker Image
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write  # Pour cosign

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to GitHub Container Registry
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=sha,prefix=git-
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          platforms: linux/amd64,linux/arm64

      - name: Sign image with Cosign
        if: github.event_name != 'pull_request'
        uses: sigstore/cosign-installer@v3

      - name: Sign the image
        if: github.event_name != 'pull_request'
        run: |
          cosign sign --yes \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}

Workflow complet — Deploy Kubernetes

# .github/workflows/deploy.yml
name: Deploy to Kubernetes

on:
  workflow_run:
    workflows: ["Docker Build & Push"]
    types: [completed]
    branches: [main]

jobs:
  deploy-staging:
    name: Deploy to Staging
    runs-on: ubuntu-latest
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    environment:
      name: staging
      url: https://staging.monapp.ch

    steps:
      - uses: actions/checkout@v4

      - name: Install kubectl
        uses: azure/setup-kubectl@v4
        with:
          version: 'v1.30.0'

      - name: Configure kubeconfig
        run: |
          mkdir -p $HOME/.kube
          echo "${{ secrets.KUBE_CONFIG_STAGING }}" | base64 -d > $HOME/.kube/config

      - name: Install Kustomize
        uses: imranismail/setup-kustomize@v2

      - name: Update image tag
        working-directory: k8s/overlays/staging
        run: |
          kustomize edit set image \
            app=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:git-${GITHUB_SHA::7}

      - name: Deploy to staging
        run: |
          kustomize build k8s/overlays/staging | kubectl apply -f -
          kubectl rollout status deployment/app -n staging --timeout=300s

      - name: Run smoke tests
        run: |
          # Attendre que le service soit prêt
          sleep 10
          curl -sf https://staging.monapp.ch/healthz || exit 1
          curl -sf https://staging.monapp.ch/readyz || exit 1

  deploy-production:
    name: Deploy to Production
    runs-on: ubuntu-latest
    needs: [deploy-staging]
    environment:
      name: production
      url: https://monapp.ch

    steps:
      - uses: actions/checkout@v4

      - name: Install kubectl
        uses: azure/setup-kubectl@v4

      - name: Configure kubeconfig
        run: |
          mkdir -p $HOME/.kube
          echo "${{ secrets.KUBE_CONFIG_PROD }}" | base64 -d > $HOME/.kube/config

      - name: Install Kustomize
        uses: imranismail/setup-kustomize@v2

      - name: Deploy with canary
        working-directory: k8s/overlays/production
        run: |
          # D'abord déployer le canary (10% du trafic)
          kustomize edit set image \
            app=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:git-${GITHUB_SHA::7}

          kustomize build . | kubectl apply -f -
          kubectl rollout status deployment/app -n production --timeout=300s

      - name: Post-deploy verification
        run: |
          # Vérifier les métriques pendant 5 minutes
          for i in $(seq 1 30); do
            HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" https://monapp.ch/healthz)
            if [ "$HTTP_CODE" != "200" ]; then
              echo "Health check failed with $HTTP_CODE, rolling back..."
              kubectl rollout undo deployment/app -n production
              exit 1
            fi
            sleep 10
          done
          echo "Deploy verified successfully"

Actions réutilisables (Composite Actions)

Quand tu répètes la même logique dans plusieurs workflows, crée une action composite :

# .github/actions/setup-node-project/action.yml
name: 'Setup Node.js Project'
description: 'Checkout, setup Node.js, install deps'

inputs:
  node-version:
    description: 'Node.js version'
    required: false
    default: '20'

runs:
  using: 'composite'
  steps:
    - uses: actions/checkout@v4

    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}
        cache: 'npm'

    - name: Install dependencies
      shell: bash
      run: npm ci

    - name: Cache build
      uses: actions/cache@v4
      with:
        path: |
          .next/cache
          dist/
        key: build-${{ runner.os }}-${{ hashFiles('**/*.ts', '**/*.tsx') }}

Utilisation :

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: ./.github/actions/setup-node-project
        with:
          node-version: '20'
      - run: npm test

Module 3 — GitLab CI

Structure d’un .gitlab-ci.yml

GitLab CI utilise un seul fichier .gitlab-ci.yml à la racine du repo. La philosophie est différente de GitHub Actions — tout est plus centralisé.

# .gitlab-ci.yml

# Image Docker par défaut pour tous les jobs
default:
  image: node:20-alpine
  # Retry automatique en cas d'échec infra
  retry:
    max: 2
    when:
      - runner_system_failure
      - stuck_or_timeout_failure

# Variables globales
variables:
  NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.npm"
  DOCKER_HOST: tcp://docker:2376
  DOCKER_TLS_CERTDIR: "/certs"

# Définition des stages (ordre d'exécution)
stages:
  - quality
  - test
  - build
  - security
  - deploy

# Cache global
cache:
  key:
    files:
      - package-lock.json
  paths:
    - .npm/
    - node_modules/

# ─── QUALITY ───
lint:
  stage: quality
  script:
    - npm ci --cache .npm --prefer-offline
    - npm run lint
    - npm run format:check
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

# ─── TESTS ───
test:unit:
  stage: test
  script:
    - npm ci --cache .npm --prefer-offline
    - npm run test:unit -- --coverage
  coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
  artifacts:
    when: always
    reports:
      junit: junit.xml
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml
    paths:
      - coverage/

test:integration:
  stage: test
  services:
    - name: postgres:16-alpine
      alias: db
      variables:
        POSTGRES_USER: test
        POSTGRES_PASSWORD: test
        POSTGRES_DB: testdb
    - name: redis:7-alpine
      alias: cache
  variables:
    DATABASE_URL: "postgresql://test:test@db:5432/testdb"
    REDIS_URL: "redis://cache:6379"
  script:
    - npm ci --cache .npm --prefer-offline
    - npm run test:integration
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

# ─── BUILD ───
build:app:
  stage: build
  script:
    - npm ci --cache .npm --prefer-offline
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 1 week

build:docker:
  stage: build
  image: docker:24-dind
  services:
    - docker:24-dind
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - |
      docker build \
        --cache-from $CI_REGISTRY_IMAGE:latest \
        --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA \
        --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG \
        --tag $CI_REGISTRY_IMAGE:latest \
        --build-arg BUILDKIT_INLINE_CACHE=1 \
        .
    - docker push $CI_REGISTRY_IMAGE --all-tags
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - if: $CI_COMMIT_TAG

# ─── SECURITY ───
sast:
  stage: security
  image: returntocorp/semgrep
  script:
    - semgrep --config=auto --json --output=semgrep-report.json .
  artifacts:
    reports:
      sast: semgrep-report.json
  allow_failure: true

container_scan:
  stage: security
  image:
    name: aquasec/trivy:latest
    entrypoint: [""]
  script:
    - trivy image --exit-code 1 --severity HIGH,CRITICAL $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  needs: ["build:docker"]

# ─── DEPLOY ───
deploy:staging:
  stage: deploy
  image: bitnami/kubectl:1.30
  environment:
    name: staging
    url: https://staging.monapp.ch
  script:
    - kubectl config use-context $KUBE_CONTEXT_STAGING
    - |
      kubectl set image deployment/app \
        app=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA \
        -n staging
    - kubectl rollout status deployment/app -n staging --timeout=300s
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  needs: ["build:docker", "test:unit", "test:integration"]

deploy:production:
  stage: deploy
  image: bitnami/kubectl:1.30
  environment:
    name: production
    url: https://monapp.ch
  script:
    - kubectl config use-context $KUBE_CONTEXT_PROD
    - |
      kubectl set image deployment/app \
        app=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA \
        -n production
    - kubectl rollout status deployment/app -n production --timeout=300s
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual
  needs: ["deploy:staging"]

GitLab CI — Fonctionnalités avancées

Les includes — Pour modulariser tes pipelines :

# .gitlab-ci.yml
include:
  # Template local
  - local: '.gitlab/ci/tests.yml'
  # Template distant
  - remote: 'https://gitlab.com/company/ci-templates/-/raw/main/docker.yml'
  # Template du projet
  - project: 'devops/ci-templates'
    ref: main
    file: '/templates/deploy.yml'
  # Templates GitLab intégrés
  - template: Security/SAST.gitlab-ci.yml

Les règles conditionnelles :

deploy:
  rules:
    # Seulement sur main
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual
    # Auto sur les tags
    - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
      when: on_success
    # Jamais sur les MR
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      when: never

Les environments dynamiques — Parfait pour les review apps :

review:
  stage: deploy
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    url: https://$CI_COMMIT_REF_SLUG.review.monapp.ch
    on_stop: stop_review
    auto_stop_in: 1 week
  script:
    - helm upgrade --install review-$CI_COMMIT_REF_SLUG ./chart \
        --set image.tag=$CI_COMMIT_SHA \
        --set ingress.host=$CI_COMMIT_REF_SLUG.review.monapp.ch
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

stop_review:
  stage: deploy
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    action: stop
  script:
    - helm uninstall review-$CI_COMMIT_REF_SLUG
  when: manual

Parent-child pipelines — Pour les monorepos :

# .gitlab-ci.yml (parent)
stages:
  - triggers

frontend:
  stage: triggers
  trigger:
    include: frontend/.gitlab-ci.yml
    strategy: depend
  rules:
    - changes:
        - frontend/**/*

backend:
  stage: triggers
  trigger:
    include: backend/.gitlab-ci.yml
    strategy: depend
  rules:
    - changes:
        - backend/**/*

Module 4 — Testing dans le pipeline

La pyramide de tests

        ╱╲
       ╱ E2E ╲        Peu, lents, coûteux
      ╱────────╲       mais haute confiance
     ╱Integration╲
    ╱──────────────╲   Modérés
   ╱  Unit Tests    ╲
  ╱──────────────────╲ Beaucoup, rapides, pas chers

Tests unitaires

Rapides, isolés, nombreux. Ils couvrent la logique métier.

# Dans ton CI
test:unit:
  stage: test
  script:
    - npm run test:unit -- --coverage --ci --reporters=default --reporters=jest-junit
  artifacts:
    when: always
    reports:
      junit: junit.xml

Exemple de config Jest :

// jest.config.js
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/**/*.d.ts',
    '!src/**/*.test.ts',
    '!src/types/**',
  ],
  coverageThresholds: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
  reporters: [
    'default',
    ['jest-junit', {
      outputDirectory: '.',
      outputName: 'junit.xml',
    }],
  ],
};

Tests d’intégration

Ils testent l’interaction entre composants — API + base de données, par exemple.

# GitHub Actions avec services
test-integration:
  runs-on: ubuntu-latest
  services:
    postgres:
      image: postgres:16-alpine
      env:
        POSTGRES_USER: test
        POSTGRES_PASSWORD: test
        POSTGRES_DB: app_test
      ports:
        - 5432:5432
      options: >-
        --health-cmd pg_isready
        --health-interval 10s
        --health-timeout 5s
        --health-retries 5
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
      with:
        node-version: '20'
        cache: 'npm'
    - run: npm ci
    - run: npm run test:integration
      env:
        DATABASE_URL: postgresql://test:test@localhost:5432/app_test

Tests E2E avec Playwright

# .github/workflows/e2e.yml
name: E2E Tests

on:
  pull_request:
    branches: [main]

jobs:
  e2e:
    runs-on: ubuntu-latest
    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 chromium

      - name: Build app
        run: npm run build

      - name: Start app
        run: npm run start &
        env:
          PORT: 3000

      - name: Wait for app
        run: npx wait-on http://localhost:3000 --timeout 30000

      - name: Run Playwright tests
        run: npx playwright test
        env:
          BASE_URL: http://localhost:3000

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/

Code quality — SonarQube

# GitLab CI avec SonarQube
sonarqube:
  stage: quality
  image:
    name: sonarsource/sonar-scanner-cli:latest
    entrypoint: [""]
  variables:
    SONAR_USER_HOME: "${CI_PROJECT_DIR}/.sonar"
    GIT_DEPTH: "0"  # Important pour l'analyse de blame
  cache:
    key: "${CI_JOB_NAME}"
    paths:
      - .sonar/cache
  script:
    - |
      sonar-scanner \
        -Dsonar.projectKey=$CI_PROJECT_PATH_SLUG \
        -Dsonar.sources=src \
        -Dsonar.tests=src \
        -Dsonar.test.inclusions=**/*.test.ts,**/*.spec.ts \
        -Dsonar.javascript.lcov.reportPaths=coverage/lcov.info \
        -Dsonar.host.url=$SONAR_HOST_URL \
        -Dsonar.token=$SONAR_TOKEN \
        -Dsonar.qualitygate.wait=true

Security scanning dans le CI

# Scan des dépendances avec npm audit
security:deps:
  stage: security
  script:
    - npm audit --audit-level=high
    - npx better-npm-audit audit --level high
  allow_failure: false

# Scan avec Trivy (filesystem mode)
security:trivy:
  stage: security
  image:
    name: aquasec/trivy:latest
    entrypoint: [""]
  script:
    - trivy fs --exit-code 1 --severity HIGH,CRITICAL .
    - trivy fs --format json --output trivy-report.json .
  artifacts:
    reports:
      dependency_scanning: trivy-report.json

# SAST avec Semgrep
security:sast:
  stage: security
  image: returntocorp/semgrep
  script:
    - semgrep --config=auto --config=p/security-audit --json --output=semgrep.json .
  artifacts:
    reports:
      sast: semgrep.json

Module 5 — GitOps

C’est quoi le GitOps ?

Le GitOps, c’est simple : Git est la source de vérité pour ton infrastructure. Tout changement passe par un commit. Pas de kubectl apply à la main, pas de click dans une console.

Les 4 principes du GitOps (OpenGitOps) :

  1. Déclaratif — L’état désiré est décrit, pas les étapes pour y arriver
  2. Versionné et immuable — Git stocke tout l’historique
  3. Automatiquement appliqué — Un agent réconcilie l’état réel avec l’état désiré
  4. Continuellement réconcilié — L’agent vérifie en permanence, pas juste au deploy

Push vs Pull — Deux approches

Push-based (traditionnel) :

  • Le CI pousse les changements vers le cluster
  • Le CI a besoin d’accès au cluster (kubeconfig)
  • Problème : credentials du cluster dans le CI = surface d’attaque

Pull-based (GitOps pur) :

  • Un agent dans le cluster pull les changements depuis Git
  • Le CI ne touche jamais au cluster directement
  • L’agent a un accès en lecture seule à Git
  • Plus sécurisé, plus fiable
Push-based:
  Dev → Git → CI → Build → Push → Cluster
                                 ↑ CI a les credentials

Pull-based (GitOps):
  Dev → Git → CI → Build → Registry

         Git (manifests) ← Agent (ArgoCD/Flux) → Cluster
                           ↑ L'agent vit dans le cluster

ArgoCD — Le standard de facto

Installation :

# Installer ArgoCD
kubectl create namespace argocd
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml

# Récupérer le mot de passe initial
kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d

# Installer le CLI
brew install argocd  # macOS
# ou
curl -sSL -o argocd https://github.com/argoproj/argo-cd/releases/latest/download/argocd-linux-amd64
chmod +x argocd && sudo mv argocd /usr/local/bin/

Définir une Application ArgoCD :

# argocd/applications/my-app.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: my-app
  namespace: argocd
  finalizers:
    - resources-finalizer.argocd.argoproj.io
spec:
  project: default

  source:
    repoURL: https://github.com/company/k8s-manifests.git
    targetRevision: main
    path: apps/my-app/overlays/production

  destination:
    server: https://kubernetes.default.svc
    namespace: production

  syncPolicy:
    automated:
      prune: true       # Supprime les ressources qui ne sont plus dans Git
      selfHeal: true     # Corrige les drifts manuels
      allowEmpty: false  # Empêche de tout supprimer par accident
    syncOptions:
      - CreateNamespace=true
      - PrunePropagationPolicy=foreground
      - PruneLast=true
    retry:
      limit: 5
      backoff:
        duration: 5s
        factor: 2
        maxDuration: 3m

ApplicationSet pour le multi-environnement :

# argocd/applicationsets/my-app.yaml
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: my-app
  namespace: argocd
spec:
  generators:
    - list:
        elements:
          - env: staging
            cluster: in-cluster
            namespace: staging
            values:
              replicas: "1"
          - env: production
            cluster: in-cluster
            namespace: production
            values:
              replicas: "3"
  template:
    metadata:
      name: 'my-app-{{env}}'
    spec:
      project: default
      source:
        repoURL: https://github.com/company/k8s-manifests.git
        targetRevision: main
        path: 'apps/my-app/overlays/{{env}}'
      destination:
        server: https://kubernetes.default.svc
        namespace: '{{namespace}}'
      syncPolicy:
        automated:
          prune: true
          selfHeal: true

Flux CD — L’alternative CNCF

# Installer Flux
flux install

# Bootstrap avec GitHub
flux bootstrap github \
  --owner=company \
  --repository=fleet-infra \
  --branch=main \
  --path=./clusters/production \
  --personal

Définir un déploiement Flux :

# clusters/production/my-app/kustomization.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: my-app
  namespace: flux-system
spec:
  interval: 5m
  path: ./apps/my-app/overlays/production
  prune: true
  sourceRef:
    kind: GitRepository
    name: fleet-infra
  healthChecks:
    - apiVersion: apps/v1
      kind: Deployment
      name: my-app
      namespace: production
  timeout: 3m
  retryInterval: 1m

---
# Image automation (met à jour automatiquement le tag dans Git)
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImagePolicy
metadata:
  name: my-app
  namespace: flux-system
spec:
  imageRepositoryRef:
    name: my-app
  policy:
    semver:
      range: '>=1.0.0'

---
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImageRepository
metadata:
  name: my-app
  namespace: flux-system
spec:
  image: ghcr.io/company/my-app
  interval: 1m

Structure d’un repo GitOps

k8s-manifests/
├── apps/
│   ├── my-app/
│   │   ├── base/
│   │   │   ├── kustomization.yaml
│   │   │   ├── deployment.yaml
│   │   │   ├── service.yaml
│   │   │   └── hpa.yaml
│   │   └── overlays/
│   │       ├── staging/
│   │       │   ├── kustomization.yaml
│   │       │   └── patch-replicas.yaml
│   │       └── production/
│   │           ├── kustomization.yaml
│   │           ├── patch-replicas.yaml
│   │           └── patch-resources.yaml
│   └── another-app/
│       └── ...
├── infrastructure/
│   ├── cert-manager/
│   ├── ingress-nginx/
│   └── monitoring/
└── clusters/
    ├── staging/
    │   └── kustomization.yaml
    └── production/
        └── kustomization.yaml

Comparatif — GitHub Actions vs GitLab CI

Pas de tableau, comme promis. Voici les différences qui comptent :

Configuration

  • GitHub Actions : Un fichier par workflow dans .github/workflows/. Tu peux en avoir autant que tu veux. Chaque workflow est indépendant.
  • GitLab CI : Un seul .gitlab-ci.yml à la racine (avec possibilité d’includes). Plus centralisé, mais peut devenir un monstre sur un gros projet.

Runners

  • GitHub Actions : Runners hébergés par GitHub (gratuit pour les repos publics, minutes limitées pour les privés). Self-hosted runners disponibles. Les runners GitHub sont frais à chaque job.
  • GitLab CI : Runners auto-hébergés par défaut (gitlab.com offre des shared runners avec minutes). Plus de contrôle, mais tu gères l’infra.

Marketplace / Écosystème

  • GitHub Actions : Marketplace gigantesque avec des milliers d’actions. C’est un avantage et un risque (supply chain attacks — utilise toujours un SHA, pas un tag).
  • GitLab CI : Pas de marketplace, mais des templates intégrés (SAST, DAST, container scanning). Moins de risque de supply chain, mais tu codes plus toi-même.

Container Registry

  • GitHub Actions : ghcr.io intégré. Gratuit pour les repos publics.
  • GitLab CI : Registry intégré par projet. Variables $CI_REGISTRY* prêtes à l’emploi. Très bien intégré.

Environnements et review apps

  • GitHub Actions : Support des environments avec protection rules et secrets par environnement. Pas de review apps native.
  • GitLab CI : Environments dynamiques natifs, review apps intégrées, auto-stop. Plus mature sur ce point.

Monorepo

  • GitHub Actions : Path filters dans les triggers (on.push.paths). Fonctionne, mais pas de pipeline parent-enfant natif.
  • GitLab CI : Parent-child pipelines, rules:changes par chemin. Meilleur support monorepo.

Prix (en 2026)

  • GitHub Actions : 2000 min/mois gratuit (privé), illimité en public. Après, ~0.008$/min (Linux).
  • GitLab CI : 400 min/mois gratuit (shared runners). Self-hosted = coût de ton infra.

Verdict

  • Choisis GitHub Actions si tu es déjà sur GitHub, que tu veux un écosystème riche, et que tes projets sont petits à moyens.
  • Choisis GitLab CI si tu as besoin de tout-en-un (registry, environments, review apps), de self-hosting, ou si tu travailles en monorepo.
  • Les deux sont excellents. Le meilleur CI/CD, c’est celui que ton équipe maîtrise.

Bonnes pratiques — Checklist

Avant de partir, vérifie que ton pipeline coche ces cases :

Vitesse

  • Le pipeline complet tourne en moins de 15 minutes
  • Les tests unitaires finissent en moins de 5 minutes
  • Tu utilises le cache pour les dépendances
  • Les jobs indépendants tournent en parallèle

Fiabilité

  • Pas de tests flaky (ou ils sont identifiés et quarantinés)
  • Les retry sont configurés pour les échecs infra
  • Les timeouts sont explicites

Sécurité

  • Les secrets sont dans un vault / secrets manager
  • Les images Docker sont scannées (Trivy, Snyk)
  • Les dépendances sont auditées
  • Tu pin les versions des actions/images (SHA, pas latest)
  • Cosign signe tes images

GitOps

  • L’état désiré est dans Git
  • Les deployments sont automatiques (staging) ou semi-auto (prod)
  • Le drift est détecté et corrigé
  • Les rollbacks sont un git revert

Observabilité

  • Tu sais pourquoi un pipeline a échoué en 30 secondes
  • Les métriques de pipeline sont trackées (durée, taux de succès)
  • Les notifications sont ciblées (pas de spam)

Pour aller plus loin


Tu veux aller plus loin ? Consulte nos autres formations sur Kubernetes, Docker, et Sécurité DevSecOps.