From ce8a1049dde1cd51947c6416814f6518537db14c Mon Sep 17 00:00:00 2001 From: David Date: Sat, 4 Apr 2026 13:16:48 +0200 Subject: [PATCH] fix(cicd): sync corrected pipelines from cicd branch --- .github/workflows/cd-main.yml | 365 +++++++++++++++++-------------- .github/workflows/cd-preprod.yml | 305 +++++++++----------------- .github/workflows/ci.yml | 111 +++++----- .github/workflows/pr-checks.yml | 147 +++++-------- .github/workflows/rollback.yml | 204 +++++++---------- 5 files changed, 499 insertions(+), 633 deletions(-) diff --git a/.github/workflows/cd-main.yml b/.github/workflows/cd-main.yml index 5912f40..f8f5236 100644 --- a/.github/workflows/cd-main.yml +++ b/.github/workflows/cd-main.yml @@ -1,111 +1,197 @@ -name: CD Production (Hetzner k3s) +name: CD Production -# Production deployment pipeline — Hetzner k3s cluster. +# Production pipeline — Hetzner k3s. # -# 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 +# SECURITY: Two mandatory gates before any production deployment: +# 1. quality-gate — lint + unit tests on the exact commit being deployed +# 2. verify-image — confirms preprod-SHA image EXISTS in registry, +# which proves this commit passed the full preprod +# pipeline (lint + unit + integration + docker build). +# If someone merges to main without going through preprod, +# this step fails and the deployment is blocked. # -# 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) +# Flow: quality-gate → verify-image → promote → deploy → smoke-tests → notify # +# Secrets required: +# REGISTRY_TOKEN — Scaleway registry (read/write) +# HETZNER_KUBECONFIG — base64: cat ~/.kube/kubeconfig-xpeditis-prod | base64 -w 0 +# PROD_BACKEND_URL — https://api.xpeditis.com +# PROD_FRONTEND_URL — https://app.xpeditis.com +# DISCORD_WEBHOOK_URL + 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 + NODE_VERSION: '20' 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) + # ── 1. Quality Gate ────────────────────────────────────────────────── + # Runs on every prod deployment regardless of what happened in preprod. + backend-quality: + name: Backend — Lint runs-on: ubuntu-latest - outputs: - short-sha: ${{ steps.sha.outputs.short }} - backend-image: ${{ steps.images.outputs.backend }} - frontend-image: ${{ steps.images.outputs.frontend }} - + defaults: + run: + working-directory: apps/backend steps: - - name: Compute short SHA + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: apps/backend/package-lock.json + - run: npm install --legacy-peer-deps + - run: npm run lint + + frontend-quality: + name: Frontend — Lint & Type-check + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: apps/frontend/package-lock.json + - run: npm ci --legacy-peer-deps + - run: npm run lint + - run: npm run type-check + + backend-tests: + name: Backend — Unit Tests + runs-on: ubuntu-latest + needs: backend-quality + defaults: + run: + working-directory: apps/backend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: apps/backend/package-lock.json + - run: npm install --legacy-peer-deps + - run: npm test -- --passWithNoTests + + frontend-tests: + name: Frontend — Unit Tests + runs-on: ubuntu-latest + needs: frontend-quality + defaults: + run: + working-directory: apps/frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: apps/frontend/package-lock.json + - run: npm ci --legacy-peer-deps + - run: npm test -- --passWithNoTests + + # ── 2. Image Verification ──────────────────────────────────────────── + # Checks that preprod-SHA tags exist for this EXACT commit. + # This is the security gate: if the preprod pipeline never ran for this + # commit (or failed before the docker build step), this job fails and + # the deployment is fully blocked. + verify-image: + name: Verify Preprod Image Exists + runs-on: ubuntu-latest + needs: [backend-tests, frontend-tests] + outputs: + sha: ${{ steps.sha.outputs.short }} + steps: + - name: 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 + - uses: docker/setup-buildx-action@v3 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to Scaleway Registry - uses: docker/login-action@v3 + - uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: nologin password: ${{ secrets.REGISTRY_TOKEN }} - - name: Promote Backend (preprod → latest + prod-SHA) + - name: Check backend image preprod-SHA run: | - docker buildx imagetools create \ - --tag ${{ env.IMAGE_BACKEND }}:latest \ - --tag ${{ steps.images.outputs.backend }} \ - ${{ env.IMAGE_BACKEND }}:preprod + TAG="${{ env.REGISTRY }}/xpeditis-backend:preprod-${{ steps.sha.outputs.short }}" + echo "Verifying: $TAG" + docker buildx imagetools inspect "$TAG" || { + echo "" + echo "BLOCKED: Image $TAG not found in registry." + echo "This commit was not built by the preprod pipeline." + echo "Merge to preprod first and wait for the full pipeline to succeed." + exit 1 + } - - name: Promote Frontend (preprod → latest + prod-SHA) + - name: Check frontend image preprod-SHA run: | - docker buildx imagetools create \ - --tag ${{ env.IMAGE_FRONTEND }}:latest \ - --tag ${{ steps.images.outputs.frontend }} \ - ${{ env.IMAGE_FRONTEND }}:preprod + TAG="${{ env.REGISTRY }}/xpeditis-frontend:preprod-${{ steps.sha.outputs.short }}" + echo "Verifying: $TAG" + docker buildx imagetools inspect "$TAG" || { + echo "" + echo "BLOCKED: Image $TAG not found in registry." + echo "This commit was not built by the preprod pipeline." + echo "Merge to preprod first and wait for the full pipeline to succeed." + exit 1 + } - - 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) + # ── 3. Promote Images ──────────────────────────────────────────────── + # Re-tags preprod-SHA → latest + prod-SHA within Scaleway. + # No rebuild. No layer transfer. Manifest-level operation only. + promote-images: + name: Promote Images (preprod-SHA → prod) runs-on: ubuntu-latest - needs: promote-images + needs: verify-image + steps: + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: nologin + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Promote backend + run: | + SHA="${{ needs.verify-image.outputs.sha }}" + docker buildx imagetools create \ + --tag ${{ env.REGISTRY }}/xpeditis-backend:latest \ + --tag ${{ env.REGISTRY }}/xpeditis-backend:prod-${SHA} \ + ${{ env.REGISTRY }}/xpeditis-backend:preprod-${SHA} + echo "Backend promoted: preprod-${SHA} → latest + prod-${SHA}" + + - name: Promote frontend + run: | + SHA="${{ needs.verify-image.outputs.sha }}" + docker buildx imagetools create \ + --tag ${{ env.REGISTRY }}/xpeditis-frontend:latest \ + --tag ${{ env.REGISTRY }}/xpeditis-frontend:prod-${SHA} \ + ${{ env.REGISTRY }}/xpeditis-frontend:preprod-${SHA} + echo "Frontend promoted: preprod-${SHA} → latest + prod-${SHA}" + + # ── 4. Deploy to k3s ───────────────────────────────────────────────── + deploy: + name: Deploy to Production (k3s) + runs-on: ubuntu-latest + needs: [verify-image, promote-images] environment: name: production url: https://app.xpeditis.com - steps: - name: Configure kubectl run: | @@ -113,156 +199,113 @@ jobs: echo "${{ secrets.HETZNER_KUBECONFIG }}" | base64 -d > ~/.kube/config chmod 600 ~/.kube/config kubectl cluster-info - kubectl get nodes + kubectl get nodes -o wide - - name: Deploy Backend + - name: Deploy backend + id: 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." + SHA="${{ needs.verify-image.outputs.sha }}" + IMAGE="${{ env.REGISTRY }}/xpeditis-backend:prod-${SHA}" + echo "Deploying: $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 rollout complete." - - name: Deploy Frontend + - name: Deploy frontend + id: 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." + SHA="${{ needs.verify-image.outputs.sha }}" + IMAGE="${{ env.REGISTRY }}/xpeditis-frontend:prod-${SHA}" + echo "Deploying: $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 rollout complete." - - name: Auto-rollback on failure + - name: Auto-rollback on deployment 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." + echo "Deployment failed — initiating rollback..." + kubectl rollout undo deployment/xpeditis-backend -n ${{ env.K8S_NAMESPACE }} + kubectl rollout undo deployment/xpeditis-frontend -n ${{ env.K8S_NAMESPACE }} + kubectl rollout status deployment/xpeditis-backend -n ${{ env.K8S_NAMESPACE }} --timeout=120s + kubectl rollout status deployment/xpeditis-frontend -n ${{ env.K8S_NAMESPACE }} --timeout=120s + echo "Rollback complete. Previous version is live." - # ────────────────────────────────────────────────────────────── - # 3. Smoke Tests - # kubectl rollout status already verifies pod readiness. - # These confirm the full network path: - # Cloudflare → Hetzner LB → Traefik → pod. - # ────────────────────────────────────────────────────────────── + # ── 5. Smoke Tests ─────────────────────────────────────────────────── + # kubectl rollout status already verified pod readiness. + # These smoke tests validate 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 + - name: Health — 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 + if [ "$STATUS" = "200" ]; then echo "Backend OK."; exit 0; fi sleep 15 done - echo "CRITICAL: Backend unreachable after rollout." + echo "CRITICAL: Backend unreachable after 12 attempts." exit 1 - - name: Health check — Frontend + - name: Health — 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 + if [ "$STATUS" = "200" ]; then echo "Frontend OK."; exit 0; fi sleep 15 done - echo "CRITICAL: Frontend unreachable after rollout." + echo "CRITICAL: Frontend unreachable after 12 attempts." 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 - # ────────────────────────────────────────────────────────────── + # ── Notifications ──────────────────────────────────────────────────── notify-success: name: Notify Success runs-on: ubuntu-latest - needs: [promote-images, smoke-tests] + needs: [verify-image, smoke-tests] if: success() - steps: - - name: Send Discord notification - run: | + - 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": "Version", "value": "`prod-${{ needs.verify-image.outputs.sha }}`", "inline": true}, {"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} + {"name": "Workflow", "value": "[${{ github.run_id }}](${{ 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] + needs: [backend-quality, frontend-quality, backend-tests, frontend-tests, verify-image, promote-images, deploy, smoke-tests] if: failure() - steps: - - name: Send Discord notification - run: | + - run: | curl -s -H "Content-Type: application/json" -d '{ - "content": "@here PRODUCTION DEPLOYMENT FAILED", + "content": "@here PRODUCTION PIPELINE FAILED", "embeds": [{ - "title": "🔴 PRODUCTION PIPELINE FAILED", - "description": "Auto-rollback was triggered if deployment failed. Check rollout history.", + "title": "🔴 Production Pipeline Failed", + "description": "Check the workflow for details. Auto-rollback was triggered if the failure was during deploy.", "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} + {"name": "Workflow", "value": "[${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": false}, + {"name": "Rollback", "value": "[Run rollback workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/rollback.yml)", "inline": false} ], "footer": {"text": "Xpeditis CI/CD • Production"} }] diff --git a/.github/workflows/cd-preprod.yml b/.github/workflows/cd-preprod.yml index 0d2e359..830f19c 100644 --- a/.github/workflows/cd-preprod.yml +++ b/.github/workflows/cd-preprod.yml @@ -1,23 +1,22 @@ name: CD Preprod -# Full pipeline for the preprod branch. -# Flow: quality → integration tests → Docker build & push → deploy → smoke tests → notify +# Full pipeline triggered on every push to preprod. +# Flow: lint → unit tests → integration tests → docker build → 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 +# Secrets required: +# REGISTRY_TOKEN — Scaleway registry (read/write) +# NEXT_PUBLIC_API_URL — https://api.preprod.xpeditis.com +# NEXT_PUBLIC_APP_URL — https://preprod.xpeditis.com +# PORTAINER_WEBHOOK_BACKEND — Portainer webhook (preprod backend) +# PORTAINER_WEBHOOK_FRONTEND— Portainer webhook (preprod frontend) +# PREPROD_BACKEND_URL — https://api.preprod.xpeditis.com +# PREPROD_FRONTEND_URL — https://preprod.xpeditis.com +# DISCORD_WEBHOOK_URL 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 @@ -27,81 +26,80 @@ env: NODE_VERSION: '20' jobs: - # ────────────────────────────────────────── - # 1. Lint & Type-check - # ────────────────────────────────────────── - quality: - name: Quality (${{ matrix.app }}) + # ── 1. Lint ───────────────────────────────────────────────────────── + backend-quality: + name: Backend — Lint runs-on: ubuntu-latest - strategy: - fail-fast: true - matrix: - app: [backend, frontend] - defaults: run: - working-directory: apps/${{ matrix.app }} - + working-directory: apps/backend steps: - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - cache-dependency-path: apps/${{ matrix.app }}/package-lock.json + cache-dependency-path: apps/backend/package-lock.json + - run: npm install --legacy-peer-deps + - run: npm run lint - - 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 }}) + frontend-quality: + name: Frontend — Lint & Type-check runs-on: ubuntu-latest - needs: quality - strategy: - fail-fast: true - matrix: - app: [backend, frontend] - defaults: run: - working-directory: apps/${{ matrix.app }} - + working-directory: apps/frontend steps: - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - cache-dependency-path: apps/${{ matrix.app }}/package-lock.json + cache-dependency-path: apps/frontend/package-lock.json + - run: npm ci --legacy-peer-deps + - run: npm run lint + - run: npm run type-check - - name: Install dependencies - run: npm ci --legacy-peer-deps + # ── 2. Unit Tests ──────────────────────────────────────────────────── + backend-tests: + name: Backend — Unit Tests + runs-on: ubuntu-latest + needs: backend-quality + defaults: + run: + working-directory: apps/backend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: apps/backend/package-lock.json + - run: npm install --legacy-peer-deps + - run: npm test -- --passWithNoTests - - name: Run unit tests - run: npm test -- --passWithNoTests --coverage + frontend-tests: + name: Frontend — Unit Tests + runs-on: ubuntu-latest + needs: frontend-quality + defaults: + run: + working-directory: apps/frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: apps/frontend/package-lock.json + - run: npm ci --legacy-peer-deps + - run: npm test -- --passWithNoTests - # ────────────────────────────────────────── - # 3. Integration Tests - # ────────────────────────────────────────── + # ── 3. Integration Tests ───────────────────────────────────────────── integration-tests: - name: Integration Tests + name: Backend — Integration Tests runs-on: ubuntu-latest - needs: unit-tests - + needs: [backend-tests, frontend-tests] defaults: run: working-directory: apps/backend @@ -120,7 +118,6 @@ jobs: --health-retries 10 ports: - 5432:5432 - redis: image: redis:7-alpine options: >- @@ -133,17 +130,12 @@ jobs: steps: - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 + - 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 - + - run: npm install --legacy-peer-deps - name: Run integration tests env: NODE_ENV: test @@ -152,50 +144,40 @@ jobs: DATABASE_USER: xpeditis_test DATABASE_PASSWORD: xpeditis_test_password DATABASE_NAME: xpeditis_test - DATABASE_SYNCHRONIZE: false + DATABASE_SYNCHRONIZE: 'false' REDIS_HOST: localhost REDIS_PORT: 6379 REDIS_PASSWORD: '' - JWT_SECRET: test-secret-key-for-ci-only + JWT_SECRET: test-secret-key-ci SMTP_HOST: localhost SMTP_PORT: 1025 SMTP_FROM: test@xpeditis.com run: npm run test:integration -- --passWithNoTests - # ────────────────────────────────────────── - # 4a. Docker Build & Push — Backend - # ────────────────────────────────────────── + # ── 4. Docker Build & Push ─────────────────────────────────────────── + # Tags: preprod (latest for this env) + preprod-SHA (used by prod for exact promotion) build-backend: - name: Build & Push Backend + name: Build Backend runs-on: ubuntu-latest needs: integration-tests outputs: - image-tag: ${{ steps.sha.outputs.short }} - + sha: ${{ steps.sha.outputs.short }} steps: - uses: actions/checkout@v4 - - - name: Compute short SHA + - name: 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 + - uses: docker/setup-buildx-action@v3 + - 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 + - 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 }} @@ -203,35 +185,24 @@ jobs: 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 + name: Build Frontend runs-on: ubuntu-latest needs: integration-tests outputs: - image-tag: ${{ steps.sha.outputs.short }} - + sha: ${{ steps.sha.outputs.short }} steps: - uses: actions/checkout@v4 - - - name: Compute short SHA + - name: 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 + - uses: docker/setup-buildx-action@v3 + - 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 + - uses: docker/build-push-action@v5 with: context: ./apps/frontend file: ./apps/frontend/Dockerfile @@ -246,151 +217,91 @@ jobs: NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }} NEXT_PUBLIC_APP_URL=${{ secrets.NEXT_PUBLIC_APP_URL }} - # ────────────────────────────────────────── - # 5. Deploy to Preprod via Portainer - # ────────────────────────────────────────── + # ── 5. Deploy via Portainer ────────────────────────────────────────── deploy: name: Deploy to Preprod runs-on: ubuntu-latest needs: [build-backend, build-frontend] environment: preprod - steps: - - name: Trigger Backend deployment + - name: Deploy backend run: | - echo "Deploying backend (preprod-${{ needs.build-backend.outputs.image-tag }})..." - curl -sf -X POST \ - -H "Content-Type: application/json" \ - "${{ secrets.PORTAINER_WEBHOOK_BACKEND }}" + curl -sf -X POST "${{ secrets.PORTAINER_WEBHOOK_BACKEND }}" echo "Backend webhook triggered." - - - name: Wait for backend to stabilize + - name: Wait for backend startup run: sleep 20 - - - name: Trigger Frontend deployment + - name: Deploy frontend run: | - echo "Deploying frontend (preprod-${{ needs.build-frontend.outputs.image-tag }})..." - curl -sf -X POST \ - -H "Content-Type: application/json" \ - "${{ secrets.PORTAINER_WEBHOOK_FRONTEND }}" + curl -sf -X POST "${{ secrets.PORTAINER_WEBHOOK_FRONTEND }}" echo "Frontend webhook triggered." - # ────────────────────────────────────────── - # 6. Smoke Tests — verify preprod is healthy - # ────────────────────────────────────────── + # ── 6. Smoke Tests ─────────────────────────────────────────────────── smoke-tests: name: Smoke Tests runs-on: ubuntu-latest needs: deploy - steps: - - name: Wait for services to start + - name: Wait for services run: sleep 40 - - - name: Health check — Backend + - name: Health — 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") + for i in {1..12}; do + STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 \ + "${{ secrets.PREPROD_BACKEND_URL }}/api/v1/health" 2>/dev/null || echo "000") echo " Attempt $i: HTTP $STATUS" - if [ "$STATUS" = "200" ]; then - echo "Backend is healthy." - exit 0 - fi + if [ "$STATUS" = "200" ]; then echo "Backend OK."; exit 0; fi sleep 15 done - echo "Backend health check failed after 10 attempts." + echo "Backend unreachable after 12 attempts." exit 1 - - - name: Health check — Frontend + - name: Health — Frontend run: | - echo "Checking frontend health..." - for i in {1..10}; do - STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ - --max-time 10 \ + for i in {1..12}; 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 + if [ "$STATUS" = "200" ]; then echo "Frontend OK."; exit 0; fi sleep 15 done - echo "Frontend health check failed after 10 attempts." + echo "Frontend unreachable after 12 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 - # ────────────────────────────────────────── + # ── Notifications ──────────────────────────────────────────────────── notify-success: name: Notify Success runs-on: ubuntu-latest needs: [build-backend, build-frontend, smoke-tests] if: success() - steps: - - name: Send Discord notification - run: | + - 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} + {"name": "SHA", "value": "`${{ needs.build-backend.outputs.sha }}`", "inline": true}, + {"name": "Workflow", "value": "[${{ github.run_id }}](${{ 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] + needs: [backend-quality, frontend-quality, backend-tests, frontend-tests, integration-tests, build-backend, build-frontend, deploy, smoke-tests] if: failure() - steps: - - name: Send Discord notification - run: | + - run: | curl -s -H "Content-Type: application/json" -d '{ "embeds": [{ "title": "❌ Preprod Pipeline Failed", - "description": "Preprod was NOT deployed. Fix the issue before retrying.", + "description": "Preprod was NOT deployed.", "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": "Workflow", "value": "[${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": false} ], "footer": {"text": "Xpeditis CI/CD • Preprod"} }] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a13643..cc49e49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,5 @@ name: Dev CI -# Fast feedback loop for the dev branch. -# Runs lint + unit tests only — no Docker build, no deployment. - on: push: branches: [dev] @@ -17,96 +14,90 @@ env: NODE_VERSION: '20' jobs: - # ────────────────────────────────────────── - # Lint & Type-check - # ────────────────────────────────────────── - quality: - name: Quality (${{ matrix.app }}) + backend-quality: + name: Backend — Lint runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - app: [backend, frontend] - defaults: run: - working-directory: apps/${{ matrix.app }} - + working-directory: apps/backend steps: - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - cache-dependency-path: apps/${{ matrix.app }}/package-lock.json + cache-dependency-path: apps/backend/package-lock.json + - run: npm install --legacy-peer-deps + - run: npm run lint - - 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 }}) + frontend-quality: + name: Frontend — Lint & Type-check runs-on: ubuntu-latest - needs: quality - strategy: - fail-fast: false - matrix: - app: [backend, frontend] - defaults: run: - working-directory: apps/${{ matrix.app }} - + working-directory: apps/frontend steps: - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - cache-dependency-path: apps/${{ matrix.app }}/package-lock.json + cache-dependency-path: apps/frontend/package-lock.json + - run: npm ci --legacy-peer-deps + - run: npm run lint + - run: npm run type-check - - name: Install dependencies - run: npm ci --legacy-peer-deps + backend-tests: + name: Backend — Unit Tests + runs-on: ubuntu-latest + needs: backend-quality + defaults: + run: + working-directory: apps/backend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: apps/backend/package-lock.json + - run: npm install --legacy-peer-deps + - run: npm test -- --passWithNoTests - - name: Run unit tests - run: npm test -- --passWithNoTests --coverage + frontend-tests: + name: Frontend — Unit Tests + runs-on: ubuntu-latest + needs: frontend-quality + defaults: + run: + working-directory: apps/frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: apps/frontend/package-lock.json + - run: npm ci --legacy-peer-deps + - run: npm test -- --passWithNoTests - # ────────────────────────────────────────── - # Discord notification on failure - # ────────────────────────────────────────── notify-failure: name: Notify Failure runs-on: ubuntu-latest - needs: [quality, unit-tests] + needs: [backend-quality, frontend-quality, backend-tests, frontend-tests] if: failure() - steps: - - name: Send Discord notification + - name: Discord 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} + {"name": "Workflow", "value": "[${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": false} ], - "footer": {"text": "Xpeditis CI/CD"} + "footer": {"text": "Xpeditis CI • Dev"} }] }' ${{ secrets.DISCORD_WEBHOOK_URL }} diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 6b894d3..f78397f 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -1,10 +1,8 @@ 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. +# Required status checks — configure these in branch protection rules. +# PRs to preprod : lint + type-check + unit tests + integration tests +# PRs to main : lint + type-check + unit tests only on: pull_request: @@ -18,82 +16,80 @@ env: NODE_VERSION: '20' jobs: - # ────────────────────────────────────────── - # Lint & Type-check (both apps, parallel) - # ────────────────────────────────────────── - quality: - name: Quality (${{ matrix.app }}) + backend-quality: + name: Backend — Lint runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - app: [backend, frontend] - defaults: run: - working-directory: apps/${{ matrix.app }} - + working-directory: apps/backend steps: - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - cache-dependency-path: apps/${{ matrix.app }}/package-lock.json + cache-dependency-path: apps/backend/package-lock.json + - run: npm install --legacy-peer-deps + - run: npm run lint - - 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 }}) + frontend-quality: + name: Frontend — Lint & Type-check runs-on: ubuntu-latest - needs: quality - strategy: - fail-fast: false - matrix: - app: [backend, frontend] - defaults: run: - working-directory: apps/${{ matrix.app }} - + working-directory: apps/frontend steps: - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - cache-dependency-path: apps/${{ matrix.app }}/package-lock.json + cache-dependency-path: apps/frontend/package-lock.json + - run: npm ci --legacy-peer-deps + - run: npm run lint + - run: npm run type-check - - name: Install dependencies - run: npm ci --legacy-peer-deps + backend-tests: + name: Backend — Unit Tests + runs-on: ubuntu-latest + needs: backend-quality + defaults: + run: + working-directory: apps/backend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: apps/backend/package-lock.json + - run: npm install --legacy-peer-deps + - run: npm test -- --passWithNoTests - - name: Run unit tests - run: npm test -- --passWithNoTests --coverage + frontend-tests: + name: Frontend — Unit Tests + runs-on: ubuntu-latest + needs: frontend-quality + defaults: + run: + working-directory: apps/frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: apps/frontend/package-lock.json + - run: npm ci --legacy-peer-deps + - run: npm test -- --passWithNoTests - # ────────────────────────────────────────── - # Integration Tests — PRs to preprod only - # ────────────────────────────────────────── + # Integration tests — PRs to preprod only + # Code going to main was already integration-tested when it passed through preprod integration-tests: - name: Integration Tests + name: Backend — Integration Tests runs-on: ubuntu-latest - needs: unit-tests + needs: backend-tests if: github.base_ref == 'preprod' - defaults: run: working-directory: apps/backend @@ -112,7 +108,6 @@ jobs: --health-retries 10 ports: - 5432:5432 - redis: image: redis:7-alpine options: >- @@ -125,17 +120,12 @@ jobs: steps: - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 + - 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 - + - run: npm install --legacy-peer-deps - name: Run integration tests env: NODE_ENV: test @@ -144,33 +134,12 @@ jobs: DATABASE_USER: xpeditis_test DATABASE_PASSWORD: xpeditis_test_password DATABASE_NAME: xpeditis_test - DATABASE_SYNCHRONIZE: false + DATABASE_SYNCHRONIZE: 'false' REDIS_HOST: localhost REDIS_PORT: 6379 REDIS_PASSWORD: '' - JWT_SECRET: test-secret-key-for-ci-only + JWT_SECRET: test-secret-key-ci 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 index ea82bd9..54e4f85 100644 --- a/.github/workflows/rollback.yml +++ b/.github/workflows/rollback.yml @@ -1,16 +1,24 @@ name: Rollback -# Emergency rollback for production (Hetzner k3s) and preprod (Portainer). -# All images are on Scaleway registry. +# Emergency rollback — production (Hetzner k3s) and preprod (Portainer). # -# 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 +# Production strategies: +# previous — kubectl rollout undo (fastest, reverts to previous ReplicaSet) +# specific-version — kubectl set image to a specific prod-SHA tag # -# Preprod (Portainer): -# Re-tags a Scaleway preprod-SHA image back to :preprod, triggers Portainer webhook. +# Preprod strategy: +# Re-tags a preprod-SHA image back to :preprod, triggers Portainer webhook. # -# Run from: GitHub Actions → Workflows → Rollback → Run workflow +# Secrets required: +# REGISTRY_TOKEN — Scaleway registry +# HETZNER_KUBECONFIG — base64 kubeconfig (production only) +# PORTAINER_WEBHOOK_BACKEND — Portainer webhook preprod backend +# PORTAINER_WEBHOOK_FRONTEND — Portainer webhook preprod frontend +# PROD_BACKEND_URL — https://api.xpeditis.com +# PROD_FRONTEND_URL — https://app.xpeditis.com +# PREPROD_BACKEND_URL — https://api.preprod.xpeditis.com +# PREPROD_FRONTEND_URL — https://preprod.xpeditis.com +# DISCORD_WEBHOOK_URL on: workflow_dispatch: @@ -19,41 +27,31 @@ on: description: 'Target environment' required: true type: choice - options: - - production - - preprod + options: [production, preprod] strategy: - description: 'Rollback strategy ("previous" = kubectl rollout undo, prod only)' + description: 'Strategy (production only — "previous" = instant kubectl undo)' required: true type: choice - options: - - previous - - specific-version + 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)' + description: 'Reason (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 + - name: Check inputs run: | ENV="${{ github.event.inputs.environment }}" STRATEGY="${{ github.event.inputs.strategy }}" @@ -64,38 +62,31 @@ jobs: exit 1 fi - if [ "$STRATEGY" = "specific-version" ] && [ "$ENV" = "production" ]; then + if [ "$ENV" = "production" ] && [ "$STRATEGY" = "specific-version" ]; 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 [ "$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 }}" + echo "Validated — env: $ENV | strategy: $STRATEGY | tag: ${TAG:-N/A} | reason: ${{ github.event.inputs.reason }}" - # ────────────────────────────────────────── - # PRODUCTION ROLLBACK — k3s via kubectl - # ────────────────────────────────────────── + # ── Production rollback via kubectl ────────────────────────────────── rollback-production: - name: Rollback Production (k3s) + name: Rollback Production 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: | @@ -104,26 +95,16 @@ jobs: chmod 600 ~/.kube/config kubectl cluster-info - # ── Strategy A: kubectl rollout undo (fastest) - - name: Rollback to previous version + - name: Rollback — 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 + - name: Login to Scaleway (for image verification) if: github.event.inputs.strategy == 'specific-version' uses: docker/login-action@v3 with: @@ -131,103 +112,81 @@ jobs: username: nologin password: ${{ secrets.REGISTRY_TOKEN }} - - name: Rollback to specific version + - uses: docker/setup-buildx-action@v3 + if: github.event.inputs.strategy == 'specific-version' + + - name: Rollback — 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}" + BACKEND="${{ env.REGISTRY }}/xpeditis-backend:${TAG}" + FRONTEND="${{ env.REGISTRY }}/xpeditis-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 "Verifying images exist..." + docker buildx imagetools inspect "$BACKEND" || { echo "ERROR: $BACKEND not found"; exit 1; } + docker buildx imagetools inspect "$FRONTEND" || { echo "ERROR: $FRONTEND not found"; exit 1; } - echo "Deploying backend: $BACKEND_IMAGE" - kubectl set image deployment/xpeditis-backend backend="$BACKEND_IMAGE" -n ${{ env.K8S_NAMESPACE }} + kubectl set image deployment/xpeditis-backend backend="$BACKEND" -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 set image deployment/xpeditis-frontend frontend="$FRONTEND" -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 + - name: 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 - # ────────────────────────────────────────── + # ── Preprod rollback via Portainer ─────────────────────────────────── rollback-preprod: - name: Rollback Preprod (Portainer) + name: Rollback Preprod runs-on: ubuntu-latest needs: validate if: github.event.inputs.environment == 'preprod' - steps: - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + - uses: docker/setup-buildx-action@v3 - - name: Login to Scaleway Registry - uses: docker/login-action@v3 + - uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: nologin password: ${{ secrets.REGISTRY_TOKEN }} - - name: Verify target images exist + - name: Verify target image exists 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; } + docker buildx imagetools inspect "${{ env.REGISTRY }}/xpeditis-backend:${TAG}" || \ + { echo "ERROR: backend image not found: $TAG"; exit 1; } + docker buildx imagetools inspect "${{ env.REGISTRY }}/xpeditis-frontend:${TAG}" || \ + { echo "ERROR: frontend image not found: $TAG"; exit 1; } - - name: Re-tag target version as preprod + - name: Re-tag 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} + --tag ${{ env.REGISTRY }}/xpeditis-backend:preprod \ + ${{ env.REGISTRY }}/xpeditis-backend:${TAG} docker buildx imagetools create \ - --tag ${{ env.IMAGE_FRONTEND }}:preprod \ - ${{ env.IMAGE_FRONTEND }}:${TAG} - echo "Re-tag complete." + --tag ${{ env.REGISTRY }}/xpeditis-frontend:preprod \ + ${{ env.REGISTRY }}/xpeditis-frontend:${TAG} - - name: Trigger Backend deployment (Portainer) - run: | - curl -sf -X POST -H "Content-Type: application/json" \ - "${{ secrets.PORTAINER_WEBHOOK_BACKEND }}" - echo "Backend webhook triggered." + - name: Deploy backend (Portainer) + run: curl -sf -X POST "${{ secrets.PORTAINER_WEBHOOK_BACKEND }}" + - run: sleep 20 + - name: Deploy frontend (Portainer) + run: curl -sf -X POST "${{ secrets.PORTAINER_WEBHOOK_FRONTEND }}" - - 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 ─────────────────────────────────────────────────────── smoke-tests: - name: Smoke Tests + name: Smoke Tests Post-Rollback 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 + - name: Set URLs id: urls run: | if [ "${{ github.event.inputs.environment }}" = "production" ]; then @@ -240,44 +199,40 @@ jobs: echo "wait=60" >> $GITHUB_OUTPUT fi - - name: Wait for services - run: sleep ${{ steps.urls.outputs.wait }} + - run: sleep ${{ steps.urls.outputs.wait }} - - name: Health check — Backend + - name: Health — 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 + if [ "$STATUS" = "200" ]; then echo "Backend OK."; exit 0; fi sleep 15 done - echo "CRITICAL: Backend unhealthy after rollback." + echo "Backend unhealthy after rollback." exit 1 - - name: Health check — Frontend + - name: Health — 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 + if [ "$STATUS" = "200" ]; then echo "Frontend OK."; exit 0; fi sleep 15 done - echo "CRITICAL: Frontend unhealthy after rollback." + echo "Frontend unhealthy after rollback." exit 1 - # ────────────────────────────────────────── - # Discord notification - # ────────────────────────────────────────── + # ── Notifications ───────────────────────────────────────────────────── notify: - name: Notify Rollback Result + name: Notify runs-on: ubuntu-latest needs: [rollback-production, rollback-preprod, smoke-tests] if: always() - steps: - - name: Notify success + - name: Success if: needs.smoke-tests.result == 'success' run: | curl -s -H "Content-Type: application/json" -d '{ @@ -288,29 +243,26 @@ jobs: {"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} + {"name": "By", "value": "${{ github.actor }}", "inline": true}, + {"name": "Reason", "value": "${{ github.event.inputs.reason }}", "inline": false} ], "footer": {"text": "Xpeditis CI/CD • Rollback"} }] }' ${{ secrets.DISCORD_WEBHOOK_URL }} - - name: Notify failure + - name: 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} + {"name": "Attempted", "value": "`${{ github.event.inputs.version_tag || 'previous' }}`", "inline": true}, + {"name": "By", "value": "${{ github.actor }}", "inline": true}, + {"name": "Reason", "value": "${{ github.event.inputs.reason }}", "inline": false} ], "footer": {"text": "Xpeditis CI/CD • Rollback"} }]