Skip to content

Merge pull request #1162 from Mirabel64/fix/issue-1079-audit-gate #1

Merge pull request #1162 from Mirabel64/fix/issue-1079-audit-gate

Merge pull request #1162 from Mirabel64/fix/issue-1079-audit-gate #1

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."
}
}]
}'