diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9296665..409c0c7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,6 +4,15 @@ on: push: branches: [main] workflow_dispatch: + inputs: + dry_run: + description: 'Dry run — skip VPS changes, just run quality gates and notify' + required: false + default: 'false' + skip_tests: + description: 'Skip unit tests (emergency deploy only)' + required: false + default: 'false' permissions: contents: read @@ -32,6 +41,9 @@ env: jobs: + # --------------------------------------------------------------------------- + # Job 1: Quality Gates — syntax, tests, audit, metadata, secrets, guards + # --------------------------------------------------------------------------- quality-gates: name: Quality Gates runs-on: ubuntu-latest @@ -39,15 +51,65 @@ jobs: commit_hash: ${{ steps.meta.outputs.commit_hash }} deploy_time: ${{ steps.meta.outputs.deploy_time }} version: ${{ steps.meta.outputs.version }} + started_at: ${{ steps.start.outputs.started_at }} + duration_seconds: ${{ steps.duration.outputs.duration_seconds }} steps: + - name: Record start time + id: start + run: echo "started_at=$(date +%s)" >> "$GITHUB_OUTPUT" + + - name: Guard — only deploy from main + if: github.event_name == 'push' + run: | + if [ "$GITHUB_REF" != "refs/heads/main" ]; then + echo "ERROR: Deploy triggered on non-main ref: $GITHUB_REF" >&2 + exit 1 + fi + echo "✅ Running on refs/heads/main" + - uses: actions/checkout@v4 + - name: Guard — skip if commit says so + run: | + MSG=$(git log -1 --format="%s %b") + if echo "$MSG" | grep -qi '\[skip deploy\]'; then + echo "Commit message contains [skip deploy] — aborting." + exit 1 + fi + echo "✅ No [skip deploy] flag found" + + - name: Verify required secrets exist + env: + _BOT_TOKEN: ${{ secrets.BOT_TOKEN }} + _SERVER_KEY: ${{ secrets.SERVER_KEY }} + _SERVER_HOST: ${{ secrets.SERVER_HOST }} + _SERVER_USER: ${{ secrets.SERVER_USER }} + run: | + MISSING=0 + [ -z "$_BOT_TOKEN" ] && echo "❌ Missing secret: BOT_TOKEN" && MISSING=1 + [ -z "$_SERVER_KEY" ] && echo "❌ Missing secret: SERVER_KEY" && MISSING=1 + [ -z "$_SERVER_HOST" ]&& echo "❌ Missing secret: SERVER_HOST"&& MISSING=1 + [ -z "$_SERVER_USER" ]&& echo "❌ Missing secret: SERVER_USER"&& MISSING=1 + [ "$MISSING" -eq 1 ] && exit 1 + echo "✅ All required secrets present" + - uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' + - name: Verify Node version is 20.x + run: | + echo "Node: $(node -v)" + echo "npm: $(npm -v)" + NODE_MAJOR=$(node -v | sed 's/v\([0-9]*\).*/\1/') + if [ "$NODE_MAJOR" -lt 20 ]; then + echo "ERROR: Node 20+ required, found $(node -v)" >&2 + exit 1 + fi + echo "✅ Node $(node -v) OK" + - name: Install deps run: npm ci @@ -62,11 +124,19 @@ jobs: run: npm run check - name: Tests + if: github.event.inputs.skip_tests != 'true' run: npm test - name: Security audit (non-blocking) run: npm audit --audit-level=high || true + - name: Compute duration + id: duration + run: | + STARTED="${{ steps.start.outputs.started_at }}" + NOW=$(date +%s) + echo "duration_seconds=$((NOW - STARTED))" >> "$GITHUB_OUTPUT" + - name: Notify deploy start env: BOT_TOKEN: ${{ secrets.BOT_TOKEN }} @@ -80,10 +150,14 @@ Version: \`${{ steps.meta.outputs.version }}\` Mode: \`${DEPLOY_MODE}\` By: \`${{ github.actor }}\`" + # --------------------------------------------------------------------------- + # Job 2: Deploy to VPS (skipped on dry-run) + # --------------------------------------------------------------------------- deploy: name: Deploy to VPS runs-on: ubuntu-latest needs: quality-gates + if: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true' }} environment: name: production @@ -120,16 +194,28 @@ By: \`${{ github.actor }}\`" ssh deploy_target " set -e cd /var/www/html/Runewager + echo 'Checking .env exists...' if [ ! -f .env ]; then echo 'ERROR: .env missing on VPS' >&2 exit 1 fi + echo 'Checking port 3000 usage...' lsof -i :3000 || true + echo 'Checking memory/disk...' free -m || true df -h . || true + + echo 'Checking disk free threshold...' + FREE_KB=\$(df . | awk 'NR==2 {print \$4}') + FREE_MB=\$((FREE_KB / 1024)) + if [ \"\$FREE_MB\" -lt 100 ]; then + echo 'WARNING: Less than 100MB free disk space (' \$FREE_MB 'MB). Proceeding with caution.' + else + echo \"✅ Disk: \${FREE_MB}MB free\" + fi " - name: Deploy (git pull origin main only) @@ -156,6 +242,21 @@ By: \`${{ github.actor }}\`" /var/www/html/Runewager/scripts/deploy-port-swap.sh || true " + - name: Download deploy report from VPS + if: always() + run: | + ssh deploy_target "cat /tmp/deploy-report.txt 2>/dev/null || echo 'No deploy report file found on server.'" \ + > /tmp/deploy-report.txt || echo 'No deploy report available.' > /tmp/deploy-report.txt + + - name: Upload deploy report as artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: deploy-summary + path: /tmp/deploy-report.txt + if-no-files-found: ignore + retention-days: 7 + - name: Notify success if: success() env: @@ -173,9 +274,11 @@ Time: \`${{ needs.quality-gates.outputs.deploy_time }}\`" BOT_TOKEN: ${{ secrets.BOT_TOKEN }} run: | PRE="${{ steps.pre.outputs.sha }}" + chmod +x scripts/notify-telegram.sh ./scripts/notify-telegram.sh "❌ *Deploy Failed — Rolling Back* -Previous SHA: \`${PRE}\`" +Previous SHA: \`${PRE}\` +Reason: Deployment failed — see GitHub Actions logs for full stack trace." ssh deploy_target " cd /var/www/html/Runewager @@ -200,16 +303,73 @@ Restored to: \`${PRE}\`" COMMIT="${{ needs.quality-gates.outputs.commit_hash }}" VERSION="${{ needs.quality-gates.outputs.version }}" TIME="${{ needs.quality-gates.outputs.deploy_time }}" + DURATION="${{ needs.quality-gates.outputs.duration_seconds }}" - ssh deploy_target "cat /tmp/deploy-report.txt 2>/dev/null || echo 'No deploy report file found on server.'" > /tmp/deploy-report.txt - - REPORT_BODY=$(cat /tmp/deploy-report.txt) + REPORT_BODY=$(cat /tmp/deploy-report.txt 2>/dev/null || echo "No deploy report available.") if [ "$STATUS" = "success" ]; then - MSG="🎉 *Final Deploy Report*\nStatus: ✅ Success\nCommit: \`$COMMIT\`\nVersion: \`$VERSION\`\nTime: \`$TIME\`\n\n$REPORT_BODY" + MSG="🎉 *Final Deploy Report* +Status: ✅ Success +Commit: \`$COMMIT\` +Version: \`$VERSION\` +Time: \`$TIME\` +Duration: \`${DURATION}s\` + +$REPORT_BODY" else - MSG="⚠️ *Final Deploy Report*\nStatus: ❌ Failure\nCommit: \`$COMMIT\`\nVersion: \`$VERSION\`\nTime: \`$TIME\`\nReason: Deployment failed — see GitHub Actions logs.\n\n$REPORT_BODY" + MSG="⚠️ *Final Deploy Report* +Status: ❌ Failure +Commit: \`$COMMIT\` +Version: \`$VERSION\` +Time: \`$TIME\` +Duration: \`${DURATION}s\` +Reason: Deployment failed — see GitHub Actions logs. + +$REPORT_BODY" fi chmod +x scripts/notify-telegram.sh ./scripts/notify-telegram.sh "$MSG" + + - name: Post-deploy health check (30s delay) + if: success() + env: + BOT_TOKEN: ${{ secrets.BOT_TOKEN }} + run: | + echo "Waiting 30 seconds before post-deploy health check..." + sleep 30 + HEALTH_STATUS="FAIL" + if ssh deploy_target "curl -fsS http://127.0.0.1:3000/health" >/dev/null 2>&1; then + HEALTH_STATUS="OK" + fi + chmod +x scripts/notify-telegram.sh + ./scripts/notify-telegram.sh "📡 *Post-Deploy Health Check (30s)* +Status: \`${HEALTH_STATUS}\` +Commit: \`${{ needs.quality-gates.outputs.commit_hash }}\`" + + # --------------------------------------------------------------------------- + # Job 3: Dry-run report (only when dry_run=true) + # --------------------------------------------------------------------------- + dry-run-report: + name: Dry Run Report + runs-on: ubuntu-latest + needs: quality-gates + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true' }} + + steps: + - uses: actions/checkout@v4 + + - name: Send dry-run notification + env: + BOT_TOKEN: ${{ secrets.BOT_TOKEN }} + run: | + chmod +x scripts/notify-telegram.sh + ./scripts/notify-telegram.sh "🧪 *Dry Run Only — No VPS Changes* +Repo: \`${{ github.repository }}\` +Branch: \`${{ github.ref_name }}\` +Commit: \`${{ needs.quality-gates.outputs.commit_hash }}\` +Version: \`${{ needs.quality-gates.outputs.version }}\` +Time: \`${{ needs.quality-gates.outputs.deploy_time }}\` +By: \`${{ github.actor }}\` + +Quality gates passed. VPS was NOT modified." diff --git a/index.js b/index.js index d281bbd..b7fc7cd 100644 --- a/index.js +++ b/index.js @@ -2549,6 +2549,45 @@ bot.command('health', async (ctx) => { } }); +bot.command('admin_notify', safeAdminHandler('admin_notify', { usage: '/admin_notify ', example: '/admin_notify Deploying now' }, async (ctx) => { + if (!requireAdmin(ctx)) return; + const text = (ctx.message.text || '').replace(/^\/admin_notify\s*/i, '').trim(); + if (!text) { + return sendCommandError(ctx, { command: 'admin_notify', reason: 'No message provided.', howToUse: '/admin_notify ', example: '/admin_notify Deploying now' }); + } + await notifyAdminsPlain(`📣 [Admin Broadcast]\n${text}`); + await ctx.reply('✅ Admin notification sent.'); +})); + +bot.command('deploy_status', safeAdminHandler('deploy_status', { usage: '/deploy_status', example: '/deploy_status' }, async (ctx) => { + if (!requireAdmin(ctx)) return; + const { exec } = require('child_process'); + exec('cat /tmp/deploy-report.txt 2>/dev/null || echo "No deploy report found."', { timeout: 5000 }, async (err, stdout) => { + const output = (stdout || '').trim() || (err ? `Error: ${err.message}` : 'No deploy report found.'); + const chunks = []; + for (let i = 0; i < output.length; i += 3900) chunks.push(output.slice(i, i + 3900)); + for (const chunk of chunks) { + await ctx.reply(`📋 *Deploy Report*\n\n\`\`\`\n${chunk}\n\`\`\``, { parse_mode: 'Markdown' }).catch(() => ctx.reply(chunk)); + } + }); +})); + +bot.command('logs', safeAdminHandler('logs', { usage: '/logs [lines]', example: '/logs 50' }, async (ctx) => { + if (!requireAdmin(ctx)) return; + const parts = (ctx.message.text || '').trim().split(/\s+/); + const lines = Math.min(Number(parts[1]) || 50, 200); + const { exec } = require('child_process'); + const cmd = `journalctl -u runewager.service -n ${lines} --no-pager 2>/dev/null || tail -n ${lines} /tmp/runewager-3000.log 2>/dev/null || echo "No logs found."`; + exec(cmd, { timeout: 8000 }, async (err, stdout) => { + const output = (stdout || '').trim() || (err ? `Error: ${err.message}` : 'No logs found.'); + const chunks = []; + for (let i = 0; i < output.length; i += 3900) chunks.push(output.slice(i, i + 3900)); + for (const chunk of chunks) { + await ctx.reply(`📜 *Logs (last ${lines} lines)*\n\n\`\`\`\n${chunk}\n\`\`\``, { parse_mode: 'Markdown' }).catch(() => ctx.reply(chunk)); + } + }); +})); + bot.command('version', async (ctx) => { if (!requireAdmin(ctx)) return; const commitHash = process.env.COMMIT_HASH || 'local';