Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 166 additions & 6 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -32,22 +41,75 @@ env:

jobs:

# ---------------------------------------------------------------------------
# Job 1: Quality Gates — syntax, tests, audit, metadata, secrets, guards
# ---------------------------------------------------------------------------
quality-gates:
name: Quality Gates
runs-on: ubuntu-latest
outputs:
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

Expand All @@ -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 }}
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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."
39 changes: 39 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2549,6 +2549,45 @@ bot.command('health', async (ctx) => {
}
});

bot.command('admin_notify', safeAdminHandler('admin_notify', { usage: '/admin_notify <message>', 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 <message>', 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';
Expand Down
Loading