name: Deploy to Production on: push: branches: [main] workflow_dispatch: inputs: environment: description: 'Target environment' required: true default: 'production' type: choice options: - production - staging skip_tests: description: 'Skip E2E tests (emergency deploy only)' required: false default: false type: boolean # Prevent concurrent deployments concurrency: group: deploy-${{ github.ref }} cancel-in-progress: false # Never cancel a deployment in progress env: NODE_VERSION: '20' PORT: 10000 jobs: # ============================================================ # Job 1: Build & Verify # ============================================================ build: name: Build & Verify runs-on: ubuntu-latest timeout-minutes: 15 outputs: build-artifact: ${{ steps.artifact-name.outputs.name }} steps: - name: Checkout repository uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - name: Install dependencies run: npm ci --prefer-offline - name: Type check run: npx tsc --noEmit continue-on-error: true # Warn but don't block on type errors - name: Build production bundle run: npm run build env: NODE_ENV: production - name: Verify build output run: | echo "Verifying build output..." test -d dist/ || (echo "ERROR: dist/ directory not found" && exit 1) test -f dist/server/entry.mjs || (echo "ERROR: Server entry point missing" && exit 1) test -d dist/client/ || (echo "ERROR: Client assets missing" && exit 1) echo "Build verification passed" echo "Build size:" du -sh dist/ - name: Generate artifact name id: artifact-name run: echo "name=build-${{ github.sha }}" >> $GITHUB_OUTPUT - name: Upload build artifact uses: actions/upload-artifact@v4 with: name: ${{ steps.artifact-name.outputs.name }} path: | dist/ package.json package-lock.json server.mjs ecosystem.config.cjs .env.example retention-days: 3 # ============================================================ # Job 2: Smoke Tests (Pre-Deploy Gate) # ============================================================ pre-deploy-tests: name: Pre-Deploy Tests runs-on: ubuntu-latest timeout-minutes: 15 needs: build if: inputs.skip_tests != true steps: - name: Checkout repository uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - name: Install dependencies run: npm ci --prefer-offline - name: Install Playwright (Chromium only for speed) run: npx playwright install chromium --with-deps - name: Download build artifact uses: actions/download-artifact@v4 with: name: ${{ needs.build.outputs.build-artifact }} path: . - name: Start server run: npm run start & env: NODE_ENV: production PORT: ${{ env.PORT }} - name: Wait for server run: npx wait-on http://localhost:${{ env.PORT }}/api/health.json --timeout 60000 - name: Run smoke tests run: npx playwright test tests/e2e-smoke-suite.spec.ts --project=chromium --reporter=list env: BASE_URL: http://localhost:${{ env.PORT }} - name: Run critical API tests run: npx playwright test tests/api-integration.spec.ts --project=chromium --reporter=list env: BASE_URL: http://localhost:${{ env.PORT }} - name: Upload test results uses: actions/upload-artifact@v4 if: failure() with: name: pre-deploy-test-failures-${{ github.run_number }} path: | playwright-report/ test-results/ retention-days: 7 # ============================================================ # Job 3a: Deploy to Railway # Activate by setting DEPLOY_TARGET=railway secret # ============================================================ deploy-railway: name: Deploy to Railway runs-on: ubuntu-latest timeout-minutes: 10 needs: [build, pre-deploy-tests] if: | always() && needs.build.result == 'success' && (needs.pre-deploy-tests.result == 'success' || needs.pre-deploy-tests.result == 'skipped') && vars.DEPLOY_TARGET == 'railway' environment: name: ${{ inputs.environment || 'production' }} url: https://workroot.in steps: - name: Checkout repository uses: actions/checkout@v4 - name: Install Railway CLI run: npm install -g @railway/cli - name: Deploy to Railway run: railway up --service workroot-website env: RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }} - name: Verify Railway deployment run: | echo "Waiting for Railway deployment to propagate..." sleep 30 curl --fail --silent --max-time 30 https://workroot.in/api/health.json \ || (echo "Health check failed after Railway deploy" && exit 1) echo "Railway deployment verified" # ============================================================ # Job 3b: Deploy to Render # Activate by setting DEPLOY_TARGET=render secret # ============================================================ deploy-render: name: Deploy to Render runs-on: ubuntu-latest timeout-minutes: 10 needs: [build, pre-deploy-tests] if: | always() && needs.build.result == 'success' && (needs.pre-deploy-tests.result == 'success' || needs.pre-deploy-tests.result == 'skipped') && vars.DEPLOY_TARGET == 'render' environment: name: ${{ inputs.environment || 'production' }} url: https://workroot.in steps: - name: Trigger Render deploy hook run: | curl --fail --silent --show-error \ -X POST "${{ secrets.RENDER_DEPLOY_HOOK_URL }}" \ || (echo "Failed to trigger Render deploy hook" && exit 1) echo "Render deployment triggered" - name: Wait for Render deployment run: | echo "Waiting for Render to deploy (up to 5 minutes)..." for i in $(seq 1 30); do sleep 10 STATUS=$(curl --silent --max-time 10 https://workroot.in/api/health.json | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('status',''))" 2>/dev/null || echo "error") if [ "$STATUS" = "ok" ]; then echo "Render deployment verified after ${i}0 seconds" exit 0 fi echo "Attempt $i/30: status=$STATUS, retrying..." done echo "ERROR: Render deployment health check timed out" exit 1 # ============================================================ # Job 3c: Deploy to VPS (SSH + PM2) # Activate by setting DEPLOY_TARGET=vps secret # ============================================================ deploy-vps: name: Deploy to VPS (PM2) runs-on: ubuntu-latest timeout-minutes: 20 needs: [build, pre-deploy-tests] if: | always() && needs.build.result == 'success' && (needs.pre-deploy-tests.result == 'success' || needs.pre-deploy-tests.result == 'skipped') && vars.DEPLOY_TARGET == 'vps' environment: name: ${{ inputs.environment || 'production' }} url: https://workroot.in steps: - name: Checkout repository uses: actions/checkout@v4 - name: Download build artifact uses: actions/download-artifact@v4 with: name: ${{ needs.build.outputs.build-artifact }} path: build-output/ - name: Setup SSH run: | mkdir -p ~/.ssh echo "${{ secrets.VPS_SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key chmod 600 ~/.ssh/deploy_key echo "${{ secrets.VPS_HOST_KEY }}" >> ~/.ssh/known_hosts - name: Create deployment package run: | tar -czf deploy-package.tar.gz -C build-output . echo "Deployment package created: $(du -sh deploy-package.tar.gz | cut -f1)" - name: Upload package to VPS run: | scp -i ~/.ssh/deploy_key \ -o StrictHostKeyChecking=yes \ deploy-package.tar.gz \ ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/tmp/workroot-deploy-${{ github.sha }}.tar.gz - name: Deploy on VPS run: | ssh -i ~/.ssh/deploy_key \ -o StrictHostKeyChecking=yes \ ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \ 'bash -s' << 'DEPLOY_SCRIPT' set -e DEPLOY_DIR="/var/www/workroot" BACKUP_DIR="/var/www/workroot-backup-$(date +%Y%m%d-%H%M%S)" DEPLOY_PKG="/tmp/workroot-deploy-${{ github.sha }}.tar.gz" echo "=== Starting deployment ===" echo "Deploy time: $(date)" echo "Commit: ${{ github.sha }}" # Step 1: Backup current deployment if [ -d "$DEPLOY_DIR" ]; then echo "Backing up current deployment to $BACKUP_DIR..." cp -r "$DEPLOY_DIR" "$BACKUP_DIR" fi # Step 2: Extract new deployment echo "Extracting deployment package..." mkdir -p "$DEPLOY_DIR" tar -xzf "$DEPLOY_PKG" -C "$DEPLOY_DIR" # Step 3: Install production dependencies echo "Installing production dependencies..." cd "$DEPLOY_DIR" npm ci --omit=dev --prefer-offline # Step 4: Ensure logs directory exists mkdir -p logs # Step 5: Reload PM2 (zero-downtime restart) echo "Reloading PM2..." if pm2 list | grep -q "workroot-website"; then pm2 reload ecosystem.config.cjs --update-env else pm2 start ecosystem.config.cjs --env production pm2 save fi # Step 6: Health check echo "Running health check..." sleep 5 for i in $(seq 1 12); do STATUS=$(curl --silent --max-time 5 http://localhost:10000/api/health.json | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('status',''))" 2>/dev/null || echo "error") if [ "$STATUS" = "ok" ]; then echo "Health check passed after ${i}0 seconds" break fi if [ $i -eq 12 ]; then echo "ERROR: Health check failed after 2 minutes" echo "=== Rolling back ===" cp -r "$BACKUP_DIR/." "$DEPLOY_DIR/" cd "$DEPLOY_DIR" npm ci --omit=dev --prefer-offline pm2 reload ecosystem.config.cjs --update-env exit 1 fi echo "Attempt $i/12: status=$STATUS, retrying..." sleep 10 done # Step 7: Cleanup rm -f "$DEPLOY_PKG" rm -rf "$BACKUP_DIR" echo "=== Deployment complete ===" pm2 status DEPLOY_SCRIPT # ============================================================ # Job 3d: Deploy to Fly.io # Activate by setting DEPLOY_TARGET=fly secret # ============================================================ deploy-fly: name: Deploy to Fly.io runs-on: ubuntu-latest timeout-minutes: 15 needs: [build, pre-deploy-tests] if: | always() && needs.build.result == 'success' && (needs.pre-deploy-tests.result == 'success' || needs.pre-deploy-tests.result == 'skipped') && vars.DEPLOY_TARGET == 'fly' environment: name: ${{ inputs.environment || 'production' }} url: https://workroot.in steps: - name: Checkout repository uses: actions/checkout@v4 - name: Setup Fly CLI uses: superfly/flyctl-actions/setup-flyctl@master - name: Deploy to Fly.io run: flyctl deploy --remote-only --wait-timeout 300 env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} - name: Verify Fly.io deployment run: | sleep 15 flyctl status curl --fail --silent --max-time 30 https://workroot.in/api/health.json \ || (echo "Health check failed after Fly.io deploy" && exit 1) echo "Fly.io deployment verified" env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} # ============================================================ # Job 4: Post-Deploy Verification # ============================================================ post-deploy-verify: name: Post-Deploy Verification runs-on: ubuntu-latest timeout-minutes: 15 needs: [deploy-railway, deploy-render, deploy-vps, deploy-fly] if: | always() && ( needs.deploy-railway.result == 'success' || needs.deploy-render.result == 'success' || needs.deploy-vps.result == 'success' || needs.deploy-fly.result == 'success' ) steps: - name: Checkout repository uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - name: Install dependencies run: npm ci --prefer-offline - name: Install Playwright run: npx playwright install chromium --with-deps - name: Run production smoke tests run: | npx playwright test tests/e2e-smoke-suite.spec.ts \ --project=chromium \ --reporter=list env: BASE_URL: https://workroot.in - name: Verify critical endpoints run: | echo "Checking production endpoints..." # Health check HEALTH=$(curl --silent --max-time 10 https://workroot.in/api/health.json) echo "Health: $HEALTH" # Homepage HTTP_STATUS=$(curl --silent --max-time 10 -o /dev/null -w "%{http_code}" https://workroot.in/) echo "Homepage: HTTP $HTTP_STATUS" [ "$HTTP_STATUS" = "200" ] || (echo "Homepage returned $HTTP_STATUS" && exit 1) # Contact page HTTP_STATUS=$(curl --silent --max-time 10 -o /dev/null -w "%{http_code}" https://workroot.in/contact) echo "Contact page: HTTP $HTTP_STATUS" [ "$HTTP_STATUS" = "200" ] || (echo "Contact page returned $HTTP_STATUS" && exit 1) # Sitemap HTTP_STATUS=$(curl --silent --max-time 10 -o /dev/null -w "%{http_code}" https://workroot.in/sitemap.xml) echo "Sitemap: HTTP $HTTP_STATUS" [ "$HTTP_STATUS" = "200" ] || (echo "Sitemap returned $HTTP_STATUS" && exit 1) echo "All critical endpoints verified" - name: Upload post-deploy results uses: actions/upload-artifact@v4 if: always() with: name: post-deploy-results-${{ github.run_number }} path: | playwright-report/ test-results/ retention-days: 14 # ============================================================ # Job 5: Notify on Failure # ============================================================ notify-failure: name: Notify on Failure runs-on: ubuntu-latest needs: [build, pre-deploy-tests, post-deploy-verify] if: | always() && ( needs.build.result == 'failure' || needs.pre-deploy-tests.result == 'failure' || needs.post-deploy-verify.result == 'failure' ) steps: - name: Create failure summary run: | echo "## Deployment Failed" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "| Stage | Status |" >> $GITHUB_STEP_SUMMARY echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY echo "| Build | ${{ needs.build.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Pre-Deploy Tests | ${{ needs.pre-deploy-tests.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Post-Deploy Verify | ${{ needs.post-deploy-verify.result }} |" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Branch:** ${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY echo "**Commit:** ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY echo "**Author:** ${{ github.actor }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "Check the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details." >> $GITHUB_STEP_SUMMARY # Uncomment and configure one notification method: # Slack notification # - name: Notify Slack # uses: slackapi/slack-github-action@v1.27.0 # with: # payload: | # { # "text": "Deployment failed on ${{ github.ref_name }} by ${{ github.actor }}", # "attachments": [{ # "color": "danger", # "text": "Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" # }] # } # env: # SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} # SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK