xpeditis2.0/docs/deployment/hetzner/11-cicd-github-actions.md
2026-03-26 18:08:28 +01:00

15 KiB

11 — CI/CD avec GitHub Actions

Pipeline complet : commit → build Docker → push GHCR → déploiement k3s → vérification.


Architecture du pipeline

Push sur main
     │
     ├── Job: test
     │   ├── npm run backend:lint
     │   ├── npm run backend:test
     │   └── npm run frontend:lint
     │
     ├── Job: build (si tests OK)
     │   ├── docker buildx build backend → ghcr.io/<org>/xpeditis-backend:sha + :latest
     │   └── docker buildx build frontend → ghcr.io/<org>/xpeditis-frontend:sha + :latest
     │
     └── Job: deploy (si build OK)
         ├── kubectl set image deployment/xpeditis-backend ...
         ├── kubectl set image deployment/xpeditis-frontend ...
         ├── kubectl rollout status ...
         └── Health check final

Secrets GitHub à configurer

Dans votre repo GitHub → Settings → Secrets and variables → Actions → New repository secret :

Secret Valeur Usage
HETZNER_KUBECONFIG Contenu de ~/.kube/kubeconfig-xpeditis-prod (base64) Accès kubectl
GHCR_TOKEN Personal Access Token GitHub (scope: write:packages) Push images
SLACK_WEBHOOK_URL URL webhook Slack (optionnel) Notifications
# Encoder le kubeconfig en base64 pour GitHub Secrets
cat ~/.kube/kubeconfig-xpeditis-prod | base64 -w 0
# Copier le résultat dans HETZNER_KUBECONFIG

# Créer le Personal Access Token GitHub
# https://github.com/settings/tokens/new
# Scopes : write:packages, read:packages, delete:packages

Workflow principal — .github/workflows/deploy.yml

# .github/workflows/deploy.yml
name: Build & Deploy to Hetzner

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

env:
  REGISTRY: ghcr.io
  IMAGE_BACKEND: ghcr.io/${{ github.repository_owner }}/xpeditis-backend
  IMAGE_FRONTEND: ghcr.io/${{ github.repository_owner }}/xpeditis-frontend

jobs:
  # ============================================================
  # JOB 1 : Tests & Lint
  # ============================================================
  test:
    name: Tests & Lint
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"

      - name: Install dependencies
        run: npm run install:all

      - name: Lint backend
        run: npm run backend:lint

      - name: Lint frontend
        run: npm run frontend:lint

      - name: Test backend (unit)
        run: npm run backend:test -- --passWithNoTests

      - name: TypeScript check frontend
        run: |
          cd apps/frontend
          npm run type-check          

  # ============================================================
  # JOB 2 : Build & Push Docker Images
  # ============================================================
  build:
    name: Build Docker Images
    runs-on: ubuntu-latest
    needs: test
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    permissions:
      contents: read
      packages: write

    outputs:
      backend_tag: ${{ steps.meta-backend.outputs.version }}
      frontend_tag: ${{ steps.meta-frontend.outputs.version }}

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

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

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

      # ── Backend ──
      - name: Extract metadata (backend)
        id: meta-backend
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.IMAGE_BACKEND }}
          tags: |
            type=sha,prefix=sha-,format=short
            type=raw,value=latest,enable={{is_default_branch}}
            type=raw,value={{date 'YYYY-MM-DD'}},enable={{is_default_branch}}            

      - name: Build & Push backend
        uses: docker/build-push-action@v5
        with:
          context: .
          file: apps/backend/Dockerfile
          push: true
          tags: ${{ steps.meta-backend.outputs.tags }}
          labels: ${{ steps.meta-backend.outputs.labels }}
          cache-from: type=gha,scope=backend
          cache-to: type=gha,mode=max,scope=backend
          platforms: linux/amd64     # Changer en linux/amd64,linux/arm64 si vous utilisez des CAX

      # ── Frontend ──
      - name: Extract metadata (frontend)
        id: meta-frontend
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.IMAGE_FRONTEND }}
          tags: |
            type=sha,prefix=sha-,format=short
            type=raw,value=latest,enable={{is_default_branch}}
            type=raw,value={{date 'YYYY-MM-DD'}},enable={{is_default_branch}}            

      - name: Build & Push frontend
        uses: docker/build-push-action@v5
        with:
          context: .
          file: apps/frontend/Dockerfile
          push: true
          tags: ${{ steps.meta-frontend.outputs.tags }}
          labels: ${{ steps.meta-frontend.outputs.labels }}
          cache-from: type=gha,scope=frontend
          cache-to: type=gha,mode=max,scope=frontend
          platforms: linux/amd64
          build-args: |
            NEXT_PUBLIC_API_URL=https://api.xpeditis.com
            NEXT_PUBLIC_APP_URL=https://app.xpeditis.com
            NEXT_PUBLIC_API_PREFIX=api/v1            

  # ============================================================
  # JOB 3 : Deploy vers k3s Hetzner
  # ============================================================
  deploy:
    name: Deploy to Hetzner k3s
    runs-on: ubuntu-latest
    needs: build
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    environment:
      name: production
      url: https://app.xpeditis.com

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

      - name: Configure kubectl
        run: |
          mkdir -p ~/.kube
          echo "${{ secrets.HETZNER_KUBECONFIG }}" | base64 -d > ~/.kube/config
          chmod 600 ~/.kube/config
          kubectl cluster-info          

      - name: Deploy Backend
        run: |
          BACKEND_TAG="sha-$(echo ${{ github.sha }} | cut -c1-7)"

          kubectl set image deployment/xpeditis-backend \
            backend=${{ env.IMAGE_BACKEND }}:${BACKEND_TAG} \
            -n xpeditis-prod

          kubectl rollout status deployment/xpeditis-backend \
            -n xpeditis-prod \
            --timeout=300s

          echo "✅ Backend deployed: ${BACKEND_TAG}"          

      - name: Deploy Frontend
        run: |
          FRONTEND_TAG="sha-$(echo ${{ github.sha }} | cut -c1-7)"

          kubectl set image deployment/xpeditis-frontend \
            frontend=${{ env.IMAGE_FRONTEND }}:${FRONTEND_TAG} \
            -n xpeditis-prod

          kubectl rollout status deployment/xpeditis-frontend \
            -n xpeditis-prod \
            --timeout=300s

          echo "✅ Frontend deployed: ${FRONTEND_TAG}"          

      - name: Health Check
        run: |
          sleep 15  # Laisser le temps au LB de propager

          # Test API backend
          STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \
            https://api.xpeditis.com/api/v1/health)
          if [ "$STATUS" != "200" ]; then
            echo "❌ Backend health check failed (HTTP $STATUS)"
            exit 1
          fi
          echo "✅ Backend healthy (HTTP $STATUS)"

          # Test frontend
          STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \
            https://app.xpeditis.com/)
          if [ "$STATUS" != "200" ]; then
            echo "❌ Frontend health check failed (HTTP $STATUS)"
            exit 1
          fi
          echo "✅ Frontend healthy (HTTP $STATUS)"          

      - name: Notify Slack (success)
        if: success()
        run: |
          if [ -n "${{ secrets.SLACK_WEBHOOK_URL }}" ]; then
            curl -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \
              -H 'Content-type: application/json' \
              --data '{
                "text": "✅ Xpeditis déployé en production",
                "attachments": [{
                  "color": "good",
                  "fields": [
                    {"title": "Commit", "value": "${{ github.sha }}", "short": true},
                    {"title": "Auteur", "value": "${{ github.actor }}", "short": true},
                    {"title": "Message", "value": "${{ github.event.head_commit.message }}", "short": false}
                  ]
                }]
              }'
          fi          

      - name: Notify Slack (failure)
        if: failure()
        run: |
          if [ -n "${{ secrets.SLACK_WEBHOOK_URL }}" ]; then
            curl -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \
              -H 'Content-type: application/json' \
              --data '{
                "text": "❌ Échec du déploiement Xpeditis",
                "attachments": [{
                  "color": "danger",
                  "fields": [
                    {"title": "Commit", "value": "${{ github.sha }}", "short": true},
                    {"title": "Job", "value": "${{ github.workflow }}", "short": true}
                  ]
                }]
              }'
          fi          

      - name: Rollback on failure
        if: failure()
        run: |
          echo "⏮️ Rollback en cours..."
          kubectl rollout undo deployment/xpeditis-backend -n xpeditis-prod
          kubectl rollout undo deployment/xpeditis-frontend -n xpeditis-prod
          kubectl rollout status deployment/xpeditis-backend -n xpeditis-prod --timeout=120s
          echo "✅ Rollback terminé"          

Workflow de staging (PR preview) — .github/workflows/staging.yml

# .github/workflows/staging.yml
name: Deploy to Staging

on:
  pull_request:
    branches:
      - main

jobs:
  build-staging:
    name: Build Staging
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'

    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 }}

      - name: Build & Push (staging tag)
        uses: docker/build-push-action@v5
        with:
          context: .
          file: apps/backend/Dockerfile
          push: true
          tags: ghcr.io/${{ github.repository_owner }}/xpeditis-backend:pr-${{ github.event.pull_request.number }}
          build-args: NODE_ENV=staging

      - name: Comment PR
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: '🐳 Image Docker staging buildée : `pr-${{ github.event.pull_request.number }}`'
            })            

Mise à jour des manifests Kubernetes

Alternativement, vous pouvez mettre à jour les fichiers YAML dans Git et les appliquer :

# Dans le workflow CI, mettre à jour le tag d'image dans les manifests
- name: Update image in manifests
  run: |
    IMAGE_TAG="sha-$(echo ${{ github.sha }} | cut -c1-7)"

    # Mettre à jour les fichiers YAML
    sed -i "s|image: ghcr.io/.*/xpeditis-backend:.*|image: ${{ env.IMAGE_BACKEND }}:${IMAGE_TAG}|g" \
      k8s/03-backend-deployment.yaml

    sed -i "s|image: ghcr.io/.*/xpeditis-frontend:.*|image: ${{ env.IMAGE_FRONTEND }}:${IMAGE_TAG}|g" \
      k8s/05-frontend-deployment.yaml

    # Committer les changements (GitOps)
    git config user.name "GitHub Actions"
    git config user.email "actions@github.com"
    git add k8s/
    git commit -m "chore: update image tags to ${IMAGE_TAG} [skip ci]"
    git push

Dockerfile final — Backend

# apps/backend/Dockerfile
FROM node:20-alpine AS deps
RUN apk add --no-cache python3 make g++
WORKDIR /app
COPY package*.json ./
COPY apps/backend/package*.json apps/backend/
RUN npm ci --workspace=apps/backend

FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/backend/node_modules ./apps/backend/node_modules
COPY . .
RUN cd apps/backend && npm run build

FROM node:20-alpine AS runner
RUN apk add --no-cache dumb-init
RUN addgroup -g 1001 -S nodejs && adduser -S nestjs -u 1001
WORKDIR /app
COPY --from=builder --chown=nestjs:nodejs /app/apps/backend/dist ./dist
COPY --from=builder --chown=nestjs:nodejs /app/apps/backend/node_modules ./node_modules
COPY --from=builder --chown=nestjs:nodejs /app/apps/backend/package.json ./
USER nestjs
EXPOSE 4000
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
  CMD wget -qO- http://localhost:4000/api/v1/health || exit 1
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/main.js"]

Dockerfile final — Frontend

# apps/frontend/Dockerfile
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
COPY apps/frontend/package*.json apps/frontend/
RUN npm ci --workspace=apps/frontend

FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/frontend/node_modules ./apps/frontend/node_modules
COPY . .
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_APP_URL
ARG NEXT_PUBLIC_API_PREFIX=api/v1
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \
    NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL \
    NEXT_PUBLIC_API_PREFIX=$NEXT_PUBLIC_API_PREFIX \
    NEXT_TELEMETRY_DISABLED=1
RUN cd apps/frontend && npm run build

FROM node:20-alpine AS runner
RUN apk add --no-cache dumb-init
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
WORKDIR /app
COPY --from=builder --chown=nextjs:nodejs /app/apps/frontend/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/frontend/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/apps/frontend/public ./public
USER nextjs
EXPOSE 3000
ENV NODE_ENV=production PORT=3000 HOSTNAME="0.0.0.0" NEXT_TELEMETRY_DISABLED=1
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]

Test du pipeline en local

# Simuler le build Docker localement
docker build \
  -f apps/backend/Dockerfile \
  -t xpeditis-backend:local \
  .

docker build \
  -f apps/frontend/Dockerfile \
  -t xpeditis-frontend:local \
  --build-arg NEXT_PUBLIC_API_URL=http://localhost:4000 \
  --build-arg NEXT_PUBLIC_APP_URL=http://localhost:3000 \
  .

# Tester l'image backend
docker run --rm -p 4000:4000 \
  -e NODE_ENV=production \
  -e DATABASE_HOST=<votre_db_host> \
  -e DATABASE_USER=xpeditis \
  -e DATABASE_PASSWORD=<password> \
  -e DATABASE_NAME=xpeditis \
  -e REDIS_HOST=<votre_redis_host> \
  -e REDIS_PASSWORD=<password> \
  -e JWT_SECRET=test-secret \
  -e SMTP_HOST=localhost \
  -e SMTP_PORT=25 \
  -e SMTP_USER=test \
  -e SMTP_PASS=test \
  xpeditis-backend:local

curl http://localhost:4000/api/v1/health