diff --git a/.github/workflows/cd-main.yml b/.github/workflows/cd-main.yml new file mode 100644 index 0000000..5912f40 --- /dev/null +++ b/.github/workflows/cd-main.yml @@ -0,0 +1,269 @@ +name: CD Production (Hetzner k3s) + +# Production deployment pipeline — Hetzner k3s cluster. +# +# Flow: +# 1. Promote: re-tag preprod → latest + prod-SHA within Scaleway (no rebuild, no data transfer) +# 2. Deploy: kubectl set image + rollout status (blocks until pods are healthy) +# 3. Auto-rollback: kubectl rollout undo if rollout fails +# 4. Smoke tests: belt-and-suspenders HTTP health checks +# 5. Notify Discord +# +# Required secrets: +# REGISTRY_TOKEN — Scaleway registry token (read + write) +# HETZNER_KUBECONFIG — base64-encoded kubeconfig for xpeditis-prod cluster +# PROD_BACKEND_URL — https://api.xpeditis.com (health check) +# PROD_FRONTEND_URL — https://app.xpeditis.com (health check) +# DISCORD_WEBHOOK_URL — Discord notifications +# +# K8s cluster details (from docs/deployment/hetzner/): +# Namespace: xpeditis-prod +# Deployments: xpeditis-backend (container: backend) +# xpeditis-frontend (container: frontend) +# +on: + push: + branches: [main] + +# Only one prod deployment at a time. Never cancel. +concurrency: + group: cd-production + cancel-in-progress: false + +env: + REGISTRY: rg.fr-par.scw.cloud/weworkstudio + IMAGE_BACKEND: rg.fr-par.scw.cloud/weworkstudio/xpeditis-backend + IMAGE_FRONTEND: rg.fr-par.scw.cloud/weworkstudio/xpeditis-frontend + K8S_NAMESPACE: xpeditis-prod + +jobs: + # ────────────────────────────────────────────────────────────── + # 1. Promote preprod → prod tags within Scaleway + # imagetools create re-tags at manifest level — no layer + # download/upload, instant even for multi-arch images. + # ────────────────────────────────────────────────────────────── + promote-images: + name: Promote Images (preprod → prod) + runs-on: ubuntu-latest + outputs: + short-sha: ${{ steps.sha.outputs.short }} + backend-image: ${{ steps.images.outputs.backend }} + frontend-image: ${{ steps.images.outputs.frontend }} + + steps: + - name: Compute short SHA + id: sha + run: echo "short=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT + + - name: Set image references + id: images + run: | + echo "backend=${{ env.IMAGE_BACKEND }}:prod-${{ steps.sha.outputs.short }}" >> $GITHUB_OUTPUT + echo "frontend=${{ env.IMAGE_FRONTEND }}:prod-${{ steps.sha.outputs.short }}" >> $GITHUB_OUTPUT + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Scaleway Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: nologin + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Promote Backend (preprod → latest + prod-SHA) + run: | + docker buildx imagetools create \ + --tag ${{ env.IMAGE_BACKEND }}:latest \ + --tag ${{ steps.images.outputs.backend }} \ + ${{ env.IMAGE_BACKEND }}:preprod + + - name: Promote Frontend (preprod → latest + prod-SHA) + run: | + docker buildx imagetools create \ + --tag ${{ env.IMAGE_FRONTEND }}:latest \ + --tag ${{ steps.images.outputs.frontend }} \ + ${{ env.IMAGE_FRONTEND }}:preprod + + - name: Verify promoted images + run: | + echo "=== Backend ===" + docker buildx imagetools inspect ${{ steps.images.outputs.backend }} + echo "=== Frontend ===" + docker buildx imagetools inspect ${{ steps.images.outputs.frontend }} + + # ────────────────────────────────────────────────────────────── + # 2. Deploy to Hetzner k3s + # kubectl set image → rollout status waits for pods to be + # healthy (readiness probes pass) before the job succeeds. + # Auto-rollback on failure via kubectl rollout undo. + # ────────────────────────────────────────────────────────────── + deploy: + name: Deploy to k3s (xpeditis-prod) + runs-on: ubuntu-latest + needs: promote-images + environment: + name: production + url: https://app.xpeditis.com + + steps: + - name: Configure kubectl + run: | + mkdir -p ~/.kube + echo "${{ secrets.HETZNER_KUBECONFIG }}" | base64 -d > ~/.kube/config + chmod 600 ~/.kube/config + kubectl cluster-info + kubectl get nodes + + - name: Deploy Backend + run: | + IMAGE="${{ needs.promote-images.outputs.backend-image }}" + echo "Deploying backend: $IMAGE" + kubectl set image deployment/xpeditis-backend \ + backend=$IMAGE \ + -n ${{ env.K8S_NAMESPACE }} + kubectl rollout status deployment/xpeditis-backend \ + -n ${{ env.K8S_NAMESPACE }} \ + --timeout=300s + echo "Backend deployed." + + - name: Deploy Frontend + run: | + IMAGE="${{ needs.promote-images.outputs.frontend-image }}" + echo "Deploying frontend: $IMAGE" + kubectl set image deployment/xpeditis-frontend \ + frontend=$IMAGE \ + -n ${{ env.K8S_NAMESPACE }} + kubectl rollout status deployment/xpeditis-frontend \ + -n ${{ env.K8S_NAMESPACE }} \ + --timeout=300s + echo "Frontend deployed." + + - name: Auto-rollback on failure + if: failure() + run: | + echo "Deployment failed — rolling back..." + kubectl rollout undo deployment/xpeditis-backend -n ${{ env.K8S_NAMESPACE }} || true + kubectl rollout undo deployment/xpeditis-frontend -n ${{ env.K8S_NAMESPACE }} || true + kubectl rollout status deployment/xpeditis-backend -n ${{ env.K8S_NAMESPACE }} --timeout=120s || true + kubectl rollout status deployment/xpeditis-frontend -n ${{ env.K8S_NAMESPACE }} --timeout=120s || true + echo "Previous version restored." + + # ────────────────────────────────────────────────────────────── + # 3. Smoke Tests + # kubectl rollout status already verifies pod readiness. + # These confirm the full network path: + # Cloudflare → Hetzner LB → Traefik → pod. + # ────────────────────────────────────────────────────────────── + smoke-tests: + name: Smoke Tests + runs-on: ubuntu-latest + needs: deploy + + steps: + - name: Wait for LB propagation + run: sleep 30 + + - name: Health check — Backend + run: | + for i in {1..12}; do + STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 \ + "${{ secrets.PROD_BACKEND_URL }}/api/v1/health" 2>/dev/null || echo "000") + echo " Attempt $i: HTTP $STATUS" + if [ "$STATUS" = "200" ]; then echo "Backend healthy."; exit 0; fi + sleep 15 + done + echo "CRITICAL: Backend unreachable after rollout." + exit 1 + + - name: Health check — Frontend + run: | + for i in {1..12}; do + STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 \ + "${{ secrets.PROD_FRONTEND_URL }}" 2>/dev/null || echo "000") + echo " Attempt $i: HTTP $STATUS" + if [ "$STATUS" = "200" ]; then echo "Frontend healthy."; exit 0; fi + sleep 15 + done + echo "CRITICAL: Frontend unreachable after rollout." + exit 1 + + # ────────────────────────────────────────────────────────────── + # 4. Deployment Summary + # ────────────────────────────────────────────────────────────── + summary: + name: Deployment Summary + runs-on: ubuntu-latest + needs: [promote-images, smoke-tests] + if: success() + + steps: + - name: Write summary + run: | + echo "## Production Deployment" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| | |" >> $GITHUB_STEP_SUMMARY + echo "|---|---|" >> $GITHUB_STEP_SUMMARY + echo "| **Commit** | \`${{ github.sha }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Backend** | \`${{ needs.promote-images.outputs.backend-image }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Frontend** | \`${{ needs.promote-images.outputs.frontend-image }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Source** | Promoted from \`preprod\` tag — no rebuild |" >> $GITHUB_STEP_SUMMARY + echo "| **Cluster** | Hetzner k3s — namespace \`xpeditis-prod\` |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "To rollback: [Run rollback workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/rollback.yml)" >> $GITHUB_STEP_SUMMARY + + # ────────────────────────────────────────────────────────────── + # Discord — Success + # ────────────────────────────────────────────────────────────── + notify-success: + name: Notify Success + runs-on: ubuntu-latest + needs: [promote-images, smoke-tests] + if: success() + + steps: + - name: Send Discord notification + run: | + curl -s -H "Content-Type: application/json" -d '{ + "embeds": [{ + "title": "🚀 Production Deployed & Healthy", + "color": 3066993, + "fields": [ + {"name": "Author", "value": "${{ github.actor }}", "inline": true}, + {"name": "Version", "value": "`prod-${{ needs.promote-images.outputs.short-sha }}`", "inline": true}, + {"name": "Commit", "value": "[`${{ github.sha }}`](${{ github.event.head_commit.url }})", "inline": false}, + {"name": "Registry", "value": "Scaleway — promoted from `preprod`, no rebuild", "inline": false}, + {"name": "Cluster", "value": "Hetzner k3s — `xpeditis-prod`", "inline": false}, + {"name": "Workflow", "value": "[${{ github.workflow }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": false} + ], + "footer": {"text": "Xpeditis CI/CD • Production"} + }] + }' ${{ secrets.DISCORD_WEBHOOK_URL }} + + # ────────────────────────────────────────────────────────────── + # Discord — Failure (CRITICAL) + # ────────────────────────────────────────────────────────────── + notify-failure: + name: Notify Failure + runs-on: ubuntu-latest + needs: [promote-images, deploy, smoke-tests] + if: failure() + + steps: + - name: Send Discord notification + run: | + curl -s -H "Content-Type: application/json" -d '{ + "content": "@here PRODUCTION DEPLOYMENT FAILED", + "embeds": [{ + "title": "🔴 PRODUCTION PIPELINE FAILED", + "description": "Auto-rollback was triggered if deployment failed. Check rollout history.", + "color": 15158332, + "fields": [ + {"name": "Author", "value": "${{ github.actor }}", "inline": true}, + {"name": "Commit", "value": "[`${{ github.sha }}`](${{ github.event.head_commit.url }})", "inline": true}, + {"name": "Workflow", "value": "[${{ github.workflow }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": false}, + {"name": "Manual rollback", "value": "[Run rollback workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/rollback.yml)", "inline": false} + ], + "footer": {"text": "Xpeditis CI/CD • Production"} + }] + }' ${{ secrets.DISCORD_WEBHOOK_URL }} diff --git a/.github/workflows/cd-preprod.yml b/.github/workflows/cd-preprod.yml new file mode 100644 index 0000000..0d2e359 --- /dev/null +++ b/.github/workflows/cd-preprod.yml @@ -0,0 +1,397 @@ +name: CD Preprod + +# Full pipeline for the preprod branch. +# Flow: quality → integration tests → Docker build & push → deploy → smoke tests → notify +# +# Required secrets: +# REGISTRY_TOKEN — Scaleway container registry token +# NEXT_PUBLIC_API_URL — Preprod API URL (e.g. https://api.preprod.xpeditis.com) +# NEXT_PUBLIC_APP_URL — Preprod app URL (e.g. https://preprod.xpeditis.com) +# PORTAINER_WEBHOOK_BACKEND — Portainer webhook for preprod backend service +# PORTAINER_WEBHOOK_FRONTEND — Portainer webhook for preprod frontend service +# PREPROD_BACKEND_URL — Health check URL (e.g. https://api.preprod.xpeditis.com) +# PREPROD_FRONTEND_URL — Health check URL (e.g. https://preprod.xpeditis.com) +# DISCORD_WEBHOOK_URL — Discord deployment notifications + +on: + push: + branches: [preprod] + +# Only one preprod deployment at a time. Never cancel an in-progress deployment. +concurrency: + group: cd-preprod + cancel-in-progress: false + +env: + REGISTRY: rg.fr-par.scw.cloud/weworkstudio + NODE_VERSION: '20' + +jobs: + # ────────────────────────────────────────── + # 1. Lint & Type-check + # ────────────────────────────────────────── + quality: + name: Quality (${{ matrix.app }}) + runs-on: ubuntu-latest + strategy: + fail-fast: true + matrix: + app: [backend, frontend] + + defaults: + run: + working-directory: apps/${{ matrix.app }} + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: apps/${{ matrix.app }}/package-lock.json + + - name: Install dependencies + run: npm ci --legacy-peer-deps + + - name: Lint + run: npm run lint + + - name: Type-check (frontend only) + if: matrix.app == 'frontend' + run: npm run type-check + + # ────────────────────────────────────────── + # 2. Unit Tests + # ────────────────────────────────────────── + unit-tests: + name: Unit Tests (${{ matrix.app }}) + runs-on: ubuntu-latest + needs: quality + strategy: + fail-fast: true + matrix: + app: [backend, frontend] + + defaults: + run: + working-directory: apps/${{ matrix.app }} + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: apps/${{ matrix.app }}/package-lock.json + + - name: Install dependencies + run: npm ci --legacy-peer-deps + + - name: Run unit tests + run: npm test -- --passWithNoTests --coverage + + # ────────────────────────────────────────── + # 3. Integration Tests + # ────────────────────────────────────────── + integration-tests: + name: Integration Tests + runs-on: ubuntu-latest + needs: unit-tests + + defaults: + run: + working-directory: apps/backend + + services: + postgres: + image: postgres:15-alpine + env: + POSTGRES_USER: xpeditis_test + POSTGRES_PASSWORD: xpeditis_test_password + POSTGRES_DB: xpeditis_test + options: >- + --health-cmd pg_isready + --health-interval 5s + --health-timeout 5s + --health-retries 10 + ports: + - 5432:5432 + + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + ports: + - 6379:6379 + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: apps/backend/package-lock.json + + - name: Install dependencies + run: npm ci --legacy-peer-deps + + - name: Run integration tests + env: + NODE_ENV: test + DATABASE_HOST: localhost + DATABASE_PORT: 5432 + DATABASE_USER: xpeditis_test + DATABASE_PASSWORD: xpeditis_test_password + DATABASE_NAME: xpeditis_test + DATABASE_SYNCHRONIZE: false + REDIS_HOST: localhost + REDIS_PORT: 6379 + REDIS_PASSWORD: '' + JWT_SECRET: test-secret-key-for-ci-only + SMTP_HOST: localhost + SMTP_PORT: 1025 + SMTP_FROM: test@xpeditis.com + run: npm run test:integration -- --passWithNoTests + + # ────────────────────────────────────────── + # 4a. Docker Build & Push — Backend + # ────────────────────────────────────────── + build-backend: + name: Build & Push Backend + runs-on: ubuntu-latest + needs: integration-tests + outputs: + image-tag: ${{ steps.sha.outputs.short }} + + steps: + - uses: actions/checkout@v4 + + - name: Compute short SHA + id: sha + run: echo "short=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Scaleway Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: nologin + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Build and push Backend image + uses: docker/build-push-action@v5 + with: + context: ./apps/backend + file: ./apps/backend/Dockerfile + push: true + # Tag with branch name AND commit SHA for traceability and prod promotion + tags: | + ${{ env.REGISTRY }}/xpeditis-backend:preprod + ${{ env.REGISTRY }}/xpeditis-backend:preprod-${{ steps.sha.outputs.short }} + cache-from: type=registry,ref=${{ env.REGISTRY }}/xpeditis-backend:buildcache + cache-to: type=registry,ref=${{ env.REGISTRY }}/xpeditis-backend:buildcache,mode=max + platforms: linux/amd64,linux/arm64 + + # ────────────────────────────────────────── + # 4b. Docker Build & Push — Frontend + # ────────────────────────────────────────── + build-frontend: + name: Build & Push Frontend + runs-on: ubuntu-latest + needs: integration-tests + outputs: + image-tag: ${{ steps.sha.outputs.short }} + + steps: + - uses: actions/checkout@v4 + + - name: Compute short SHA + id: sha + run: echo "short=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Scaleway Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: nologin + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Build and push Frontend image + uses: docker/build-push-action@v5 + with: + context: ./apps/frontend + file: ./apps/frontend/Dockerfile + push: true + tags: | + ${{ env.REGISTRY }}/xpeditis-frontend:preprod + ${{ env.REGISTRY }}/xpeditis-frontend:preprod-${{ steps.sha.outputs.short }} + cache-from: type=registry,ref=${{ env.REGISTRY }}/xpeditis-frontend:buildcache + cache-to: type=registry,ref=${{ env.REGISTRY }}/xpeditis-frontend:buildcache,mode=max + platforms: linux/amd64,linux/arm64 + build-args: | + NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }} + NEXT_PUBLIC_APP_URL=${{ secrets.NEXT_PUBLIC_APP_URL }} + + # ────────────────────────────────────────── + # 5. Deploy to Preprod via Portainer + # ────────────────────────────────────────── + deploy: + name: Deploy to Preprod + runs-on: ubuntu-latest + needs: [build-backend, build-frontend] + environment: preprod + + steps: + - name: Trigger Backend deployment + run: | + echo "Deploying backend (preprod-${{ needs.build-backend.outputs.image-tag }})..." + curl -sf -X POST \ + -H "Content-Type: application/json" \ + "${{ secrets.PORTAINER_WEBHOOK_BACKEND }}" + echo "Backend webhook triggered." + + - name: Wait for backend to stabilize + run: sleep 20 + + - name: Trigger Frontend deployment + run: | + echo "Deploying frontend (preprod-${{ needs.build-frontend.outputs.image-tag }})..." + curl -sf -X POST \ + -H "Content-Type: application/json" \ + "${{ secrets.PORTAINER_WEBHOOK_FRONTEND }}" + echo "Frontend webhook triggered." + + # ────────────────────────────────────────── + # 6. Smoke Tests — verify preprod is healthy + # ────────────────────────────────────────── + smoke-tests: + name: Smoke Tests + runs-on: ubuntu-latest + needs: deploy + + steps: + - name: Wait for services to start + run: sleep 40 + + - name: Health check — Backend + run: | + echo "Checking backend health..." + for i in {1..10}; do + STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + --max-time 10 \ + "${{ secrets.PREPROD_BACKEND_URL }}/health" 2>/dev/null || echo "000") + echo " Attempt $i: HTTP $STATUS" + if [ "$STATUS" = "200" ]; then + echo "Backend is healthy." + exit 0 + fi + sleep 15 + done + echo "Backend health check failed after 10 attempts." + exit 1 + + - name: Health check — Frontend + run: | + echo "Checking frontend health..." + for i in {1..10}; do + STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + --max-time 10 \ + "${{ secrets.PREPROD_FRONTEND_URL }}" 2>/dev/null || echo "000") + echo " Attempt $i: HTTP $STATUS" + if [ "$STATUS" = "200" ]; then + echo "Frontend is healthy." + exit 0 + fi + sleep 15 + done + echo "Frontend health check failed after 10 attempts." + exit 1 + + # ────────────────────────────────────────── + # 7. Deployment Summary + # ────────────────────────────────────────── + summary: + name: Deployment Summary + runs-on: ubuntu-latest + needs: [build-backend, build-frontend, smoke-tests] + if: success() + + steps: + - name: Write summary + run: | + echo "## Preprod Deployment" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| | |" >> $GITHUB_STEP_SUMMARY + echo "|---|---|" >> $GITHUB_STEP_SUMMARY + echo "| **Commit** | \`${{ github.sha }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Backend image** | \`${{ env.REGISTRY }}/xpeditis-backend:preprod-${{ needs.build-backend.outputs.image-tag }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Frontend image** | \`${{ env.REGISTRY }}/xpeditis-frontend:preprod-${{ needs.build-frontend.outputs.image-tag }}\` |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "To promote this exact build to production, merge this commit to \`main\`." >> $GITHUB_STEP_SUMMARY + + # ────────────────────────────────────────── + # Discord — Success + # ────────────────────────────────────────── + notify-success: + name: Notify Success + runs-on: ubuntu-latest + needs: [build-backend, build-frontend, smoke-tests] + if: success() + + steps: + - name: Send Discord notification + run: | + curl -s -H "Content-Type: application/json" -d '{ + "embeds": [{ + "title": "✅ Preprod Deployed & Healthy", + "color": 3066993, + "fields": [ + {"name": "Author", "value": "${{ github.actor }}", "inline": true}, + {"name": "Commit", "value": "[`${{ github.sha }}`](${{ github.event.head_commit.url }})", "inline": true}, + {"name": "Backend", "value": "`preprod-${{ needs.build-backend.outputs.image-tag }}`", "inline": false}, + {"name": "Frontend", "value": "`preprod-${{ needs.build-frontend.outputs.image-tag }}`", "inline": false}, + {"name": "Workflow", "value": "[${{ github.workflow }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": false} + ], + "footer": {"text": "Xpeditis CI/CD • Preprod"} + }] + }' ${{ secrets.DISCORD_WEBHOOK_URL }} + + # ────────────────────────────────────────── + # Discord — Failure + # ────────────────────────────────────────── + notify-failure: + name: Notify Failure + runs-on: ubuntu-latest + needs: [quality, unit-tests, integration-tests, build-backend, build-frontend, deploy, smoke-tests] + if: failure() + + steps: + - name: Send Discord notification + run: | + curl -s -H "Content-Type: application/json" -d '{ + "embeds": [{ + "title": "❌ Preprod Pipeline Failed", + "description": "Preprod was NOT deployed. Fix the issue before retrying.", + "color": 15158332, + "fields": [ + {"name": "Author", "value": "${{ github.actor }}", "inline": true}, + {"name": "Commit", "value": "[`${{ github.sha }}`](${{ github.event.head_commit.url }})", "inline": true}, + {"name": "Workflow", "value": "[${{ github.workflow }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": false} + ], + "footer": {"text": "Xpeditis CI/CD • Preprod"} + }] + }' ${{ secrets.DISCORD_WEBHOOK_URL }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee25f46..4a13643 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,372 +1,112 @@ -name: CI/CD Pipeline - -on: - push: - branches: - - preprod - -env: - REGISTRY: rg.fr-par.scw.cloud/weworkstudio - NODE_VERSION: '20' - -jobs: - # ============================================ - # Backend Build, Test & Deploy - # ============================================ - backend: - name: Backend - Build, Test & Push - runs-on: ubuntu-latest - defaults: - run: - working-directory: apps/backend - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - - - name: Install dependencies - run: npm install --legacy-peer-deps - - - name: Lint code - run: npm run lint - - - name: Run unit tests - run: npm test -- --coverage --passWithNoTests - - - name: Build application - run: npm run build - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to Scaleway Registry - uses: docker/login-action@v3 - with: - registry: rg.fr-par.scw.cloud/weworkstudio - username: nologin - password: ${{ secrets.REGISTRY_TOKEN }} - - - name: Extract metadata for Docker - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/xpeditis-backend - tags: | - type=ref,event=branch - type=raw,value=latest,enable={{is_default_branch}} - - - name: Build and push Backend Docker image - uses: docker/build-push-action@v5 - with: - context: ./apps/backend - file: ./apps/backend/Dockerfile - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=registry,ref=${{ env.REGISTRY }}/xpeditis-backend:buildcache - cache-to: type=registry,ref=${{ env.REGISTRY }}/xpeditis-backend:buildcache,mode=max - platforms: linux/amd64,linux/arm64 - - # ============================================ - # Frontend Build, Test & Deploy - # ============================================ - frontend: - name: Frontend - Build, Test & Push - runs-on: ubuntu-latest - defaults: - run: - working-directory: apps/frontend - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - cache: 'npm' - cache-dependency-path: apps/frontend/package-lock.json - - - name: Install dependencies - run: npm ci --legacy-peer-deps - - - name: Lint code - run: npm run lint - - - name: Run tests - run: npm test -- --passWithNoTests || echo "No tests found" - - - name: Build application - env: - NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL || 'http://localhost:4000' }} - NEXT_PUBLIC_APP_URL: ${{ secrets.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' }} - NEXT_TELEMETRY_DISABLED: 1 - run: npm run build - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to Scaleway Registry - uses: docker/login-action@v3 - with: - registry: rg.fr-par.scw.cloud/weworkstudio - username: nologin - password: ${{ secrets.REGISTRY_TOKEN }} - - - name: Extract metadata for Docker - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/xpeditis-frontend - tags: | - type=ref,event=branch - type=raw,value=latest,enable={{is_default_branch}} - - - name: Build and push Frontend Docker image - uses: docker/build-push-action@v5 - with: - context: ./apps/frontend - file: ./apps/frontend/Dockerfile - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=registry,ref=${{ env.REGISTRY }}/xpeditis-frontend:buildcache - cache-to: type=registry,ref=${{ env.REGISTRY }}/xpeditis-frontend:buildcache,mode=max - platforms: linux/amd64,linux/arm64 - build-args: | - NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL || 'http://localhost:4000' }} - NEXT_PUBLIC_APP_URL=${{ secrets.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' }} - - # ============================================ - # Integration Tests (Optional) - # ============================================ - integration-tests: - name: Integration Tests - runs-on: ubuntu-latest - needs: [backend, frontend] - if: github.event_name == 'pull_request' - defaults: - run: - working-directory: apps/backend - - services: - postgres: - image: postgres:15-alpine - env: - POSTGRES_USER: xpeditis - POSTGRES_PASSWORD: xpeditis_dev_password - POSTGRES_DB: xpeditis_test - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 - - redis: - image: redis:7-alpine - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 6379:6379 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - - - name: Install dependencies - run: npm install --legacy-peer-deps - - - name: Run integration tests - env: - DATABASE_HOST: localhost - DATABASE_PORT: 5432 - DATABASE_USER: xpeditis - DATABASE_PASSWORD: xpeditis_dev_password - DATABASE_NAME: xpeditis_test - REDIS_HOST: localhost - REDIS_PORT: 6379 - JWT_SECRET: test-secret-key-for-ci - run: npm run test:integration || echo "No integration tests found" - - # ============================================ - # Deployment Summary - # ============================================ - deployment-summary: - name: Deployment Summary - runs-on: ubuntu-latest - needs: [backend, frontend] - if: success() - - steps: - - name: Summary - run: | - echo "## 🚀 Deployment Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Backend Image" >> $GITHUB_STEP_SUMMARY - echo "- Registry: \`${{ env.REGISTRY }}/xpeditis-backend\`" >> $GITHUB_STEP_SUMMARY - echo "- Branch: \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY - echo "- Commit: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Frontend Image" >> $GITHUB_STEP_SUMMARY - echo "- Registry: \`${{ env.REGISTRY }}/xpeditis-frontend\`" >> $GITHUB_STEP_SUMMARY - echo "- Branch: \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY - echo "- Commit: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Pull Commands" >> $GITHUB_STEP_SUMMARY - echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY - echo "docker pull ${{ env.REGISTRY }}/xpeditis-backend:${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY - echo "docker pull ${{ env.REGISTRY }}/xpeditis-frontend:${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - - # ============================================ - # Deploy to Portainer via Webhooks - # ============================================ - deploy-portainer: - name: Deploy to Portainer - runs-on: ubuntu-latest - needs: [backend, frontend] - if: success() && github.ref == 'refs/heads/preprod' - - steps: - - name: Trigger Backend Webhook - run: | - echo "🚀 Deploying Backend to Portainer..." - curl -X POST \ - -H "Content-Type: application/json" \ - -d '{"data": "backend-deployment"}' \ - ${{ secrets.PORTAINER_WEBHOOK_BACKEND }} - echo "✅ Backend webhook triggered" - - - name: Wait before Frontend deployment - run: sleep 10 - - - name: Trigger Frontend Webhook - run: | - echo "🚀 Deploying Frontend to Portainer..." - curl -X POST \ - -H "Content-Type: application/json" \ - -d '{"data": "frontend-deployment"}' \ - ${{ secrets.PORTAINER_WEBHOOK_FRONTEND }} - echo "✅ Frontend webhook triggered" - - # ============================================ - # Discord Notification - Success - # ============================================ - notify-success: - name: Discord Notification (Success) - runs-on: ubuntu-latest - needs: [backend, frontend, deploy-portainer] - if: success() - - steps: - - name: Send Discord notification - run: | - curl -H "Content-Type: application/json" \ - -d '{ - "embeds": [{ - "title": "✅ CI/CD Pipeline Success", - "description": "Deployment completed successfully!", - "color": 3066993, - "fields": [ - { - "name": "Repository", - "value": "${{ github.repository }}", - "inline": true - }, - { - "name": "Branch", - "value": "${{ github.ref_name }}", - "inline": true - }, - { - "name": "Commit", - "value": "[${{ github.sha }}](${{ github.event.head_commit.url }})", - "inline": false - }, - { - "name": "Backend Image", - "value": "`${{ env.REGISTRY }}/xpeditis-backend:${{ github.ref_name }}`", - "inline": false - }, - { - "name": "Frontend Image", - "value": "`${{ env.REGISTRY }}/xpeditis-frontend:${{ github.ref_name }}`", - "inline": false - }, - { - "name": "Workflow", - "value": "[${{ github.workflow }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", - "inline": false - } - ], - "timestamp": "${{ github.event.head_commit.timestamp }}", - "footer": { - "text": "Xpeditis CI/CD" - } - }] - }' \ - ${{ secrets.DISCORD_WEBHOOK_URL }} - - # ============================================ - # Discord Notification - Failure - # ============================================ - notify-failure: - name: Discord Notification (Failure) - runs-on: ubuntu-latest - needs: [backend, frontend, deploy-portainer] - if: failure() - - steps: - - name: Send Discord notification - run: | - curl -H "Content-Type: application/json" \ - -d '{ - "embeds": [{ - "title": "❌ CI/CD Pipeline Failed", - "description": "Deployment failed! Check the logs for details.", - "color": 15158332, - "fields": [ - { - "name": "Repository", - "value": "${{ github.repository }}", - "inline": true - }, - { - "name": "Branch", - "value": "${{ github.ref_name }}", - "inline": true - }, - { - "name": "Commit", - "value": "[${{ github.sha }}](${{ github.event.head_commit.url }})", - "inline": false - }, - { - "name": "Workflow", - "value": "[${{ github.workflow }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", - "inline": false - } - ], - "timestamp": "${{ github.event.head_commit.timestamp }}", - "footer": { - "text": "Xpeditis CI/CD" - } - }] - }' \ - ${{ secrets.DISCORD_WEBHOOK_URL }} +name: Dev CI + +# Fast feedback loop for the dev branch. +# Runs lint + unit tests only — no Docker build, no deployment. + +on: + push: + branches: [dev] + pull_request: + branches: [dev] + +concurrency: + group: dev-ci-${{ github.ref }} + cancel-in-progress: true + +env: + NODE_VERSION: '20' + +jobs: + # ────────────────────────────────────────── + # Lint & Type-check + # ────────────────────────────────────────── + quality: + name: Quality (${{ matrix.app }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + app: [backend, frontend] + + defaults: + run: + working-directory: apps/${{ matrix.app }} + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: apps/${{ matrix.app }}/package-lock.json + + - name: Install dependencies + run: npm ci --legacy-peer-deps + + - name: Lint + run: npm run lint + + - name: Type-check (frontend only) + if: matrix.app == 'frontend' + run: npm run type-check + + # ────────────────────────────────────────── + # Unit Tests + # ────────────────────────────────────────── + unit-tests: + name: Unit Tests (${{ matrix.app }}) + runs-on: ubuntu-latest + needs: quality + strategy: + fail-fast: false + matrix: + app: [backend, frontend] + + defaults: + run: + working-directory: apps/${{ matrix.app }} + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: apps/${{ matrix.app }}/package-lock.json + + - name: Install dependencies + run: npm ci --legacy-peer-deps + + - name: Run unit tests + run: npm test -- --passWithNoTests --coverage + + # ────────────────────────────────────────── + # Discord notification on failure + # ────────────────────────────────────────── + notify-failure: + name: Notify Failure + runs-on: ubuntu-latest + needs: [quality, unit-tests] + if: failure() + + steps: + - name: Send Discord notification + run: | + curl -s -H "Content-Type: application/json" -d '{ + "embeds": [{ + "title": "❌ Dev CI Failed", + "description": "Fix the issues before merging to preprod.", + "color": 15158332, + "fields": [ + {"name": "Branch", "value": "`${{ github.ref_name }}`", "inline": true}, + {"name": "Author", "value": "${{ github.actor }}", "inline": true}, + {"name": "Commit", "value": "[`${{ github.sha }}`](${{ github.event.head_commit.url }})", "inline": false}, + {"name": "Workflow", "value": "[${{ github.workflow }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": false} + ], + "footer": {"text": "Xpeditis CI/CD"} + }] + }' ${{ secrets.DISCORD_WEBHOOK_URL }} diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000..6b894d3 --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,176 @@ +name: PR Checks + +# Validation gate for pull requests. +# PRs to preprod → lint + unit tests + integration tests +# PRs to main → lint + unit tests only (code was integration-tested in preprod already) +# +# Configure these as required status checks in GitHub branch protection rules. + +on: + pull_request: + branches: [preprod, main] + +concurrency: + group: pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +env: + NODE_VERSION: '20' + +jobs: + # ────────────────────────────────────────── + # Lint & Type-check (both apps, parallel) + # ────────────────────────────────────────── + quality: + name: Quality (${{ matrix.app }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + app: [backend, frontend] + + defaults: + run: + working-directory: apps/${{ matrix.app }} + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: apps/${{ matrix.app }}/package-lock.json + + - name: Install dependencies + run: npm ci --legacy-peer-deps + + - name: Lint + run: npm run lint + + - name: Type-check (frontend only) + if: matrix.app == 'frontend' + run: npm run type-check + + # ────────────────────────────────────────── + # Unit Tests (both apps, parallel) + # ────────────────────────────────────────── + unit-tests: + name: Unit Tests (${{ matrix.app }}) + runs-on: ubuntu-latest + needs: quality + strategy: + fail-fast: false + matrix: + app: [backend, frontend] + + defaults: + run: + working-directory: apps/${{ matrix.app }} + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: apps/${{ matrix.app }}/package-lock.json + + - name: Install dependencies + run: npm ci --legacy-peer-deps + + - name: Run unit tests + run: npm test -- --passWithNoTests --coverage + + # ────────────────────────────────────────── + # Integration Tests — PRs to preprod only + # ────────────────────────────────────────── + integration-tests: + name: Integration Tests + runs-on: ubuntu-latest + needs: unit-tests + if: github.base_ref == 'preprod' + + defaults: + run: + working-directory: apps/backend + + services: + postgres: + image: postgres:15-alpine + env: + POSTGRES_USER: xpeditis_test + POSTGRES_PASSWORD: xpeditis_test_password + POSTGRES_DB: xpeditis_test + options: >- + --health-cmd pg_isready + --health-interval 5s + --health-timeout 5s + --health-retries 10 + ports: + - 5432:5432 + + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + ports: + - 6379:6379 + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: apps/backend/package-lock.json + + - name: Install dependencies + run: npm ci --legacy-peer-deps + + - name: Run integration tests + env: + NODE_ENV: test + DATABASE_HOST: localhost + DATABASE_PORT: 5432 + DATABASE_USER: xpeditis_test + DATABASE_PASSWORD: xpeditis_test_password + DATABASE_NAME: xpeditis_test + DATABASE_SYNCHRONIZE: false + REDIS_HOST: localhost + REDIS_PORT: 6379 + REDIS_PASSWORD: '' + JWT_SECRET: test-secret-key-for-ci-only + SMTP_HOST: localhost + SMTP_PORT: 1025 + SMTP_FROM: test@xpeditis.com + run: npm run test:integration -- --passWithNoTests + + # ────────────────────────────────────────── + # PR Summary + # ────────────────────────────────────────── + pr-summary: + name: PR Summary + runs-on: ubuntu-latest + needs: [quality, unit-tests] + if: always() + + steps: + - name: Write job summary + run: | + echo "## PR Check Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Check | Status |" >> $GITHUB_STEP_SUMMARY + echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY + echo "| Quality (lint + type-check) | ${{ needs.quality.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Unit Tests | ${{ needs.unit-tests.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Target Branch | \`${{ github.base_ref }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Author | ${{ github.actor }} |" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/rollback.yml b/.github/workflows/rollback.yml new file mode 100644 index 0000000..ea82bd9 --- /dev/null +++ b/.github/workflows/rollback.yml @@ -0,0 +1,317 @@ +name: Rollback + +# Emergency rollback for production (Hetzner k3s) and preprod (Portainer). +# All images are on Scaleway registry. +# +# Production (k3s): +# Option A — "previous": kubectl rollout undo (instant, reverts to last ReplicaSet) +# Option B — "specific-version": kubectl set image to a Scaleway prod-SHA tag +# +# Preprod (Portainer): +# Re-tags a Scaleway preprod-SHA image back to :preprod, triggers Portainer webhook. +# +# Run from: GitHub Actions → Workflows → Rollback → Run workflow + +on: + workflow_dispatch: + inputs: + environment: + description: 'Target environment' + required: true + type: choice + options: + - production + - preprod + strategy: + description: 'Rollback strategy ("previous" = kubectl rollout undo, prod only)' + required: true + type: choice + options: + - previous + - specific-version + version_tag: + description: 'Tag for specific-version (e.g. prod-a1b2c3d or preprod-a1b2c3d)' + required: false + type: string + reason: + description: 'Reason for rollback (audit trail)' + required: true + type: string + +env: + REGISTRY: rg.fr-par.scw.cloud/weworkstudio + IMAGE_BACKEND: rg.fr-par.scw.cloud/weworkstudio/xpeditis-backend + IMAGE_FRONTEND: rg.fr-par.scw.cloud/weworkstudio/xpeditis-frontend + K8S_NAMESPACE: xpeditis-prod + +jobs: + # ────────────────────────────────────────── + # Validate inputs + # ────────────────────────────────────────── + validate: + name: Validate Inputs + runs-on: ubuntu-latest + + steps: + - name: Validate + run: | + ENV="${{ github.event.inputs.environment }}" + STRATEGY="${{ github.event.inputs.strategy }}" + TAG="${{ github.event.inputs.version_tag }}" + + if [ "$STRATEGY" = "specific-version" ] && [ -z "$TAG" ]; then + echo "ERROR: version_tag is required for specific-version strategy." + exit 1 + fi + + if [ "$STRATEGY" = "specific-version" ] && [ "$ENV" = "production" ]; then + if [[ ! "$TAG" =~ ^prod- ]]; then + echo "ERROR: Production tag must start with 'prod-' (got: $TAG)" + exit 1 + fi + fi + + if [ "$STRATEGY" = "specific-version" ] && [ "$ENV" = "preprod" ]; then + if [[ ! "$TAG" =~ ^preprod- ]]; then + echo "ERROR: Preprod tag must start with 'preprod-' (got: $TAG)" + exit 1 + fi + fi + + echo "Validation passed." + echo " Environment : $ENV" + echo " Strategy : $STRATEGY" + echo " Version : ${TAG:-N/A (previous)}" + echo " Reason : ${{ github.event.inputs.reason }}" + + # ────────────────────────────────────────── + # PRODUCTION ROLLBACK — k3s via kubectl + # ────────────────────────────────────────── + rollback-production: + name: Rollback Production (k3s) + runs-on: ubuntu-latest + needs: validate + if: github.event.inputs.environment == 'production' + environment: + name: production + url: https://app.xpeditis.com + + steps: + - name: Configure kubectl + run: | + mkdir -p ~/.kube + echo "${{ secrets.HETZNER_KUBECONFIG }}" | base64 -d > ~/.kube/config + chmod 600 ~/.kube/config + kubectl cluster-info + + # ── Strategy A: kubectl rollout undo (fastest) + - name: Rollback to previous version + if: github.event.inputs.strategy == 'previous' + run: | + echo "Rolling back backend..." + kubectl rollout undo deployment/xpeditis-backend -n ${{ env.K8S_NAMESPACE }} + kubectl rollout status deployment/xpeditis-backend -n ${{ env.K8S_NAMESPACE }} --timeout=180s + + echo "Rolling back frontend..." + kubectl rollout undo deployment/xpeditis-frontend -n ${{ env.K8S_NAMESPACE }} + kubectl rollout status deployment/xpeditis-frontend -n ${{ env.K8S_NAMESPACE }} --timeout=180s + + kubectl get pods -n ${{ env.K8S_NAMESPACE }} + + # ── Strategy B: kubectl set image to specific Scaleway tag + - name: Set up Docker Buildx (for image inspect) + if: github.event.inputs.strategy == 'specific-version' + uses: docker/setup-buildx-action@v3 + + - name: Login to Scaleway Registry + if: github.event.inputs.strategy == 'specific-version' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: nologin + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Rollback to specific version + if: github.event.inputs.strategy == 'specific-version' + run: | + TAG="${{ github.event.inputs.version_tag }}" + BACKEND_IMAGE="${{ env.IMAGE_BACKEND }}:${TAG}" + FRONTEND_IMAGE="${{ env.IMAGE_FRONTEND }}:${TAG}" + + echo "Verifying images exist in Scaleway..." + docker buildx imagetools inspect "$BACKEND_IMAGE" || \ + { echo "ERROR: Backend image not found: $BACKEND_IMAGE"; exit 1; } + docker buildx imagetools inspect "$FRONTEND_IMAGE" || \ + { echo "ERROR: Frontend image not found: $FRONTEND_IMAGE"; exit 1; } + + echo "Deploying backend: $BACKEND_IMAGE" + kubectl set image deployment/xpeditis-backend backend="$BACKEND_IMAGE" -n ${{ env.K8S_NAMESPACE }} + kubectl rollout status deployment/xpeditis-backend -n ${{ env.K8S_NAMESPACE }} --timeout=180s + + echo "Deploying frontend: $FRONTEND_IMAGE" + kubectl set image deployment/xpeditis-frontend frontend="$FRONTEND_IMAGE" -n ${{ env.K8S_NAMESPACE }} + kubectl rollout status deployment/xpeditis-frontend -n ${{ env.K8S_NAMESPACE }} --timeout=180s + + kubectl get pods -n ${{ env.K8S_NAMESPACE }} + + - name: Show rollout history + if: always() + run: | + echo "=== Backend rollout history ===" + kubectl rollout history deployment/xpeditis-backend -n ${{ env.K8S_NAMESPACE }} || true + echo "=== Frontend rollout history ===" + kubectl rollout history deployment/xpeditis-frontend -n ${{ env.K8S_NAMESPACE }} || true + + # ────────────────────────────────────────── + # PREPROD ROLLBACK — Scaleway re-tag + Portainer + # ────────────────────────────────────────── + rollback-preprod: + name: Rollback Preprod (Portainer) + runs-on: ubuntu-latest + needs: validate + if: github.event.inputs.environment == 'preprod' + + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Scaleway Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: nologin + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Verify target images exist + run: | + TAG="${{ github.event.inputs.version_tag }}" + docker buildx imagetools inspect "${{ env.IMAGE_BACKEND }}:${TAG}" || \ + { echo "ERROR: Backend image not found: $TAG"; exit 1; } + docker buildx imagetools inspect "${{ env.IMAGE_FRONTEND }}:${TAG}" || \ + { echo "ERROR: Frontend image not found: $TAG"; exit 1; } + + - name: Re-tag target version as preprod + run: | + TAG="${{ github.event.inputs.version_tag }}" + echo "Re-tagging $TAG → preprod..." + docker buildx imagetools create \ + --tag ${{ env.IMAGE_BACKEND }}:preprod \ + ${{ env.IMAGE_BACKEND }}:${TAG} + docker buildx imagetools create \ + --tag ${{ env.IMAGE_FRONTEND }}:preprod \ + ${{ env.IMAGE_FRONTEND }}:${TAG} + echo "Re-tag complete." + + - name: Trigger Backend deployment (Portainer) + run: | + curl -sf -X POST -H "Content-Type: application/json" \ + "${{ secrets.PORTAINER_WEBHOOK_BACKEND }}" + echo "Backend webhook triggered." + + - name: Wait for backend + run: sleep 20 + + - name: Trigger Frontend deployment (Portainer) + run: | + curl -sf -X POST -H "Content-Type: application/json" \ + "${{ secrets.PORTAINER_WEBHOOK_FRONTEND }}" + echo "Frontend webhook triggered." + + # ────────────────────────────────────────── + # Smoke Tests + # ────────────────────────────────────────── + smoke-tests: + name: Smoke Tests + runs-on: ubuntu-latest + needs: [rollback-production, rollback-preprod] + if: always() && (needs.rollback-production.result == 'success' || needs.rollback-preprod.result == 'success') + + steps: + - name: Set health check URLs + id: urls + run: | + if [ "${{ github.event.inputs.environment }}" = "production" ]; then + echo "backend=${{ secrets.PROD_BACKEND_URL }}/api/v1/health" >> $GITHUB_OUTPUT + echo "frontend=${{ secrets.PROD_FRONTEND_URL }}" >> $GITHUB_OUTPUT + echo "wait=30" >> $GITHUB_OUTPUT + else + echo "backend=${{ secrets.PREPROD_BACKEND_URL }}/api/v1/health" >> $GITHUB_OUTPUT + echo "frontend=${{ secrets.PREPROD_FRONTEND_URL }}" >> $GITHUB_OUTPUT + echo "wait=60" >> $GITHUB_OUTPUT + fi + + - name: Wait for services + run: sleep ${{ steps.urls.outputs.wait }} + + - name: Health check — Backend + run: | + for i in {1..12}; do + STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 \ + "${{ steps.urls.outputs.backend }}" 2>/dev/null || echo "000") + echo " Attempt $i: HTTP $STATUS" + if [ "$STATUS" = "200" ]; then echo "Backend healthy."; exit 0; fi + sleep 15 + done + echo "CRITICAL: Backend unhealthy after rollback." + exit 1 + + - name: Health check — Frontend + run: | + for i in {1..12}; do + STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 \ + "${{ steps.urls.outputs.frontend }}" 2>/dev/null || echo "000") + echo " Attempt $i: HTTP $STATUS" + if [ "$STATUS" = "200" ]; then echo "Frontend healthy."; exit 0; fi + sleep 15 + done + echo "CRITICAL: Frontend unhealthy after rollback." + exit 1 + + # ────────────────────────────────────────── + # Discord notification + # ────────────────────────────────────────── + notify: + name: Notify Rollback Result + runs-on: ubuntu-latest + needs: [rollback-production, rollback-preprod, smoke-tests] + if: always() + + steps: + - name: Notify success + if: needs.smoke-tests.result == 'success' + run: | + curl -s -H "Content-Type: application/json" -d '{ + "embeds": [{ + "title": "↩️ Rollback Successful", + "color": 16776960, + "fields": [ + {"name": "Environment", "value": "`${{ github.event.inputs.environment }}`", "inline": true}, + {"name": "Strategy", "value": "`${{ github.event.inputs.strategy }}`", "inline": true}, + {"name": "Version", "value": "`${{ github.event.inputs.version_tag || 'previous' }}`", "inline": true}, + {"name": "Triggered by", "value": "${{ github.actor }}", "inline": true}, + {"name": "Reason", "value": "${{ github.event.inputs.reason }}", "inline": false}, + {"name": "Workflow", "value": "[${{ github.workflow }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": false} + ], + "footer": {"text": "Xpeditis CI/CD • Rollback"} + }] + }' ${{ secrets.DISCORD_WEBHOOK_URL }} + + - name: Notify failure + if: needs.smoke-tests.result != 'success' + run: | + curl -s -H "Content-Type: application/json" -d '{ + "content": "@here ROLLBACK FAILED — MANUAL INTERVENTION REQUIRED", + "embeds": [{ + "title": "🔴 Rollback Failed", + "description": "Service may be degraded. Escalate immediately.", + "color": 15158332, + "fields": [ + {"name": "Environment", "value": "`${{ github.event.inputs.environment }}`", "inline": true}, + {"name": "Attempted version", "value": "`${{ github.event.inputs.version_tag || 'previous' }}`", "inline": true}, + {"name": "Triggered by", "value": "${{ github.actor }}", "inline": true}, + {"name": "Reason", "value": "${{ github.event.inputs.reason }}", "inline": false}, + {"name": "Workflow", "value": "[${{ github.workflow }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": false} + ], + "footer": {"text": "Xpeditis CI/CD • Rollback"} + }] + }' ${{ secrets.DISCORD_WEBHOOK_URL }} diff --git a/docs/deployment/hetzner/09-kubernetes-manifests.md b/docs/deployment/hetzner/09-kubernetes-manifests.md index 2fa4dfe..d2a9508 100644 --- a/docs/deployment/hetzner/09-kubernetes-manifests.md +++ b/docs/deployment/hetzner/09-kubernetes-manifests.md @@ -327,7 +327,7 @@ spec: # Pull depuis GHCR (GitHub Container Registry) imagePullSecrets: - - name: ghcr-credentials + - name: scaleway-registry # Redémarrage automatique restartPolicy: Always @@ -441,7 +441,7 @@ spec: command: ["/bin/sh", "-c", "sleep 5"] imagePullSecrets: - - name: ghcr-credentials + - name: scaleway-registry restartPolicy: Always ``` @@ -678,20 +678,18 @@ spec: --- -## Secret GHCR (GitHub Container Registry) +## Secret Scaleway Container Registry -Pour que Kubernetes puisse pull les images depuis GHCR : +Pour que Kubernetes puisse pull les images depuis le registry Scaleway : ```bash -# Créer un Personal Access Token GitHub avec scope: read:packages -# https://github.com/settings/tokens/new +# REGISTRY_TOKEN = token Scaleway (Settings → API Keys → Container Registry) -kubectl create secret docker-registry ghcr-credentials \ +kubectl create secret docker-registry scaleway-registry \ --namespace xpeditis-prod \ - --docker-server=ghcr.io \ - --docker-username= \ - --docker-password= \ - --docker-email= + --docker-server=rg.fr-par.scw.cloud \ + --docker-username=nologin \ + --docker-password= ``` --- @@ -757,7 +755,7 @@ spec: - secretRef: name: backend-secrets imagePullSecrets: - - name: ghcr-credentials + - name: scaleway-registry EOF kubectl apply -f /tmp/migration-job.yaml diff --git a/docs/deployment/hetzner/13-backup-disaster-recovery.md b/docs/deployment/hetzner/13-backup-disaster-recovery.md index 9b62355..3ac5599 100644 --- a/docs/deployment/hetzner/13-backup-disaster-recovery.md +++ b/docs/deployment/hetzner/13-backup-disaster-recovery.md @@ -279,14 +279,14 @@ kubectl apply -f k8s/00-namespaces.yaml gpg --decrypt "$HOME/.xpeditis-secrets-backup/k8s-secrets-XXXXXXXX.tar.gz.gpg" | tar xzf - kubectl apply -f /tmp/backend-secrets-*.yaml kubectl apply -f /tmp/frontend-secrets-*.yaml -kubectl apply -f /tmp/ghcr-creds-*.yaml +kubectl apply -f /tmp/scaleway-creds-*.yaml -# Recréer le secret GHCR -kubectl create secret docker-registry ghcr-credentials \ +# Recréer le secret Scaleway +kubectl create secret docker-registry scaleway-registry \ --namespace xpeditis-prod \ - --docker-server=ghcr.io \ - --docker-username= \ - --docker-password= + --docker-server=rg.fr-par.scw.cloud \ + --docker-username=nologin \ + --docker-password= ``` ### Étape 3 : Restaurer les services (15 min)