diff --git a/.github/settings.yml b/.github/settings.yml new file mode 100644 index 0000000..119e9b9 --- /dev/null +++ b/.github/settings.yml @@ -0,0 +1,29 @@ +# Probot Settings App — https://github.com/repository-settings/app +# Syncs repository settings on push to the default branch. + +repository: + has_issues: true + has_projects: false + has_wiki: false + has_downloads: false + default_branch: main + allow_squash_merge: true + allow_merge_commit: true + allow_rebase_merge: true + delete_branch_on_merge: true + +branches: + - name: main + protection: + required_pull_request_reviews: + required_approving_review_count: 0 + dismiss_stale_reviews: false + require_code_owner_reviews: false + required_status_checks: + strict: false + contexts: [] + enforce_admins: false + restrictions: null + required_linear_history: false + allow_force_pushes: true + allow_deletions: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ecb4ac..c3dfe30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,13 +13,15 @@ on: pull_request: push: branches-ignore: [main] + workflow_dispatch: permissions: - contents: read + contents: write + pull-requests: write concurrency: group: ci-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false env: NODE_ENV: test @@ -35,8 +37,6 @@ jobs: name: Validate runs-on: ubuntu-latest timeout-minutes: 10 - environment: - name: production steps: - name: Checkout @@ -106,8 +106,6 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 needs: validate - environment: - name: production steps: - name: Checkout diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3325f99..c906ba1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,6 +3,8 @@ name: Deploy on: push: branches: [main] + pull_request: + types: [closed] workflow_dispatch: inputs: dry_run: @@ -15,7 +17,8 @@ on: default: 'false' permissions: - contents: read + contents: write + pull-requests: write env: BOT_TOKEN: ${{ secrets.BOT_TOKEN }} @@ -47,6 +50,13 @@ jobs: quality-gates: name: Quality Gates runs-on: ubuntu-latest + # Run on: direct push to main, merged PR to main, or manual workflow_dispatch. + # Closed-without-merge PRs are excluded by the merged == true check. + if: > + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && + github.event.pull_request.merged == true) outputs: commit_hash: ${{ steps.meta.outputs.commit_hash }} deploy_time: ${{ steps.meta.outputs.deploy_time }} @@ -59,14 +69,13 @@ jobs: id: start run: echo "started_at=$(date +%s)" >> "$GITHUB_OUTPUT" - - name: Guard — only deploy from main - if: github.event_name == 'push' + - name: Guard — verify valid merge to main + if: github.event_name == 'pull_request' 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" + echo "PR #${{ github.event.pull_request.number }}: ${{ github.event.pull_request.title }}" + echo "Merged into: ${{ github.event.pull_request.base.ref }}" + echo "Merge commit: ${{ github.event.pull_request.merge_commit_sha }}" + echo "✅ Valid merge to main confirmed — proceeding with quality gates" - uses: actions/checkout@v4 @@ -151,22 +160,26 @@ Mode: \`${DEPLOY_MODE}\` By: \`${{ github.actor }}\`" # --------------------------------------------------------------------------- - # Job 2: Deploy to VPS — DISABLED - # Deployments are performed directly on the VPS by running deploy.sh. - # GitHub Actions must NEVER launch, restart, or host the bot. - # To deploy: SSH into the VPS and run: bash /var/www/html/Runewager/deploy.sh + # Job 2: Deploy to VPS — SSH into VPS and run deploy.sh + # Only triggers after quality gates pass on a valid PR merge to main. # --------------------------------------------------------------------------- deploy: - name: Deploy to VPS (DISABLED — run deploy.sh on VPS directly) + name: Deploy to VPS runs-on: ubuntu-latest needs: quality-gates - if: false # GitHub must never trigger VPS restarts or bot launches + if: needs.quality-gates.result == 'success' environment: name: production + env: + DEPLOY_PASS: ${{ secrets.DEPLOY_PASS }} + steps: - uses: actions/checkout@v4 + - name: Install sshpass (password SSH fallback) + run: sudo apt-get install -y sshpass + - name: Setup SSH env: SERVER_KEY: ${{ secrets.SERVER_KEY }} @@ -221,29 +234,33 @@ By: \`${{ github.actor }}\`" fi " - - name: Deploy (git pull origin main only) + - name: Deploy via deploy.sh (with password fallback) + env: + SERVER_HOST: ${{ secrets.SERVER_HOST }} + SERVER_USER: ${{ secrets.SERVER_USER }} run: | - ssh deploy_target " - set -e - cd /var/www/html/Runewager - git fetch origin main - git reset --hard origin/main - echo 'Updated to:' \$(git rev-parse --short HEAD) - " + DEPLOY_CMD="bash /var/www/html/Runewager/deploy.sh github" - - name: Install deps on VPS - run: | - ssh deploy_target " - cd /var/www/html/Runewager - npm ci --omit=dev - " - - - name: Port-swap deploy (guarded, max 3 restarts) - run: | - ssh deploy_target " - chmod +x /var/www/html/Runewager/scripts/deploy-port-swap.sh || true - /var/www/html/Runewager/scripts/deploy-port-swap.sh || true - " + # First attempt: SSH with key + if ssh deploy_target "$DEPLOY_CMD"; then + echo "✅ Deployed via SSH key" + else + echo "⚠️ SSH key attempt failed — waiting 120 seconds before password retry…" + sleep 120 + + # Second attempt: password fallback via sshpass + if sshpass -p "$DEPLOY_PASS" ssh \ + -o StrictHostKeyChecking=no \ + -o ConnectTimeout=15 \ + "${SERVER_USER}@${SERVER_HOST}" "$DEPLOY_CMD"; then + echo "✅ Deployed via password fallback" + else + echo "❌ Both SSH attempts failed — sending admin alert" + chmod +x scripts/notify-telegram.sh + ./scripts/notify-telegram.sh "❌ Deploy failed — SSH could not connect to VPS after key + password retry. Manual intervention required." + exit 1 + fi + fi - name: Download deploy report from VPS if: always() diff --git a/deploy.sh b/deploy.sh index 3478e54..f4a5b36 100755 --- a/deploy.sh +++ b/deploy.sh @@ -2,53 +2,151 @@ # ============================================================= # Runewager — VPS Deployment Script # -# Run this directly on the VPS to deploy the latest code. -# GitHub Actions does NOT run this script — it is VPS-only. -# # Usage: -# bash /var/www/html/Runewager/deploy.sh +# bash /var/www/html/Runewager/deploy.sh [source] +# +# source: github | bot | vps (default: vps) +# github — triggered by a GitHub Actions merged-PR workflow +# bot — triggered by the /deploy Telegram command +# vps — triggered by a manual SSH/shell call on the VPS # # What it does: -# 1. git fetch + reset to origin/main (clean, no merge conflicts) -# 2. npm ci --omit=dev (install/update production deps) -# 3. systemctl restart runewager -# 4. Confirm service status +# 0. Skip deploy if VPS already has the latest commit +# 1. Stop the systemd service (prevents file locks) +# 2. git fetch --all + git reset --hard origin/main + git clean +# 3. npm ci --omit=dev (install/update production deps) +# 4. systemctl start runewager +# 5. Confirm service status +# +# Admin notifications (silent Telegram messages) are sent at +# every step so admins always know what is happening. # ============================================================= set -euo pipefail PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" APP_NAME="runewager" +DEPLOY_SOURCE="${1:-vps}" say() { printf '[%s] %s\n' "$APP_NAME" "$*"; } warn() { printf '[%s][warn] %s\n' "$APP_NAME" "$*" >&2; } say "=== Runewager VPS Deployment ===" say "Project: $PROJECT_DIR" +say "Source: $DEPLOY_SOURCE" say "Date: $(date -u '+%Y-%m-%dT%H:%M:%SZ')" cd "$PROJECT_DIR" # --------------------------------------------------------- -# 0) Stop service before touching files (prevents file locks) +# Load .env so BOT_TOKEN and ADMIN_IDS are available for +# Telegram notifications throughout the script. +# --------------------------------------------------------- +if [[ -f "$PROJECT_DIR/.env" ]]; then + set -o allexport + # shellcheck source=/dev/null + source "$PROJECT_DIR/.env" + set +o allexport +fi + +# --------------------------------------------------------- +# Silent Telegram admin notification helper +# Uses curl form-encoding so emoji and UTF-8 are handled +# correctly without requiring jq or python3. +# disable_notification=true → no phone buzz/sound. +# --------------------------------------------------------- +send_admin() { + local msg="$1" + [[ -z "${BOT_TOKEN:-}" ]] && return 0 + [[ -z "${ADMIN_IDS:-}" ]] && return 0 + IFS=',' read -ra _IDS <<< "$ADMIN_IDS" + for _chat_id in "${_IDS[@]}"; do + _chat_id="${_chat_id// /}" + [[ -z "$_chat_id" ]] && continue + curl -s --max-time 10 \ + "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \ + --data-urlencode "text=${msg}" \ + --data "chat_id=${_chat_id}" \ + --data "disable_notification=true" \ + >/dev/null 2>&1 || true + done +} + +# --------------------------------------------------------- +# Error trap — always notify admins if the script fails +# --------------------------------------------------------- +_on_error() { + local line="$1" + warn "Script failed at line $line" + send_admin "❌ Deploy failed at line $line — check VPS logs. (source: $DEPLOY_SOURCE)" +} +trap '_on_error "$LINENO"' ERR + +# --------------------------------------------------------- +# 0) Skip deploy if VPS already has the latest commit +# Prevents revert commits and accidental re-deploys from +# stopping the service for nothing. +# --------------------------------------------------------- +REMOTE_HASH=$(git ls-remote origin -h refs/heads/main 2>/dev/null | cut -f1 || true) +LOCAL_HASH=$(git rev-parse HEAD 2>/dev/null || true) +if [[ -n "$REMOTE_HASH" && "$REMOTE_HASH" = "$LOCAL_HASH" ]]; then + say "Already running latest code ($(git rev-parse --short HEAD)) — skipping deploy." + send_admin "✅ Bot is already running the latest version (commit: $(git rev-parse --short HEAD))." + exit 0 +fi + +# Resolve human-readable source label for notifications +case "$DEPLOY_SOURCE" in + github) SOURCE_LABEL="GitHub merge" ;; + bot) SOURCE_LABEL="/deploy command" ;; + *) SOURCE_LABEL="VPS manual restart" ;; +esac + +send_admin "🔄 Deploy started (source: $SOURCE_LABEL)" + +# --------------------------------------------------------- +# 1) Stop service before touching files (prevents file locks) # --------------------------------------------------------- if command -v systemctl >/dev/null 2>&1; then - say "Step 0/4 — Stopping ${APP_NAME} service…" + say "Step 1/4 — Stopping ${APP_NAME} service…" + send_admin "🛑 Stopping bot service…" systemctl stop "${APP_NAME}.service" || true fi # --------------------------------------------------------- -# 1) Pull latest code from origin/main (hard reset — no merge conflicts) +# 2) Pull latest code from origin/main (hard reset) +# IMPORTANT: git operations are wrapped in a conditional so +# that if fetch or reset fails (network, auth, bad ref) we +# can restart the existing service before exiting — the +# service was already stopped above, so without this guard +# a git failure would leave the bot permanently down. # --------------------------------------------------------- -say "Step 1/4 — Fetching latest code from origin/main…" -git fetch --all -git reset --hard origin/main -git clean -fd -say "Now at: $(git rev-parse --short HEAD)" +say "Step 2/4 — Fetching latest code from origin/main…" +send_admin "📥 Pulling latest code…" + +GIT_FETCH_OUT="" +GIT_RESET_OUT="" +if GIT_FETCH_OUT=$(git fetch --all 2>&1) \ + && GIT_RESET_OUT=$(git reset --hard origin/main 2>&1); then + git clean -fd + say "Now at: $(git rev-parse --short HEAD)" +else + # Capture whatever output we have for diagnostics + GIT_DIAG="fetch: ${GIT_FETCH_OUT} | reset: ${GIT_RESET_OUT}" + warn "Git fetch/reset failed — restarting existing service without new code" + warn "Git output: $GIT_DIAG" + send_admin "❌ Git update failed — restarted old code. Error: ${GIT_DIAG:0:200} (source: $DEPLOY_SOURCE)" + # Restart the service so the bot comes back up on the old code + if command -v systemctl >/dev/null 2>&1; then + systemctl start "${APP_NAME}.service" || true + fi + exit 1 +fi # --------------------------------------------------------- -# 2) Install / update production dependencies +# 3) Install / update production dependencies # --------------------------------------------------------- -say "Step 2/4 — Installing production dependencies…" +say "Step 3/4 — Installing production dependencies…" +send_admin "📦 Installing dependencies…" if [[ -f package-lock.json ]]; then npm ci --omit=dev else @@ -57,23 +155,29 @@ fi say "Dependencies installed." # --------------------------------------------------------- -# 3) Restart via systemctl +# 4) Start bot via systemctl # --------------------------------------------------------- -say "Step 3/4 — Restarting bot via systemctl…" +say "Step 4/4 — Starting bot via systemctl…" +send_admin "🚀 Starting bot service…" if command -v systemctl >/dev/null 2>&1; then - systemctl restart "${APP_NAME}.service" - say "Bot restarted." + systemctl start "${APP_NAME}.service" + say "Bot started." else - warn "systemctl not found. Restart manually: node $PROJECT_DIR/index.js" + warn "systemctl not found. Start manually: node $PROJECT_DIR/index.js" fi # --------------------------------------------------------- -# 4) Confirm service status +# 5) Confirm service status # --------------------------------------------------------- -say "Step 4/4 — Confirming bot status…" sleep 3 ACTIVE="$(systemctl is-active "${APP_NAME}.service" 2>/dev/null || echo unknown)" say "Service status: $ACTIVE" +if [[ "$ACTIVE" == "active" ]]; then + send_admin "✅ Deploy complete — bot running latest version (commit: $(git rev-parse --short HEAD))." +else + send_admin "⚠️ Deploy finished but service status is: $ACTIVE — check VPS logs." +fi + say "" say "=== Deployment complete. ===" diff --git a/index.js b/index.js index d70b14f..037f648 100644 --- a/index.js +++ b/index.js @@ -2506,90 +2506,33 @@ bot.command('admin_backup', async (ctx) => { // ========================= // /deploy — admin-only live deployment -// Pulls latest code, installs deps, then restarts the bot process. -// The bot notifies admins again on startup to confirm the restart landed. +// Delegates all work to deploy.sh which handles git, deps, service +// restart, and per-step silent Telegram admin notifications. // ========================= bot.command('deploy', async (ctx) => { if (!requireAdmin(ctx)) return; - const ts = () => new Date().toLocaleTimeString('en-GB', { hour12: false }); + // Persist state before anything changes + try { persistRuntimeState(); } catch (_) { /* non-fatal */ } - // Step 1 — persist state before anything changes - try { persistRuntimeState(); } catch (_) { /* non-fatal — continue deploy */ } - - const status = await ctx.reply( - `🚀 *Deployment started* — ${ts()}\n\n` - + `① Pulling latest code from origin main…`, + await ctx.reply( + `🚀 *Deployment starting* (source: /deploy command)\n\nStep-by-step notifications will follow silently.`, { parse_mode: 'Markdown' }, ); - const edit = (text) => ctx.telegram - .editMessageText(ctx.chat.id, status.message_id, null, text, { parse_mode: 'Markdown' }) - .catch(() => {}); - - // ── Step 1: fetch + hard reset (avoids hang on dirty tree / merge conflicts) ── - const fetchResult = await runCmd('git', ['fetch', '--all'], PROJECT_DIR, 30000); - const gitResult = fetchResult.ok - ? await runCmd('git', ['reset', '--hard', 'origin/main'], PROJECT_DIR, 15000) - : fetchResult; - if (gitResult.ok) { - await runCmd('git', ['clean', '-fd'], PROJECT_DIR, 10000).catch(() => {}); - } - const gitLine = gitResult.ok - ? `✅ *git update:* \`${sanitizeCmdOutput(gitResult.out.split('\n')[0])}\`` - : `⚠️ *git update failed:* \`${sanitizeCmdOutput(gitResult.err || fetchResult.err)}\``; - - await edit( - `🚀 *Deployment in progress* — ${ts()}\n\n` - + `${gitLine}\n\n② Installing dependencies…`, - ); - - // ── Step 2: npm ci ──────────────────────────────────────────── - const npmResult = await runCmd('npm', ['ci', '--omit=dev'], PROJECT_DIR, 180000); - const npmLine = npmResult.ok - ? `✅ *npm ci:* OK` - : `⚠️ *npm ci:* \`${sanitizeCmdOutput(npmResult.err)}\``; - - const allOk = gitResult.ok && npmResult.ok; - - // ── Abort if any step failed — never restart into broken code ─ - if (!allOk) { - await edit( - `🚀 *Deployment summary* — ${ts()}\n\n` - + `${gitLine}\n` - + `${npmLine}\n\n` - + `⚠️ One or more steps failed — restart *aborted* to avoid loading broken code.\n` - + `③ Restart *skipped*.`, - ); - return; - } - - await edit( - `🚀 *Deployment summary* — ${ts()}\n\n` - + `${gitLine}\n` - + `${npmLine}\n\n` - + `✅ All steps succeeded\n\n` - + `③ Restarting bot now…`, - ); - // ── Step 3: notify then restart ─────────────────────────────── - // Notify ALL admins so everyone sees the restart is happening - const restartNote = `🔁 *Bot is restarting* — ${ts()}\nYou will receive a ✅ confirmation message when it comes back online.`; - for (const adminId of ADMIN_IDS) { - bot.telegram.sendMessage(adminId, restartNote, { parse_mode: 'Markdown' }).catch(() => {}); - } + // Spawn deploy.sh detached — it stops + restarts the service, + // sends per-step Telegram notifications via curl, and handles errors. + const deployScript = path.join(PROJECT_DIR, 'deploy.sh'); + const deployProc = spawn('bash', [deployScript, 'bot'], { + detached: true, + stdio: 'ignore', + cwd: PROJECT_DIR, + }); + deployProc.unref(); - // Allow Telegram to deliver messages before the process dies - setTimeout(() => { - // Prefer systemctl restart (systemd handles clean lifecycle) - const svc = spawn('systemctl', ['restart', 'runewager'], { detached: true, stdio: 'ignore' }); - svc.on('error', () => { - // systemctl unavailable — just exit; systemd/nohup will restart us - process.exit(0); - }); - svc.unref(); - // Exit regardless after 2s so the process doesn't linger - setTimeout(() => process.exit(0), 2000); - }, 1500); + // Give Telegram time to deliver the reply before the process is killed + // by deploy.sh's systemctl stop step. + setTimeout(() => process.exit(0), 2000); }); // =========================