name: CD Production # Production pipeline — Hetzner k3s. # # 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. # # 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] concurrency: group: cd-production cancel-in-progress: false env: REGISTRY: rg.fr-par.scw.cloud/weworkstudio NODE_VERSION: '20' K8S_NAMESPACE: xpeditis-prod jobs: # ── 1. Quality Gate ────────────────────────────────────────────────── # Runs on every prod deployment regardless of what happened in preprod. backend-quality: name: Backend — Lint runs-on: ubuntu-latest 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 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 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: nologin password: ${{ secrets.REGISTRY_TOKEN }} - name: Check backend image preprod-SHA run: | 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: Check frontend image preprod-SHA run: | 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 } # ── 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: 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: | mkdir -p ~/.kube echo "${{ secrets.HETZNER_KUBECONFIG }}" | base64 -d > ~/.kube/config chmod 600 ~/.kube/config kubectl cluster-info kubectl get nodes -o wide - name: Deploy backend id: deploy-backend run: | 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 id: deploy-frontend run: | 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 deployment failure if: failure() run: | 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." # ── 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 — 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 OK."; exit 0; fi sleep 15 done echo "CRITICAL: Backend unreachable after 12 attempts." exit 1 - 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 OK."; exit 0; fi sleep 15 done echo "CRITICAL: Frontend unreachable after 12 attempts." exit 1 # ── Notifications ──────────────────────────────────────────────────── notify-success: name: Notify Success runs-on: ubuntu-latest needs: [verify-image, smoke-tests] if: success() steps: - 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.verify-image.outputs.sha }}`", "inline": true}, {"name": "Cluster", "value": "Hetzner k3s — `xpeditis-prod`", "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 }} notify-failure: name: Notify Failure runs-on: ubuntu-latest needs: [backend-quality, frontend-quality, backend-tests, frontend-tests, verify-image, promote-images, deploy, smoke-tests] if: failure() steps: - run: | curl -s -H "Content-Type: application/json" -d '{ "content": "@here PRODUCTION PIPELINE FAILED", "embeds": [{ "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": "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"} }] }' ${{ secrets.DISCORD_WEBHOOK_URL }}