diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf9d953..08a70e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,10 @@ on: push: branches-ignore: [main] +# Minimal read-only permissions — no write access needed for CI validation +permissions: + contents: read + jobs: validate: name: Validate diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a1888a5..539f9e9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,18 +8,13 @@ name: Deploy on: push: branches: [main] + # workflow_dispatch has no inputs — build output must not be affected by + # user-supplied parameters (SLSA provenance / supply-chain security requirement) workflow_dispatch: - inputs: - mode: - description: 'Deploy mode' - required: false - default: 'normal' - type: choice - options: - - normal # Full deploy with all checks - - fast # Skip non-critical checks (syntax + test always run) - - dry-run # Simulate everything; do NOT sync files or restart service - - safe # Deploy but pause and require manual Telegram approval + +# Minimal permissions — deploy job needs no write access to repo contents +permissions: + contents: read # ────────────────────────────────────────────────────────────────────────────── # Non-sensitive values (hard-coded per spec) @@ -43,7 +38,7 @@ env: RW_DISCORD_JOIN: "https://discord.gg/runewagers" RW_DISCORD_LINK: "https://discord.com/channels/1100486422395355197/1249181934811349052" RW_DISCORD_SUPPORT: "https://discord.com/channels/1100486422395355197/1249182067296567338" - DEPLOY_MODE: ${{ github.event.inputs.mode || 'normal' }} + DEPLOY_MODE: normal jobs: # ──────────────────────────────────────────────────────────────────────────── @@ -85,10 +80,10 @@ jobs: - name: Run tests run: npm test - # Gate 3: Security audit (non-blocking in fast mode) + # Gate 3: Security audit (always non-blocking — advisories warn, never block deploy) - name: Security audit run: npm audit --audit-level=high - continue-on-error: ${{ env.DEPLOY_MODE == 'fast' }} + continue-on-error: true # Gate 4: Pre-deploy checks (skipped in fast mode) - name: Pre-deploy checks @@ -226,24 +221,24 @@ jobs: .env \ ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}:/var/www/html/Runewager/current/.env - # ── Run smoke test on VPS ───────────────────────────────────────────────── - - name: Smoke test on VPS - id: smoke + # ── npm ci on VPS ───────────────────────────────────────────────────────── + - name: Install dependencies on VPS run: | ssh deploy_target " set -euo pipefail - DEPLOY_DIR=/var/www/html/Runewager/current - chmod +x \$DEPLOY_DIR/scripts/smoke-test.sh - \$DEPLOY_DIR/scripts/smoke-test.sh \$DEPLOY_DIR + cd /var/www/html/Runewager/current + npm ci --omit=dev " - # ── npm ci on VPS ───────────────────────────────────────────────────────── - - name: Install dependencies on VPS + # ── Run smoke test on VPS (after deps installed) ────────────────────────── + - name: Smoke test on VPS + id: smoke run: | ssh deploy_target " set -euo pipefail - cd /var/www/html/Runewager/current - npm ci --omit=dev + DEPLOY_DIR=/var/www/html/Runewager/current + chmod +x \$DEPLOY_DIR/scripts/smoke-test.sh + \$DEPLOY_DIR/scripts/smoke-test.sh \$DEPLOY_DIR " # ── Verify .env on VPS ───────────────────────────────────────────────────── diff --git a/index.js b/index.js index 33e76dc..969daae 100644 --- a/index.js +++ b/index.js @@ -444,7 +444,7 @@ function createDefaultUser(user) { } function getUser(ctx) { - const id = ctx.from.id; + const { id } = ctx.from; if (!userStore.has(id)) userStore.set(id, createDefaultUser(ctx.from)); const user = userStore.get(id); user.tgUsername = ctx.from.username || user.tgUsername; @@ -622,6 +622,7 @@ async function showBonusStatus(ctx, user) { denied: '❌ Denied', bonus_sent: '🎉 Bonus already received', }; + const wagerDisplay = wb.wager7dayTotal > 0 ? `${wb.wager7dayTotal.toLocaleString()} SC` : '(not recorded)'; const lines = [ '🎯 30 SC Wager Bonus — Your Status', '', @@ -629,7 +630,7 @@ async function showBonusStatus(ctx, user) { `Status: ${statusLabels[wb.status] || wb.status}`, `Runewager username: ${user.runewagerUsername || '(not linked)'}`, `Affiliate: ${wb.affiliateSource || 'GambleCodez'}`, - `Wager on file: ${wb.wager7dayTotal > 0 ? `${wb.wager7dayTotal.toLocaleString()} SC` : '(not recorded)'}`, + `Wager on file: ${wagerDisplay}`, ]; if (wb.status === 'denied' && wb.denyReason) { lines.push('', `Denial reason: ${wb.denyReason}`); @@ -1256,7 +1257,10 @@ async function configureBotSurface() { await bot.telegram.setMyCommands(globalCommands, { scope: { type: 'default' } }).catch(() => {}); await bot.telegram.setMyCommands(privateCommands, { scope: { type: 'all_private_chats' } }).catch(() => {}); await bot.telegram.setMyCommands(groupCommands, { scope: { type: 'all_group_chats' } }).catch(() => {}); + // Sequential per-admin command registration — order matters; disable lint rule + // eslint-disable-next-line no-restricted-syntax for (const adminId of ADMIN_IDS) { + // eslint-disable-next-line no-await-in-loop await bot.telegram.setMyCommands([ ...privateCommands, { command: 'admin', description: 'Admin promo controls panel' }, @@ -1733,7 +1737,7 @@ bot.command('deploy', async (ctx) => { const ts = () => new Date().toLocaleTimeString('en-GB', { hour12: false }); // Step 1 — persist state before anything changes - try { persistRuntimeState(); } catch (_) {} + try { persistRuntimeState(); } catch (_) { /* non-fatal — continue deploy */ } const status = await ctx.reply( `🚀 *Deployment started* — ${ts()}\n\n` @@ -2036,7 +2040,9 @@ bot.command('profile', safeStepHandler('profile', async (ctx) => { trackOnboardingProgress(user); const referralCode = referralCodeForUser(user); const badges = Array.from(user.badges.values()); - await ctx.reply(`Profile\nTG: @${user.tgUsername || `user${user.id}`}\nRunewager: ${user.runewagerUsername || 'not linked'}\nXP: ${user.profileXP}\nOnboarding Step: ${onboardingStepLabel(user.onboarding.currentStep)}\nReferral: ${referralCode}\nBadges: ${badges.length ? badges.join(', ') : 'none'}`); + const tgName = user.tgUsername || `user${user.id}`; + const badgeDisplay = badges.length ? badges.join(', ') : 'none'; + await ctx.reply(`Profile\nTG: @${tgName}\nRunewager: ${user.runewagerUsername || 'not linked'}\nXP: ${user.profileXP}\nOnboarding Step: ${onboardingStepLabel(user.onboarding.currentStep)}\nReferral: ${referralCode}\nBadges: ${badgeDisplay}`); })); bot.command('spin', safeStepHandler('spin', async (ctx) => { @@ -2097,8 +2103,9 @@ bot.command('status', async (ctx) => { const user = getUser(ctx); trackOnboardingProgress(user); const step = onboardingStepLabel(user.onboarding.currentStep); + const statusTgName = user.tgUsername || `user${user.id}`; await ctx.reply([ - `👤 Status for @${user.tgUsername || `user${user.id}`}`, + `👤 Status for @${statusTgName}`, `18+: ${user.ageConfirmed ? '✅' : '❌'}`, `Account confirmed: ${user.verified ? '✅' : '❌'}`, `Runewager username: ${user.runewagerUsername || '— not linked'}`, @@ -2451,7 +2458,7 @@ bot.action('confirm_smart_username', async (ctx) => { return; } const normalized = action.data.username; - const wasLinked = !!(user.runewagerUsername && user.runewagerUsername.trim()); + const wasLinked = Boolean(user.runewagerUsername && user.runewagerUsername.trim()); user.runewagerUsername = normalized; user.pendingAction = null; addBadge(user, 'linked_profile'); @@ -2984,8 +2991,11 @@ bot.action('menu_profile_action', async (ctx) => { const referralCode = referralCodeForUser(user); const badges = Array.from(user.badges.values()); const nextStep = getNextOnboardingStep(user); + const profileTgName = user.tgUsername || `user${user.id}`; + const profileRwName = user.runewagerUsername ? `*${user.runewagerUsername}* ✅` : '❌ Not linked — use /linkrunewager'; + const profileBadges = badges.length ? badges.join(', ') : 'none'; await ctx.reply( - `👤 *Your Profile*\n\nTelegram: @${user.tgUsername || `user${user.id}`}\nRunewager: ${user.runewagerUsername ? `*${user.runewagerUsername}* ✅` : '❌ Not linked — use /linkrunewager'}\nXP: ${user.profileXP}\nOnboarding: ${onboardingStepLabel(nextStep)}\nReferral Code: \`${referralCode}\`\nBadges: ${badges.length ? badges.join(', ') : 'none'}`, + `👤 *Your Profile*\n\nTelegram: @${profileTgName}\nRunewager: ${profileRwName}\nXP: ${user.profileXP}\nOnboarding: ${onboardingStepLabel(nextStep)}\nReferral Code: \`${referralCode}\`\nBadges: ${profileBadges}`, { parse_mode: 'Markdown', ...Markup.inlineKeyboard([ @@ -3031,7 +3041,7 @@ bot.action(/^page_giveaways_(\d+)$/, async (ctx) => { bot.action(/^walk_(next|back|done)$/, async (ctx) => { const user = getUser(ctx); - const action = ctx.match[1]; + const [, action] = ctx.match; const prevStep = user.walkthrough.currentStep; if (action === 'next') user.walkthrough.currentStep = Math.min(walkthroughCatalog.length - 1, user.walkthrough.currentStep + 1); if (action === 'back') user.walkthrough.currentStep = Math.max(0, user.walkthrough.currentStep - 1); @@ -3614,7 +3624,7 @@ bot.on('text', async (ctx) => { return; } const normalizedUsername = normalizeRunewagerUsername(text); - const wasLinked = !!(user.runewagerUsername && user.runewagerUsername.trim()); + const wasLinked = Boolean(user.runewagerUsername && user.runewagerUsername.trim()); const returnTo = action.returnTo || null; user.runewagerUsername = normalizedUsername; user.pendingAction = null; @@ -3691,8 +3701,8 @@ bot.on('text', async (ctx) => { await runUserMutation(user.id, async () => { user.wagerBonus.wager7dayTotal = amount; user.wagerBonus.updatedAt = Date.now(); + user.pendingAction = null; }); - user.pendingAction = null; const check = checkBonusEligibility(user); if (!check.ok) { await ctx.reply(check.reason); @@ -3909,7 +3919,6 @@ bot.on('text', async (ctx) => { giveaway.scPerWinner = sc; user.pendingAction = null; await ctx.reply(`Giveaway #${giveaway.id} SC per winner updated to ${sc}.`); - return; } }); @@ -4088,7 +4097,8 @@ function normalizeYesNo(text) { } function createGiveaway(config) { - const id = giveawayStore.counter++; + const id = giveawayStore.counter; + giveawayStore.counter += 1; const now = Date.now(); const durationMs = (config.testMode ? 1 : config.durationMinutes) * 60 * 1000; return { @@ -4240,7 +4250,10 @@ function pickRandomUnique(arr, count) { function renderWinnersList(winners) { if (!winners.length) return 'None'; return winners - .map((w) => `- @${w.tgUsername || `user${w.userId}`} (Runewager: ${w.runewagerUsername})`) + .map((w) => { + const wName = w.tgUsername || `user${w.userId}`; + return `- @${wName} (Runewager: ${w.runewagerUsername})`; + }) .join('\n'); } @@ -4254,7 +4267,8 @@ function renderGiveawaySummary(giveaway) { function renderGiveawayExport(giveaway) { const participants = Array.from(giveaway.participants.values()); - return `EXPORT GIVEAWAY #${giveaway.id}\nStatus: ${giveaway.status}\nChat ID: ${giveaway.chatId}\nCreated: ${new Date(giveaway.createdAt).toISOString()}\nEnd: ${new Date(giveaway.endTime).toISOString()}\nSC each: ${giveaway.scPerWinner}\nWinners count target: ${giveaway.maxWinners}\nParticipants (${participants.length}):\n${participants.map((p) => `- ${p.userId} @${p.tgUsername} | RW: ${p.runewagerUsername}`).join('\n') || 'None'}\nWinners:\n${renderWinnersList(giveaway.winners)}\nPaid out: ${giveaway.paidOut ? 'yes' : 'no'}`; + const participantLines = participants.map((p) => `- ${p.userId} @${p.tgUsername} | RW: ${p.runewagerUsername}`).join('\n') || 'None'; + return `EXPORT GIVEAWAY #${giveaway.id}\nStatus: ${giveaway.status}\nChat ID: ${giveaway.chatId}\nCreated: ${new Date(giveaway.createdAt).toISOString()}\nEnd: ${new Date(giveaway.endTime).toISOString()}\nSC each: ${giveaway.scPerWinner}\nWinners count target: ${giveaway.maxWinners}\nParticipants (${participants.length}):\n${participantLines}\nWinners:\n${renderWinnersList(giveaway.winners)}\nPaid out: ${giveaway.paidOut ? 'yes' : 'no'}`; } function findUserByRunewager(runewagerUsername) { @@ -4275,14 +4289,16 @@ function findUserByTelegramIdOrRunewager(input) { function renderWager30Lookup(u) { const wb = u.wagerBonus; const ts = wb.lastRequestAt ? new Date(wb.lastRequestAt).toISOString() : 'n/a'; + const lookupTgName = u.tgUsername || `user${u.id}`; + const wager7dayDisplay = wb.wager7dayTotal > 0 ? `${wb.wager7dayTotal.toLocaleString()} SC` : '(not recorded)'; return `🔍 30 SC Bonus Record\n` + `TG ID: ${u.id}\n` - + `TG: @${u.tgUsername || `user${u.id}`}\n` + + `TG: @${lookupTgName}\n` + `Runewager: ${u.runewagerUsername || '(not linked)'}\n` + `Affiliate: ${wb.affiliateSource || 'GambleCodez'}\n` + `Status: ${wb.status}\n` + `Attempts: ${wb.attempts}/3\n` - + `7-day wager declared: ${wb.wager7dayTotal > 0 ? `${wb.wager7dayTotal.toLocaleString()} SC` : '(not recorded)'}\n` + + `7-day wager declared: ${wager7dayDisplay}\n` + `Last request: ${ts}\n` + `Deny reason: ${wb.denyReason || 'n/a'}`; } @@ -4405,13 +4421,15 @@ async function bonusAdminListPending(ctx, page = 1) { for (const u of slice) { const wb = u.wagerBonus; const ts = wb.lastRequestAt ? new Date(wb.lastRequestAt).toISOString() : 'n/a'; + const pendingTgName = u.tgUsername || `user${u.id}`; + const pendingWager = wb.wager7dayTotal > 0 ? `${wb.wager7dayTotal.toLocaleString()} SC` : '(not recorded)'; // eslint-disable-next-line no-await-in-loop await ctx.reply( `📋 Pending\n` + `TG ID: ${u.id}\n` - + `TG: @${u.tgUsername || `user${u.id}`}\n` + + `TG: @${pendingTgName}\n` + `Runewager: ${u.runewagerUsername || '(not linked)'}\n` - + `7-day wager: ${wb.wager7dayTotal > 0 ? `${wb.wager7dayTotal.toLocaleString()} SC` : '(not recorded)'}\n` + + `7-day wager: ${pendingWager}\n` + `Attempts: ${wb.attempts}/3\n` + `Submitted: ${ts}`, Markup.inlineKeyboard([ @@ -4445,7 +4463,8 @@ async function bonusAdminApprove(ctx, rawId) { target.wagerBonus.status = 'approved'; target.wagerBonus.updatedAt = Date.now(); }); - await ctx.reply(`✅ Approved. TG ID: ${target.id} (@${target.tgUsername || `user${target.id}`})\n\nNow manually credit the 30 SC on Runewager, then run:\n/bonus sent ${target.id}`); + const approvedName = target.tgUsername || `user${target.id}`; + await ctx.reply(`✅ Approved. TG ID: ${target.id} (@${approvedName})\n\nNow manually credit the 30 SC on Runewager, then run:\n/bonus sent ${target.id}`); try { await bot.telegram.sendMessage(target.id, 'Your 30 SC bonus request was approved. An admin will manually credit your bonus on Runewager shortly.'); } catch (e) { /* ignore */ } @@ -4462,7 +4481,8 @@ async function bonusAdminDeny(ctx, rawId, reason) { target.wagerBonus.denyReason = reason || 'No reason provided'; target.wagerBonus.updatedAt = Date.now(); }); - await ctx.reply(`❌ Denied. TG ID: ${target.id} (@${target.tgUsername || `user${target.id}`})\nReason: ${target.wagerBonus.denyReason}`); + const deniedName = target.tgUsername || `user${target.id}`; + await ctx.reply(`❌ Denied. TG ID: ${target.id} (@${deniedName})\nReason: ${target.wagerBonus.denyReason}`); try { await bot.telegram.sendMessage(target.id, `Your 30 SC bonus request was denied.\nReason: ${target.wagerBonus.denyReason}`); } catch (e) { /* ignore */ } @@ -4479,7 +4499,8 @@ async function bonusAdminSent(ctx, rawId) { target.wagerBonus.status = 'bonus_sent'; target.wagerBonus.updatedAt = Date.now(); }); - await ctx.reply(`📤 Marked as bonus_sent. TG ID: ${target.id} (@${target.tgUsername || `user${target.id}`})`); + const sentName = target.tgUsername || `user${target.id}`; + await ctx.reply(`📤 Marked as bonus_sent. TG ID: ${target.id} (@${sentName})`); try { await bot.telegram.sendMessage(target.id, 'Your 30 SC bonus has been sent. Check your Runewager account.'); } catch (e) { /* ignore */ } @@ -4494,7 +4515,8 @@ async function bonusAdminAdd(ctx, rawId) { target.wagerBonus.attempts = Math.max(target.wagerBonus.attempts, 1); target.wagerBonus.updatedAt = Date.now(); }); - await ctx.reply(`➕ Manual record added. TG ID: ${target.id} (@${target.tgUsername || `user${target.id}`}) — status: bonus_sent`); + const addedName = target.tgUsername || `user${target.id}`; + await ctx.reply(`➕ Manual record added. TG ID: ${target.id} (@${addedName}) — status: bonus_sent`); } function extractGwId(text) { @@ -4581,7 +4603,7 @@ bot.command('testall', async (ctx) => { } catch (e) { fail('User Store', 'userStore_crud', e.message); } if (Array.isArray(walkthroughCatalog) && walkthroughCatalog.length > 0) pass('User Store', `walkthrough_catalog (${walkthroughCatalog.length} steps)`); else fail('User Store', 'walkthrough_catalog', 'Empty or not an array'); - for (let i = 0; i <= 5; i++) { + for (let i = 0; i <= 5; i += 1) { const label = onboardingStepLabel(i); if (label || i === 5) pass('User Store', `onboarding_label_step_${i}`); else fail('User Store', `onboarding_label_step_${i}`, 'Empty/unexpected'); @@ -4653,7 +4675,7 @@ bot.command('testall', async (ctx) => { requireWalkthrough: false, requireRefTag: false, testMode: false, dryRun: false, }); - giveawayStore.counter--; + giveawayStore.counter -= 1; if (gw.id && gw.participants instanceof Map && gw.status === 'running') pass('Giveaway System', 'createGiveaway_shape'); else fail('Giveaway System', 'createGiveaway_shape', JSON.stringify({ id: gw.id, status: gw.status })); } catch (e) { fail('Giveaway System', 'createGiveaway_shape', e.message); } @@ -4675,7 +4697,7 @@ bot.command('testall', async (ctx) => { const failures = allResults.filter((r) => !r.ok); const failureLines = failures.map((r) => ` • ${r.label}: ${r.detail}`).join('\n'); - let report = [ + const report = [ `🧪 *TestAll Report*`, `Status: ${totalFailed === 0 ? 'PASSED ✅' : 'FAILED ❌'}`, `Duration: ${(durationMs / 1000).toFixed(1)}s | ${totalPassed}/${totalCount} checks passed`, @@ -4765,7 +4787,7 @@ bot.action(/^tgw_dur_(\d+)$/, async (ctx) => { // Inject 10 fake participants const fakeNames = ['alice', 'bob', 'carol', 'dave', 'eve', 'frank', 'grace', 'henry', 'ivan', 'judy']; - for (let i = 0; i < 10; i++) { + for (let i = 0; i < 10; i += 1) { const fakeId = -(1000 + i); gw.participants.set(fakeId, { userId: fakeId, @@ -4797,13 +4819,13 @@ bot.action(/^tgw_dur_(\d+)$/, async (ctx) => { }, ); - // Auto-finalize after durSec - const timer = setTimeout(async () => { + // Auto-finalize after durSec. setTimeout is synchronous — no race condition. + // eslint-disable-next-line require-atomic-updates + gw.endTimer = setTimeout(async () => { const stillRunning = giveawayStore.running.get(gw.id); if (!stillRunning || stillRunning.status !== 'running') return; await runTestGiveawayFinale(gw, adminId); }, durSec * 1000); - gw.endTimer = timer; }); bot.action(/^tgw_participants_(\d+)$/, async (ctx) => { @@ -4815,13 +4837,15 @@ bot.action(/^tgw_participants_(\d+)$/, async (ctx) => { const parts = Array.from(gw.participants.values()); const real = parts.filter((p) => !p.isFake); const fake = parts.filter((p) => p.isFake); + const realNames = real.map((p) => `@${p.tgUsername}`).join(', ') || 'none'; + const fakeNames = fake.map((p) => `@${p.tgUsername}`).join(', '); await ctx.reply( `👥 Participants — Test Giveaway #${gwId}\n\n` + `Real joins: ${real.length}\n` + `Fake (test) joins: ${fake.length}\n` + `Total: ${parts.length}\n\n` - + `Real: ${real.map((p) => `@${p.tgUsername}`).join(', ') || 'none'}\n` - + `Fake: ${fake.map((p) => `@${p.tgUsername}`).join(', ')}`, + + `Real: ${realNames}\n` + + `Fake: ${fakeNames}`, Markup.inlineKeyboard([[Markup.button.callback('⚡ Finalize Now', `tgw_finalize_${gwId}`), Markup.button.callback('⛔ Abort', `tgw_abort_${gwId}`)]]), ); }); @@ -4960,7 +4984,8 @@ function startHealthServer() { res.end(JSON.stringify({ status: 'not_found' })); }); - server.listen(port, '0.0.0.0'); + // Bind to loopback only — health/metrics are internal; firewall blocks external access + server.listen(port, '127.0.0.1'); return server; } diff --git a/scripts/notify-telegram.sh b/scripts/notify-telegram.sh index 5ee465e..a26da05 100755 --- a/scripts/notify-telegram.sh +++ b/scripts/notify-telegram.sh @@ -26,6 +26,14 @@ if [[ -z "$TOKEN" ]]; then exit 0 fi +# python3 is required to safely JSON-encode the message (handles newlines, +# backslashes, control chars). Without it the fallback sed path produces +# invalid JSON for multi-line messages, so skip rather than send a broken payload. +if ! command -v python3 &>/dev/null; then + echo "[notify-telegram] WARNING: python3 not found — skipping notification to avoid malformed JSON payload." >&2 + exit 0 +fi + IDS="${ADMIN_IDS:-}" if [[ -z "$IDS" ]]; then echo "[notify-telegram] WARNING: No ADMIN_IDS set — skipping notification." >&2 @@ -40,8 +48,7 @@ send_to_one() { local payload payload=$(printf '{"chat_id":%s,"text":%s,"parse_mode":"Markdown","disable_notification":false}' \ "$chat_id" \ - "$(printf '%s' "$MSG" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))' 2>/dev/null \ - || printf '"%s"' "$(echo "$MSG" | sed 's/"/\\"/g')")") + "$(printf '%s' "$MSG" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))')") local http_code http_code=$(curl -s -o /dev/null -w "%{http_code}" \ diff --git a/scripts/rollback.sh b/scripts/rollback.sh index 421aa3f..a1b4782 100755 --- a/scripts/rollback.sh +++ b/scripts/rollback.sh @@ -96,6 +96,18 @@ say "Release restored." # ── Write rollback reason to status file ───────────────────────────────────── echo "rollback to ${RELEASE_NAME} at ${TIMESTAMP}" > "${CURRENT_DIR}/.last_rollback" +# ── Recompute PORT/HEALTH_URL from the restored release's .env ──────────────── +# The restored release may use a different PORT than the one that was running, +# so we must re-read it now — before the health check — to probe the right port. +if [[ -f "${CURRENT_DIR}/.env" ]]; then + _RESTORED_PORT=$(grep "^PORT=" "${CURRENT_DIR}/.env" 2>/dev/null | cut -d= -f2 | tr -d ' "' || true) + if [[ -n "$_RESTORED_PORT" ]]; then + PORT="$_RESTORED_PORT" + HEALTH_URL="http://127.0.0.1:${PORT}/health" + say "Port recomputed from restored .env: ${PORT}" + fi +fi + # ── Restart service ─────────────────────────────────────────────────────────── say "Restarting ${SERVICE_NAME}.service..." if systemctl restart "${SERVICE_NAME}.service" 2>&1; then diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index b391694..d562a22 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -75,7 +75,12 @@ if [[ -d "${DIR}/logs" ]] && touch "${DIR}/logs/.smoke_test_$$" 2>/dev/null; the ok "logs/ writable" else mkdir -p "${DIR}/logs" 2>/dev/null || true - ok "logs/ created" + if touch "${DIR}/logs/.smoke_test_$$" 2>/dev/null; then + rm -f "${DIR}/logs/.smoke_test_$$" + ok "logs/ created and writable" + else + fail "logs/ not writable" + fi fi # 7. Port check