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