# MotoVaultPro Production Deployment Workflow # Manual trigger only - run after verifying staging # Blue-green deployment with auto-rollback # # Optimization: Uses sparse checkout (scripts/ only) + shallow clone # since all scripts run from $DEPLOY_PATH on the production server name: Deploy to Production run-name: Production Deploy - ${{ inputs.image_tag || 'latest' }} on: workflow_dispatch: inputs: image_tag: description: 'Image tag to deploy (defaults to latest)' required: false default: 'latest' env: REGISTRY: git.motovaultpro.com DEPLOY_PATH: /opt/motovaultpro COMPOSE_FILE: docker-compose.yml COMPOSE_BLUE_GREEN: docker-compose.blue-green.yml HEALTH_CHECK_TIMEOUT: "60" jobs: # ============================================ # VALIDATE - Determine target stack # ============================================ validate: name: Validate Prerequisites runs-on: prod outputs: target_stack: ${{ steps.determine-stack.outputs.target_stack }} backend_image: ${{ steps.set-images.outputs.backend_image }} frontend_image: ${{ steps.set-images.outputs.frontend_image }} steps: - name: Check Docker availability run: | docker info > /dev/null 2>&1 || (echo "ERROR - Docker not accessible" && exit 1) docker compose version > /dev/null 2>&1 || (echo "ERROR - Docker Compose not available" && exit 1) - name: Check deployment path run: test -d "$DEPLOY_PATH" || (echo "ERROR - DEPLOY_PATH not found" && exit 1) - name: Login to Gitea Container Registry run: | echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login -u "${{ secrets.REGISTRY_USER }}" --password-stdin "$REGISTRY" - name: Set image tags id: set-images run: | TAG="${{ inputs.image_tag }}" echo "backend_image=$REGISTRY/egullickson/backend:$TAG" >> $GITHUB_OUTPUT echo "frontend_image=$REGISTRY/egullickson/frontend:$TAG" >> $GITHUB_OUTPUT - name: Determine target stack id: determine-stack run: | STATE_FILE="$DEPLOY_PATH/config/deployment/state.json" if [ -f "$STATE_FILE" ] && command -v jq &> /dev/null; then ACTIVE_STACK=$(jq -r '.active_stack // "blue"' "$STATE_FILE") if [ "$ACTIVE_STACK" = "blue" ]; then echo "target_stack=green" >> $GITHUB_OUTPUT echo "Deploying to GREEN stack (BLUE is currently active)" else echo "target_stack=blue" >> $GITHUB_OUTPUT echo "Deploying to BLUE stack (GREEN is currently active)" fi else echo "target_stack=green" >> $GITHUB_OUTPUT echo "No state file found, defaulting to GREEN stack" fi # ============================================ # DEPLOY PROD - Blue-green deployment # ============================================ deploy-prod: name: Deploy to Production runs-on: prod needs: validate env: TARGET_STACK: ${{ needs.validate.outputs.target_stack }} BACKEND_IMAGE: ${{ needs.validate.outputs.backend_image }} FRONTEND_IMAGE: ${{ needs.validate.outputs.frontend_image }} steps: - name: Checkout scripts, config, and compose files uses: actions/checkout@v4 with: sparse-checkout: | scripts/ config/ docker-compose.yml docker-compose.blue-green.yml sparse-checkout-cone-mode: false fetch-depth: 1 - name: Sync config and compose files to deploy path run: | rsync -av --delete "$GITHUB_WORKSPACE/config/" "$DEPLOY_PATH/config/" cp "$GITHUB_WORKSPACE/docker-compose.yml" "$DEPLOY_PATH/" cp "$GITHUB_WORKSPACE/docker-compose.blue-green.yml" "$DEPLOY_PATH/" - name: Login to registry run: | echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login -u "${{ secrets.REGISTRY_USER }}" --password-stdin "$REGISTRY" - name: Inject secrets run: | chmod +x "$GITHUB_WORKSPACE/scripts/inject-secrets.sh" "$GITHUB_WORKSPACE/scripts/inject-secrets.sh" env: POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} AUTH0_CLIENT_SECRET: ${{ secrets.AUTH0_CLIENT_SECRET }} AUTH0_MANAGEMENT_CLIENT_ID: ${{ secrets.AUTH0_MANAGEMENT_CLIENT_ID }} AUTH0_MANAGEMENT_CLIENT_SECRET: ${{ secrets.AUTH0_MANAGEMENT_CLIENT_SECRET }} GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }} GOOGLE_MAPS_MAP_ID: ${{ secrets.GOOGLE_MAPS_MAP_ID }} CF_DNS_API_TOKEN: ${{ secrets.CF_DNS_API_TOKEN }} RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} - name: Initialize data directories run: | cd "$DEPLOY_PATH" sudo mkdir -p data/backups data/documents data/traefik sudo chown -R 1001:1001 data/backups data/documents sudo chmod 755 data/backups data/documents # Traefik acme.json requires 600 permissions if [ ! -f data/traefik/acme.json ]; then touch data/traefik/acme.json fi chmod 600 data/traefik/acme.json - name: Pull new images run: | docker pull $BACKEND_IMAGE docker pull $FRONTEND_IMAGE - name: Start target stack run: | cd "$DEPLOY_PATH" export BACKEND_IMAGE=$BACKEND_IMAGE export FRONTEND_IMAGE=$FRONTEND_IMAGE docker compose -f $COMPOSE_FILE -f $COMPOSE_BLUE_GREEN up -d \ mvp-frontend-$TARGET_STACK mvp-backend-$TARGET_STACK - name: Wait for stack initialization run: sleep 10 - name: Run health check run: | chmod +x "$GITHUB_WORKSPACE/scripts/ci/health-check.sh" "$GITHUB_WORKSPACE/scripts/ci/health-check.sh" $TARGET_STACK $HEALTH_CHECK_TIMEOUT - name: Start Traefik run: | cd "$DEPLOY_PATH" docker compose -f $COMPOSE_FILE -f $COMPOSE_BLUE_GREEN up -d mvp-traefik - name: Wait for Traefik run: | echo "Waiting for Traefik to be healthy..." timeout 30 bash -c "until docker inspect --format='{{.State.Health.Status}}' mvp-traefik 2>/dev/null | grep -q healthy; do sleep 2; done" || { echo "Traefik health check timed out, checking status..." docker inspect --format='{{.State.Status}}' mvp-traefik docker logs mvp-traefik --tail 20 exit 1 } echo "Traefik is healthy" - name: Switch traffic run: | chmod +x "$GITHUB_WORKSPACE/scripts/ci/switch-traffic.sh" "$GITHUB_WORKSPACE/scripts/ci/switch-traffic.sh" $TARGET_STACK instant - name: Update deployment state run: | cd "$DEPLOY_PATH" STATE_FILE="config/deployment/state.json" if [ -f "$STATE_FILE" ] && command -v jq &> /dev/null; then TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") jq --arg commit "${{ inputs.image_tag }}" \ --arg ts "$TIMESTAMP" \ '.last_deployment = $ts | .last_deployment_commit = $commit | .last_deployment_status = "success" | .rollback_available = true' \ "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE" fi # ============================================ # VERIFY PROD - External health check # ============================================ verify-prod: name: Verify Production runs-on: prod needs: [validate, deploy-prod] env: TARGET_STACK: ${{ needs.validate.outputs.target_stack }} steps: - name: Wait for routing propagation run: sleep 5 - name: External health check run: | REQUIRED_FEATURES='["admin","auth","onboarding","vehicles","documents","fuel-logs","stations","maintenance","platform","notifications","user-profile","user-preferences","user-export"]' for i in 1 2 3 4 5 6; do RESPONSE=$(curl -sf https://motovaultpro.com/api/health 2>/dev/null) || { echo "Attempt $i/6: Connection failed, waiting 10s..." sleep 10 continue } # Check status is "healthy" STATUS=$(echo "$RESPONSE" | jq -r '.status') if [ "$STATUS" != "healthy" ]; then echo "Attempt $i/6: Status is '$STATUS', not 'healthy'. Waiting 10s..." sleep 10 continue fi # Check all required features are present MISSING=$(echo "$RESPONSE" | jq -r --argjson required "$REQUIRED_FEATURES" ' $required - .features | if length > 0 then . else empty end | @json ') if [ -n "$MISSING" ]; then echo "Attempt $i/6: Missing features: $MISSING. Waiting 10s..." sleep 10 continue fi FEATURE_COUNT=$(echo "$RESPONSE" | jq '.features | length') echo "OK: Production health check passed - status: healthy, features: $FEATURE_COUNT" exit 0 done echo "ERROR: Production health check failed after 6 attempts" echo "Last response: $RESPONSE" exit 1 - name: Verify container status run: | for service in mvp-frontend-$TARGET_STACK mvp-backend-$TARGET_STACK; do status=$(docker inspect --format='{{.State.Status}}' $service 2>/dev/null || echo "not found") health=$(docker inspect --format='{{.State.Health.Status}}' $service 2>/dev/null || echo "unknown") if [ "$status" != "running" ] || [ "$health" != "healthy" ]; then echo "ERROR: $service is not healthy (status: $status, health: $health)" docker logs $service --tail 50 2>/dev/null || true exit 1 fi echo "OK: $service is running and healthy" done # ============================================ # ROLLBACK - Auto-rollback on failure # ============================================ rollback: name: Auto Rollback runs-on: prod needs: [validate, deploy-prod, verify-prod] if: failure() steps: - name: Checkout scripts uses: actions/checkout@v4 with: sparse-checkout: scripts/ sparse-checkout-cone-mode: true fetch-depth: 1 - name: Execute rollback run: | chmod +x "$GITHUB_WORKSPACE/scripts/ci/auto-rollback.sh" "$GITHUB_WORKSPACE/scripts/ci/auto-rollback.sh" "Production verification failed - automatic rollback" - name: Update state run: | cd "$DEPLOY_PATH" STATE_FILE="config/deployment/state.json" if [ -f "$STATE_FILE" ] && command -v jq &> /dev/null; then jq '.last_deployment_status = "rolled_back"' "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE" fi # ============================================ # NOTIFY SUCCESS # ============================================ notify-success: name: Notify Success runs-on: prod needs: [validate, verify-prod] if: success() steps: - name: Checkout scripts only uses: actions/checkout@v4 with: sparse-checkout: scripts/ sparse-checkout-cone-mode: true fetch-depth: 1 - name: Send success notification run: | chmod +x "$GITHUB_WORKSPACE/scripts/ci/notify.sh" "$GITHUB_WORKSPACE/scripts/ci/notify.sh" success "Production deployment successful - ${{ inputs.image_tag }} is now live" ${{ inputs.image_tag }} env: DEPLOY_NOTIFY_EMAIL: ${{ vars.DEPLOY_NOTIFY_EMAIL }} RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} # ============================================ # NOTIFY FAILURE # ============================================ notify-failure: name: Notify Failure runs-on: prod needs: [validate, deploy-prod, verify-prod, rollback] if: failure() steps: - name: Checkout scripts only uses: actions/checkout@v4 with: sparse-checkout: scripts/ sparse-checkout-cone-mode: true fetch-depth: 1 - name: Send failure notification run: | chmod +x "$GITHUB_WORKSPACE/scripts/ci/notify.sh" "$GITHUB_WORKSPACE/scripts/ci/notify.sh" failure "Production deployment failed for ${{ inputs.image_tag }}" ${{ inputs.image_tag }} env: DEPLOY_NOTIFY_EMAIL: ${{ vars.DEPLOY_NOTIFY_EMAIL }} RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}