Merge pull request #1162 from Mirabel64/fix/issue-1079-audit-gate #1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Blue-Green Deploy | |
| on: | |
| push: | |
| branches: [main] | |
| paths: | |
| - 'client/**' | |
| - 'backend/**' | |
| - 'shared/**' | |
| workflow_dispatch: | |
| inputs: | |
| environment: | |
| description: 'Target environment' | |
| required: true | |
| type: choice | |
| default: production | |
| options: [production, staging] | |
| skip_smoke_tests: | |
| description: 'Skip smoke tests (emergency deploy only)' | |
| type: boolean | |
| default: false | |
| concurrency: | |
| group: blue-green-${{ github.event.inputs.environment || 'production' }} | |
| cancel-in-progress: false | |
| jobs: | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 1. Validate migrations before any deployment | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| validate-migrations: | |
| name: Validate Migration Compatibility | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-node@v7 | |
| with: | |
| node-version: '20' | |
| cache: 'npm' | |
| cache-dependency-path: backend/package-lock.json | |
| - name: Install backend dependencies | |
| working-directory: backend | |
| run: npm ci | |
| - name: Run migration compatibility check | |
| working-directory: backend | |
| env: | |
| SUPABASE_URL: ${{ secrets.SUPABASE_URL }} | |
| SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} | |
| run: node scripts/validate-migration-compatibility.js | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 2. Deploy to the inactive (blue/green) slot | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| deploy-inactive-slot: | |
| name: Deploy to Inactive Slot | |
| needs: validate-migrations | |
| runs-on: ubuntu-latest | |
| outputs: | |
| new_deployment_url: ${{ steps.deploy.outputs.deployment_url }} | |
| slot: ${{ steps.slot.outputs.slot }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-node@v7 | |
| with: | |
| node-version: '20' | |
| cache: 'npm' | |
| cache-dependency-path: client/package-lock.json | |
| - name: Install Vercel CLI | |
| run: npm install -g vercel@latest | |
| - name: Determine inactive slot | |
| id: slot | |
| run: | | |
| # Vercel preview = "blue", production = "green" (or vice versa) | |
| # We deploy to preview first, validate, then promote to production | |
| echo "slot=blue" >> $GITHUB_OUTPUT | |
| - name: Deploy to Vercel preview (blue slot) | |
| id: deploy | |
| working-directory: client | |
| env: | |
| VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} | |
| VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} | |
| VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} | |
| run: | | |
| vercel pull --yes --environment=preview --token=$VERCEL_TOKEN | |
| vercel build --token=$VERCEL_TOKEN | |
| DEPLOYMENT_URL=$(vercel deploy --prebuilt --token=$VERCEL_TOKEN \ | |
| --env NEXT_PUBLIC_API_BASE=${{ secrets.BACKEND_URL }} \ | |
| --env NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} \ | |
| --env NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} \ | |
| --meta githubCommitSha=${{ github.sha }} \ | |
| --meta blueGreenSlot=blue \ | |
| 2>&1 | grep -o 'https://[^ ]*\.vercel\.app' | tail -1) | |
| echo "deployment_url=$DEPLOYMENT_URL" >> $GITHUB_OUTPUT | |
| echo "Deployed to: $DEPLOYMENT_URL" | |
| - name: Notify Slack β deploy started | |
| if: always() | |
| env: | |
| SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} | |
| run: | | |
| [ -z "$SLACK_WEBHOOK_URL" ] && exit 0 | |
| curl -s -X POST "$SLACK_WEBHOOK_URL" \ | |
| -H 'Content-Type: application/json' \ | |
| -d '{ | |
| "text": "π΅ Blue slot deployed", | |
| "blocks": [{ | |
| "type": "section", | |
| "text": { | |
| "type": "mrkdwn", | |
| "text": "*π΅ Blue-Green Deploy* β blue slot live\n*Commit:* `${{ github.sha }}`\n*Branch:* `${{ github.ref_name }}`\n*URL:* ${{ steps.deploy.outputs.deployment_url }}\n\nRunning smoke tests before promoting to production..." | |
| } | |
| }] | |
| }' | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 3. Smoke tests on the blue (inactive) slot | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| smoke-test-blue-slot: | |
| name: Smoke Tests (Blue Slot) | |
| needs: deploy-inactive-slot | |
| runs-on: ubuntu-latest | |
| if: ${{ github.event.inputs.skip_smoke_tests != 'true' }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-node@v7 | |
| with: | |
| node-version: '20' | |
| cache: 'npm' | |
| cache-dependency-path: backend/package-lock.json | |
| - name: Install backend dependencies | |
| working-directory: backend | |
| run: npm ci | |
| - name: Wait for blue slot to stabilize | |
| run: sleep 15 | |
| - name: Health check β blue slot frontend | |
| run: | | |
| URL="${{ needs.deploy-inactive-slot.outputs.new_deployment_url }}" | |
| echo "Checking $URL/api/health ..." | |
| for i in 1 2 3 4 5; do | |
| STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 15 "$URL/api/health") | |
| if [ "$STATUS" = "200" ]; then | |
| echo "β Frontend health check passed (HTTP 200)" | |
| exit 0 | |
| fi | |
| echo "Attempt $i: HTTP $STATUS β retrying in 10s" | |
| sleep 10 | |
| done | |
| echo "β Frontend health check failed after 5 attempts" | |
| exit 1 | |
| - name: Health check β backend | |
| env: | |
| BACKEND_URL: ${{ secrets.BACKEND_URL }} | |
| run: | | |
| [ -z "$BACKEND_URL" ] && echo "β οΈ BACKEND_URL not set, skipping" && exit 0 | |
| for i in 1 2 3; do | |
| STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 15 "$BACKEND_URL/health") | |
| if [ "$STATUS" = "200" ]; then | |
| echo "β Backend health check passed" | |
| exit 0 | |
| fi | |
| echo "Attempt $i: HTTP $STATUS β retrying in 10s" | |
| sleep 10 | |
| done | |
| echo "β Backend health check failed" | |
| exit 1 | |
| - name: Backend readiness check | |
| env: | |
| BACKEND_URL: ${{ secrets.BACKEND_URL }} | |
| run: | | |
| [ -z "$BACKEND_URL" ] && exit 0 | |
| BODY=$(curl -s --max-time 15 "$BACKEND_URL/health/ready") | |
| STATUS=$(echo "$BODY" | node -e "process.stdin.resume();let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{ try{console.log(JSON.parse(d).status)}catch{console.log('error')} })") | |
| echo "Readiness status: $STATUS" | |
| if [ "$STATUS" = "ready" ]; then | |
| echo "β Backend is ready" | |
| echo "$BODY" | node -e "process.stdin.resume();let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{ try{const deps=JSON.parse(d).dependencies||[];deps.forEach(d=>console.log(\` \${d.name}: \${d.status} (\${d.latency_ms||0}ms)\`))}catch{} })" | |
| else | |
| echo "β Backend not ready: $BODY" | |
| exit 1 | |
| fi | |
| - name: Run comprehensive smoke tests | |
| working-directory: backend | |
| env: | |
| SMOKE_TEST_BASE_URL: ${{ secrets.BACKEND_URL }} | |
| SMOKE_TEST_FRONTEND_URL: ${{ needs.deploy-inactive-slot.outputs.new_deployment_url }} | |
| SUPABASE_URL: ${{ secrets.SUPABASE_URL }} | |
| SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }} | |
| NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} | |
| NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} | |
| SMOKE_TEST_USER_EMAIL: ${{ secrets.SMOKE_TEST_USER_EMAIL }} | |
| SMOKE_TEST_USER_PASSWORD: ${{ secrets.SMOKE_TEST_USER_PASSWORD }} | |
| SMOKE_TEST_API_KEY: ${{ secrets.SMOKE_TEST_API_KEY }} | |
| run: npm test -- -c tests/smoke/jest.smoke.config.js --forceExit | |
| - name: Upload smoke test results | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: smoke-tests-blue-${{ github.sha }} | |
| path: backend/coverage/smoke/ | |
| retention-days: 14 | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 4. Promote blue β production (traffic switch) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| promote-to-production: | |
| name: Promote Blue Slot to Production | |
| needs: [deploy-inactive-slot, smoke-test-blue-slot] | |
| runs-on: ubuntu-latest | |
| environment: | |
| name: production | |
| url: ${{ steps.promote.outputs.production_url }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install Vercel CLI | |
| run: npm install -g vercel@latest | |
| - name: Record current production SHA (for rollback) | |
| id: current | |
| env: | |
| VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} | |
| VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} | |
| VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} | |
| run: | | |
| CURRENT=$(vercel ls --token=$VERCEL_TOKEN --scope=$VERCEL_ORG_ID 2>/dev/null \ | |
| | grep ' production ' | head -1 | awk '{print $1}') || true | |
| echo "previous_deployment=$CURRENT" >> $GITHUB_OUTPUT | |
| echo "Previous production deployment: $CURRENT" | |
| - name: Promote blue slot to production | |
| id: promote | |
| working-directory: client | |
| env: | |
| VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} | |
| VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} | |
| VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} | |
| run: | | |
| BLUE_URL="${{ needs.deploy-inactive-slot.outputs.new_deployment_url }}" | |
| # Alias the blue deployment to production domain | |
| vercel alias "$BLUE_URL" "${{ secrets.PRODUCTION_DOMAIN }}" \ | |
| --token=$VERCEL_TOKEN --scope=$VERCEL_ORG_ID || true | |
| echo "production_url=https://${{ secrets.PRODUCTION_DOMAIN }}" >> $GITHUB_OUTPUT | |
| - name: Verify production health after switch | |
| run: | | |
| PROD_URL="${{ steps.promote.outputs.production_url }}" | |
| sleep 10 | |
| for i in 1 2 3; do | |
| STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 15 "$PROD_URL/api/health") | |
| if [ "$STATUS" = "200" ]; then | |
| echo "β Production health verified" | |
| exit 0 | |
| fi | |
| sleep 10 | |
| done | |
| echo "β οΈ Production health check did not return 200 β check manually" | |
| - name: Notify Slack β deploy successful | |
| if: success() | |
| env: | |
| SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} | |
| run: | | |
| [ -z "$SLACK_WEBHOOK_URL" ] && exit 0 | |
| curl -s -X POST "$SLACK_WEBHOOK_URL" \ | |
| -H 'Content-Type: application/json' \ | |
| -d '{ | |
| "text": "β Production deployment successful", | |
| "blocks": [{ | |
| "type": "section", | |
| "text": { | |
| "type": "mrkdwn", | |
| "text": "*β Blue-Green Deploy Complete*\n*Commit:* `${{ github.sha }}`\n*Branch:* `${{ github.ref_name }}`\n*Production:* ${{ steps.promote.outputs.production_url }}\n*Deployed by:* ${{ github.actor }}\n\nAll smoke tests passed. Traffic switched to blue slot. π" | |
| } | |
| }] | |
| }' | |
| - name: Notify Slack β deploy failed | |
| if: failure() | |
| env: | |
| SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} | |
| run: | | |
| [ -z "$SLACK_WEBHOOK_URL" ] && exit 0 | |
| curl -s -X POST "$SLACK_WEBHOOK_URL" \ | |
| -H 'Content-Type: application/json' \ | |
| -d '{ | |
| "text": "β Production deployment FAILED β traffic NOT switched", | |
| "blocks": [{ | |
| "type": "section", | |
| "text": { | |
| "type": "mrkdwn", | |
| "text": "*β Blue-Green Deploy FAILED*\n*Commit:* `${{ github.sha }}`\n*Branch:* `${{ github.ref_name }}`\n\nSmoke tests failed. Production traffic was NOT switched. Previous version is still live.\n\n*Action required:* Check <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|workflow run> and trigger rollback if needed." | |
| } | |
| }] | |
| }' | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 5. Auto-rollback if promotion health check fails | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| auto-rollback-on-failure: | |
| name: Auto Rollback (if smoke tests failed) | |
| needs: [deploy-inactive-slot, smoke-test-blue-slot] | |
| runs-on: ubuntu-latest | |
| if: failure() && needs.smoke-test-blue-slot.result == 'failure' | |
| steps: | |
| - name: Notify Slack β auto rollback triggered | |
| env: | |
| SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} | |
| run: | | |
| [ -z "$SLACK_WEBHOOK_URL" ] && exit 0 | |
| curl -s -X POST "$SLACK_WEBHOOK_URL" \ | |
| -H 'Content-Type: application/json' \ | |
| -d '{ | |
| "text": "β οΈ Smoke tests failed β blue slot NOT promoted to production", | |
| "blocks": [{ | |
| "type": "section", | |
| "text": { | |
| "type": "mrkdwn", | |
| "text": "*β οΈ Blue-Green Deploy Blocked*\n*Commit:* `${{ github.sha }}`\n\nSmoke tests failed on the blue slot. Production traffic was *not* switched β previous version remains live.\n\nSee <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|workflow run> for details." | |
| } | |
| }] | |
| }' |