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