Aller au contenu principal
Tous les articles
·7 min de lecture

CI/CD GitHub Actions pour SaaS Next.js : pipeline complet en 1 fichier

Construire un pipeline CI/CD pro pour un SaaS Next.js — typecheck, lint, tests Vitest, Playwright, Prisma migrate, deploy Vercel. Un seul .yml, ~120 lignes.

Pourquoi GitHub Actions plutôt que les alternatives

CircleCI, GitLab CI, Buildkite... Tous fonctionnent. Mais pour un SaaS Next.js déployé sur Vercel, GitHub Actions a 3 avantages décisifs :

  • Intégration native GitHub → status checks dans les PRs, pas de webhook à configurer
  • Free tier généreux → 2000 minutes/mois sur repos privés, illimité sur public
  • Marketplace riche → 90% des actions dont vous avez besoin existent déjà

Pour un SaaS early-stage, GH Actions couvre 100% des besoins CI/CD sans payer un cent.

L'objectif du pipeline

Quand un dev push une branche ou ouvre une PR, on veut :

  1. Typecheck — aucune erreur TypeScript
  2. Lint — code propre selon ESLint
  3. Format check — Prettier respecté
  4. Tests unitaires — Vitest 100% pass
  5. Tests sécurité — IDOR, RBAC, signatures HMAC
  6. Prisma validate — schéma cohérent
  7. Build — Next.js compile sans erreur
  8. E2E — Playwright sur les flows critiques (sur PRs main only)
  9. Deploy — Vercel auto sur merge main

Le tout en parallèle quand possible, < 5 minutes au total.

Le pipeline complet

# .github/workflows/ci.yml
name: CI
 
on:
  pull_request:
    branches: [main]
  push:
    branches: [main]
 
env:
  NODE_VERSION: "20"
  PNPM_VERSION: "9"
 
jobs:
  install:
    runs-on: ubuntu-latest
    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
 
      - run: pnpm install --frozen-lockfile
 
      - name: Generate Prisma client
        run: pnpm prisma generate
 
      # Cache node_modules + generated/ pour les jobs suivants
      - uses: actions/cache/save@v4
        with:
          path: |
            node_modules
            apps/web/generated
          key: deps-${{ github.sha }}
 
  typecheck:
    needs: install
    runs-on: ubuntu-latest
    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
      - uses: actions/cache/restore@v4
        with:
          path: |
            node_modules
            apps/web/generated
          key: deps-${{ github.sha }}
      - run: pnpm typecheck
 
  lint:
    needs: install
    runs-on: ubuntu-latest
    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
      - uses: actions/cache/restore@v4
        with:
          path: |
            node_modules
            apps/web/generated
          key: deps-${{ github.sha }}
      - run: pnpm lint
      - run: pnpm format:check
 
  test:
    needs: install
    runs-on: ubuntu-latest
    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
      - uses: actions/cache/restore@v4
        with:
          path: |
            node_modules
            apps/web/generated
          key: deps-${{ github.sha }}
      - run: pnpm test
      - run: pnpm test:security
 
  build:
    needs: [typecheck, lint, test]
    runs-on: ubuntu-latest
    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
      - uses: actions/cache/restore@v4
        with:
          path: |
            node_modules
            apps/web/generated
          key: deps-${{ github.sha }}
      - run: pnpm build
        env:
          # Variables d'environnement minimales pour build
          DATABASE_URL: "postgresql://fake:fake@localhost:5432/fake"
          NEXTAUTH_SECRET: "fake-secret-for-build-only"
          NEXTAUTH_URL: "http://localhost:3000"
          SKIP_ENV_VALIDATION: "1"

Le pattern critique : factoriser le setup

Vous voyez la duplication ? Chaque job répète :

- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: actions/cache/restore@v4

C'est volontaire et c'est nécessaire. Chaque job tourne sur sa propre VM dans GitHub Actions — pas de partage de state entre jobs. Le cache que install a écrit est lu par les autres jobs.

Pour éviter la duplication, utilisez une composite action locale :

# .github/actions/setup/action.yml
name: Setup
description: Setup Node, pnpm, install deps
runs:
  using: composite
  steps:
    - uses: pnpm/action-setup@v4
      with:
        version: "9"
    - uses: actions/setup-node@v4
      with:
        node-version: "20"
        cache: pnpm
    - uses: actions/cache/restore@v4
      with:
        path: |
          node_modules
          apps/web/generated
        key: deps-${{ github.sha }}

Puis dans chaque job :

steps:
  - uses: actions/checkout@v4
  - uses: ./.github/actions/setup
  - run: pnpm typecheck

3 lignes au lieu de 12 par job. La maintenance est centralisée.

Tests E2E Playwright sur PRs

Les tests Playwright sont lents (3-5min). Vous ne voulez pas les lancer sur chaque commit, mais sur les PRs vers main :

e2e:
  if: github.event_name == 'pull_request'
  needs: build
  runs-on: ubuntu-latest
 
  services:
    postgres:
      image: postgres:16
      env:
        POSTGRES_USER: postgres
        POSTGRES_PASSWORD: postgres
        POSTGRES_DB: heartco_test
      ports: [5432:5432]
      options: >-
        --health-cmd pg_isready
        --health-interval 10s
        --health-timeout 5s
        --health-retries 5
 
  steps:
    - uses: actions/checkout@v4
    - uses: ./.github/actions/setup
 
    - name: Install Playwright browsers
      run: pnpm exec playwright install --with-deps chromium
 
    - name: Run migrations on test DB
      run: pnpm prisma migrate deploy
      env:
        DATABASE_URL: postgresql://postgres:postgres@localhost:5432/heartco_test
 
    - name: Seed test data
      run: pnpm tsx scripts/seed-e2e.ts
      env:
        DATABASE_URL: postgresql://postgres:postgres@localhost:5432/heartco_test
 
    - name: Run Playwright tests
      run: pnpm exec playwright test
      env:
        DATABASE_URL: postgresql://postgres:postgres@localhost:5432/heartco_test
        NEXTAUTH_SECRET: test-secret
        NEXTAUTH_URL: http://localhost:3000
 
    - uses: actions/upload-artifact@v4
      if: failure()
      with:
        name: playwright-report
        path: playwright-report/
        retention-days: 7

Vercel deploy — automatique mais sécurisé

Vercel détecte les push GitHub et déploie automatiquement. Vous n'avez rien à câbler dans le workflow.

Par contre, configurez Vercel pour ne déployer en production que quand CI passe :

// vercel.json
{
  "git": {
    "deploymentEnabled": {
      "main": true
    }
  },
  "github": {
    "enabled": true,
    "silent": false,
    "autoJobCancelation": true
  }
}

Dans Vercel → Settings → Git :

  • ✅ "Require status checks before deploying" → cochez typecheck, test, build
  • ✅ "Production Branch" → main uniquement

Résultat : un push qui casse le typecheck n'atteint jamais la production.

Variables d'environnement et secrets

Trois niveaux :

  1. Public dans le repo (env vars du workflow) → versions de Node, pnpm
  2. Secrets GitHub (Settings → Secrets → Actions) → tokens API, DB URL test
  3. Secrets Vercel (Vercel Dashboard → env vars) → production secrets
- run: pnpm test:integration
  env:
    DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
    STRIPE_TEST_KEY: ${{ secrets.STRIPE_TEST_KEY }}

Performance — Faire passer le pipeline sous 3 minutes

Astuces qui ont marché pour nous :

  1. Cache des deps (déjà fait dans le pipeline) → -45s par job
  2. Turbo cache distantpnpm turbo build --remote-cache avec Vercel Remote Cache → -30s
  3. Parallélisation maximale → typecheck/lint/test en parallèle, pas en série
  4. Skip des jobs non concernés → utilisez paths-filter :
jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      web: ${{ steps.filter.outputs.web }}
      marketing: ${{ steps.filter.outputs.marketing }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            web:
              - 'apps/web/**'
              - 'packages/**'
            marketing:
              - 'apps/marketing/**'
 
  test-web:
    needs: changes
    if: needs.changes.outputs.web == 'true'
    # ... ne tourne que si apps/web/ a changé
  1. Fail fast → ajoutez fail-fast: false à false dans la matrice si vous voulez voir tous les échecs, ou laissez le défaut pour économiser des minutes.

Erreurs courantes

Mettre npm install à la place de pnpm install --frozen-lockfile → CI plus lente et non déterministe.

Oublier --frozen-lockfile → la CI installe des versions différentes du dev. Bug en prod garanti.

Lancer les tests avec une vraie DB en parallèle → race conditions inter-jobs. Utilisez des bases isolées (services postgres dédiés par job).

Pas de timeout → un test bloqué peut consommer 6h de CI. Ajoutez timeout-minutes: 10 à chaque job.

Workflows qui se déclenchent en boucle → si un workflow push sur le repo, il peut redéclencher un autre workflow. Ajoutez if: github.actor != 'github-actions[bot]' pour briser le cycle.

Monitoring et alertes

Pour être notifié quand le CI casse sur main :

- name: Notify Slack on failure
  if: failure() && github.ref == 'refs/heads/main'
  uses: slackapi/slack-github-action@v1
  with:
    webhook: ${{ secrets.SLACK_WEBHOOK }}
    payload: |
      {
        "text": "🚨 CI failed on main",
        "blocks": [{
          "type": "section",
          "text": {
            "type": "mrkdwn",
            "text": "*CI failed:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|run #${{ github.run_number }}>"
          }
        }]
      }

Conclusion

Un bon pipeline CI/CD n'est pas une science compliquée — c'est de l'hygiène : ne pas merger ce qui casse, déployer ce qui passe, alerter ce qui foire. 120 lignes de YAML couvrent tous ces besoins pour un SaaS Next.js.

HeartCo Starter inclut ce pipeline par défaut, avec composite actions, cache Turbo, et hooks Vercel. Vous clonez, vous push, et vous avez la CI verte (ou rouge, mais alors c'est qu'il y a une vraie raison).

Partager