GitHub Actions CI/CD for Monorepo Projects

Complete GitHub Actions CI/CD guide for Turborepo monorepos: affected-only builds, parallel jobs, caching strategies, environment-based deploys, and security best practices.

E
ECOSIRE Research and Development Team
|19 mars 20269 min de lecture2.0k Mots|

GitHub Actions CI/CD pour les projets Monorepo

Un pipeline CI/CD pour un monorepo est confronté à un défi fondamentalement différent de celui d'un pipeline à application unique : comment exécuter des pipelines rapides et fiables lorsqu'une seule de vos 5 applications a changé ? En construisant naïvement tout sur chaque commit, un pipeline de 2 minutes se transforme en un goulot d'étranglement de 15 minutes que les développeurs contournent en regroupant les commits - exactement ce que vous essayez d'éviter.

Ce guide couvre une configuration de production GitHub Actions pour un monorepo Turborepo : versions affectées uniquement, exécution de tâches parallèles, intégration de la mise en cache à distance Turbo, portes de déploiement basées sur l'environnement et modèles de sécurité qui empêchent les secrets de s'infiltrer dans les journaux ou les exécutions PR dupliquées.

Points clés à retenir

  • Utilisez fetch-depth: 2 lors du paiement pour activer la détection des packages concernés par Turbo.
  • Exécutez des tâches lint, de vérification de type, de test et de création en parallèle - ne les enchaînez pas de manière séquentielle
  • Les tâches matricielles vous permettent de tester sur plusieurs versions de Node.js ou systèmes d'exploitation sans dupliquer YAML
  • Stockez les informations d'identification du cache distant Turbo en tant que secrets GitHub, et non en tant que variables d'environnement dans YAML - Utilisez les règles de protection environment: sur les tâches de déploiement en production pour exiger une approbation manuelle
  • L'action paths-filter vous permet d'ignorer complètement CI lorsque seuls les documents ou les fichiers non codés sont modifiés
  • N'imprimez jamais de secrets dans les journaux - utilisez ::add-mask:: pour les secrets générés par l'exécution
  • Les workflows réutilisables (workflow_call) éliminent la duplication entre plusieurs workflows de référentiel

Structure du référentiel pour CI

Une bonne configuration CI reflète votre flux de travail de développement. Voici la structure qui fonctionne pour un monorepo Turborepo :

.github/
  workflows/
    ci.yml          — Runs on every PR and push to main
    deploy.yml      — Runs on push to main (after CI passes)
    security.yml    — Weekly security scans
    cleanup.yml     — Scheduled: delete stale preview deployments
  actions/
    setup-pnpm/
      action.yml    — Reusable: install Node + pnpm + cache

Flux de travail CI de base

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

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
    types: [opened, synchronize, reopened]

# Cancel in-progress runs when a new run starts
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: ${{ vars.TURBO_TEAM }}
  NODE_VERSION: '22'
  PNPM_VERSION: '10.28.0'

jobs:
  # Fast path: skip if only docs/assets changed
  changes:
    runs-on: ubuntu-latest
    outputs:
      code: ${{ steps.filter.outputs.code }}
      docs: ${{ steps.filter.outputs.docs }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            code:
              - 'apps/**/*.ts'
              - 'apps/**/*.tsx'
              - 'packages/**/*.ts'
              - 'turbo.json'
              - 'package.json'
              - 'pnpm-lock.yaml'
            docs:
              - 'apps/docs/**'
              - '**/*.md'

  lint:
    needs: changes
    if: needs.changes.outputs.code == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2  # Required for turbo affected detection

      - uses: pnpm/action-setup@v4
        with:
          version: ${{ env.PNPM_VERSION }}

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

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Lint
        run: pnpm turbo run lint

      - name: Type check
        run: pnpm turbo run type-check

  test:
    needs: changes
    if: needs.changes.outputs.code == 'true'
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:17-alpine
        env:
          POSTGRES_DB: ecosire_test
          POSTGRES_USER: ecosire
          POSTGRES_PASSWORD: password
        ports:
          - 5433: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
        with:
          fetch-depth: 2

      - uses: pnpm/action-setup@v4
        with:
          version: ${{ env.PNPM_VERSION }}

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

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Run unit tests
        run: pnpm turbo run test
        env:
          DATABASE_URL: postgresql://ecosire:password@localhost:5433/ecosire_test
          REDIS_URL: redis://localhost:6379

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          files: ./apps/api/coverage/lcov.info

  build:
    needs: [lint, test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2

      - uses: pnpm/action-setup@v4
        with:
          version: ${{ env.PNPM_VERSION }}

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

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Build all apps
        run: pnpm turbo run build
        env:
          NEXT_PUBLIC_API_URL: ${{ vars.STAGING_API_URL }}

      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: build-${{ github.sha }}
          path: |
            apps/web/.next/
            apps/api/dist/
          retention-days: 1  # Keep briefly for deploy job

Travail de test E2E avec un dramaturge

  e2e:
    needs: build
    runs-on: ubuntu-latest
    strategy:
      matrix:
        project: [chromium, firefox, mobile-chrome]
      fail-fast: false  # Don't cancel other browsers if one fails

    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: ${{ env.PNPM_VERSION }}

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

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Install Playwright browsers
        run: cd apps/web && npx playwright install ${{ matrix.project }} --with-deps

      - name: Download build artifacts
        uses: actions/download-artifact@v4
        with:
          name: build-${{ github.sha }}

      - name: Start test servers
        run: |
          cd apps/api && node dist/main.js &
          cd apps/web && npx next start &
          npx wait-on http://localhost:3000 http://localhost:3001/health
        env:
          DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}

      - name: Run E2E tests
        run: cd apps/web && npx playwright test --project=${{ matrix.project }}
        env:
          BASE_URL: http://localhost:3000

      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report-${{ matrix.project }}
          path: apps/web/playwright-report/
          retention-days: 7

Flux de travail d'analyse de sécurité

# .github/workflows/security.yml
name: Security Scan

on:
  schedule:
    - cron: '0 2 * * 1'  # Weekly Monday 2am UTC
  push:
    paths:
      - '**/package.json'
      - 'pnpm-lock.yaml'

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

      - uses: pnpm/action-setup@v4
        with:
          version: '10.28.0'

      - name: Run pnpm audit
        run: pnpm audit --audit-level moderate
        continue-on-error: true  # Don't fail CI, just report

      - name: Run Snyk security scan
        uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
        with:
          command: test
          args: --severity-threshold=high

  secret-scanning:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Full history for secret scanning

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

Flux de travail de déploiement

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

on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'Deploy environment'
        required: true
        default: 'production'
        type: choice
        options:
          - production
          - staging

# Only one production deploy at a time
concurrency:
  group: deploy-${{ inputs.environment }}
  cancel-in-progress: false  # Don't cancel in-flight deploys

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

      - uses: pnpm/action-setup@v4
        with:
          version: '10.28.0'

      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile

      - name: Run quality checks
        run: |
          pnpm turbo run lint type-check test
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM: ${{ vars.TURBO_TEAM }}

  deploy:
    needs: quality-gate
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}  # Requires approval for production
    steps:
      - uses: actions/checkout@v4

      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            set -e

            cd /opt/ecosire/app

            echo "Pulling latest changes..."
            git pull origin main

            echo "Installing dependencies..."
            pnpm install --frozen-lockfile

            echo "Building..."
            TURBO_TOKEN=${{ secrets.TURBO_TOKEN }} \
            TURBO_TEAM=${{ vars.TURBO_TEAM }} \
            npx turbo run build

            echo "Running migrations..."
            pnpm --filter @ecosire/db db:migrate

            echo "Restarting services..."
            pm2 restart ecosystem.config.cjs --update-env

            echo "Health check..."
            sleep 10
            curl -f https://ecosire.com/api/health || exit 1
            curl -f https://api.ecosire.com/api/health || exit 1

            echo "Deploy complete!"

      - name: Notify on success
        if: success()
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.repos.createCommitComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              commit_sha: context.sha,
              body: `Deployed to ${process.env.ENVIRONMENT} at ${new Date().toISOString()}`
            })
        env:
          ENVIRONMENT: ${{ inputs.environment }}

      - name: Notify on failure
        if: failure()
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.create({
              owner: context.repo.owner,
              repo: context.repo.repo,
              title: `Deploy failed: ${process.env.ENVIRONMENT}`,
              body: `Deploy to ${process.env.ENVIRONMENT} failed at ${new Date().toISOString()}\n\nRun: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
            })
        env:
          ENVIRONMENT: ${{ inputs.environment }}

Stratégie de mise en cache

Une mise en cache efficace est la différence entre des exécutions CI de 2 et 8 minutes :

# Reusable setup step (extract to composite action)
# .github/actions/setup-pnpm/action.yml
name: Setup pnpm
runs:
  using: composite
  steps:
    - uses: pnpm/action-setup@v4
      with:
        version: '10.28.0'

    - uses: actions/setup-node@v4
      with:
        node-version: '22'
        cache: 'pnpm'

    # Turbo cache (separate from pnpm cache)
    - uses: actions/cache@v4
      with:
        path: .turbo
        key: turbo-${{ runner.os }}-${{ github.sha }}
        restore-keys: |
          turbo-${{ runner.os }}-

    - name: Install dependencies
      run: pnpm install --frozen-lockfile
      shell: bash

Bonnes pratiques de sécurité

Ne faites jamais écho aux secrets :

# Wrong — secret appears in logs
- run: echo "API key is ${{ secrets.API_KEY }}"

# If you must generate and use runtime secrets
- run: |
    echo "::add-mask::$GENERATED_SECRET"
    GENERATED_SECRET=$(generate-secret)
    echo "GENERATED_SECRET=$GENERATED_SECRET" >> $GITHUB_ENV

Restreindre les autorisations :

# Minimum permissions at workflow level
permissions:
  contents: read
  actions: read
  checks: write  # Add only what you need

jobs:
  test:
    permissions:
      contents: read  # Override at job level if needed

Empêcher l'injection de requêtes d'extraction :

# For PRs from forks — never expose secrets
on:
  pull_request:

jobs:
  test:
    # Secrets not available in fork PRs by default
    # Use pull_request_target only for trusted actions
    runs-on: ubuntu-latest

Questions fréquemment posées

Comment déclencher un workflow uniquement lorsque des packages spécifiques changent ?

Utilisez dorny/paths-filter pour détecter les packages modifiés, puis transmettez les sorties du filtre aux conditions de travail. Pour la détection intégrée des éléments affectés par Turbo, assurez-vous fetch-depth: 2 lors de votre étape de paiement afin que Turbo puisse comparer avec le commit précédent. Utilisez pnpm turbo run test --filter="...[HEAD^1]" pour exécuter des tests uniquement pour les packages modifiés et leurs dépendants.

Comment dois-je gérer les migrations de bases de données dans CI ?

Exécutez les migrations dans une tâche distincte qui s'exécute après les tests mais avant la création de l'artefact de build final. Utilisez une URL de base de données de test dédiée dans CI (séparée du développement et de la production). N'exécutez jamais automatiquement des migrations vers la production à partir de CI : utilisez workflow_dispatch avec approbation manuelle via les règles de protection de l'environnement GitHub pour les modifications de la base de données de production.

Quelle est la meilleure façon de gérer les secrets dans plusieurs environnements ?

Utilisez les environnements GitHub (Paramètres > Environnements) avec des règles de protection. Créez des environnements distincts pour staging et production. Stockez les secrets spécifiques à l'environnement sous chaque environnement, et non au niveau du référentiel. Pour les secrets partagés (comme le jeton de cache distant Turbo), utilisez les secrets au niveau du référentiel. La clé environment: dans la configuration du travail active les secrets et les règles de protection spécifiques à l'environnement.

Comment accélérer l'installation de pnpm dans CI ?

Le actions/setup-node@v4 avec cache: 'pnpm' met en cache le magasin pnpm. Pour les monorepos volumineux, utilisez également pnpm install --prefer-offline après avoir restauré le cache. La combinaison de la mise en cache du magasin pnpm et de la mise en cache à distance Turbo réduit généralement le temps d'installation et de construction de 60 à 80 % sur les accès au cache.

Comment exécuter différents workflows pour les PR et les push de branche principale ?

Utilisez les conditions de déclenchement on : on.push.branches pour le principal et on.pull_request.branches pour les PR. Vous pouvez exécuter un sous-ensemble de tests plus rapide pour les PR (peluches, tests unitaires) et la suite complète sur main. Utilisez les conditions github.event_name == 'push' pour exécuter des tâches de déploiement uniquement lors du push to main, et non lors des événements PR.


Prochaines étapes

Un pipeline CI/CD bien conçu est la base d'une équipe d'ingénierie à haute vitesse : il vous donne la confiance nécessaire pour expédier fréquemment et détecter automatiquement les régressions. ECOSIRE exécute GitHub Actions CI avec lint, vérification de type, 1 301 tests unitaires, Playwright E2E et des déploiements SSH sans temps d'arrêt à chaque poussée vers main.

Que vous ayez besoin de conseils en architecture CI/CD, d'une configuration monorepo Turborepo ou d'une assistance DevOps complète, découvrez nos services d'ingénierie.

E

Rédigé par

ECOSIRE Research and Development Team

Création de produits numériques de niveau entreprise chez ECOSIRE. Partage d'analyses sur les intégrations Odoo, l'automatisation e-commerce et les solutions d'entreprise propulsées par l'IA.

Discutez sur WhatsApp