Document Steam lobby setup and releases #3
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| push: | |
| branches: | |
| - main | |
| pull_request: | |
| jobs: | |
| syntax-and-smoke: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 20 | |
| cache: npm | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Check bot entrypoint syntax | |
| run: node --check index.js | |
| - name: Check web entrypoint syntax | |
| run: node --check web.js | |
| - name: Check PM2 config syntax | |
| run: node --check ecosystem.config.js | |
| - name: Check admin and backup module syntax | |
| run: node --check src/web/index.js && node --check src/services/webAdminService.js && node --check src/utils/databaseBackup.js | |
| - name: Check frontend dashboard script syntax | |
| run: node --check src/web/public/app.js | |
| - name: Smoke test backup and web admin routes | |
| run: | | |
| node - <<'EOF' | |
| const http = require('http'); | |
| const fs = require('fs/promises'); | |
| const path = require('path'); | |
| const { createDatabase } = require('./src/data/database'); | |
| const { createBackup, listBackups, restoreBackupIntoDatabase } = require('./src/utils/databaseBackup'); | |
| const { bootstrapWebServer } = require('./src/web/index'); | |
| async function safeRemove(targetPath) { | |
| for (let attempt = 0; attempt < 5; attempt += 1) { | |
| try { | |
| await fs.rm(targetPath, { recursive: true, force: true }); | |
| return; | |
| } catch (error) { | |
| if (attempt === 4) { | |
| console.warn(`cleanup warning for ${targetPath}: ${error.message}`); | |
| return; | |
| } | |
| await new Promise((resolve) => setTimeout(resolve, 250)); | |
| } | |
| } | |
| } | |
| async function runBackupSmokeTest() { | |
| const tempRoot = path.join(process.cwd(), 'tmp-smoke-backup'); | |
| await safeRemove(tempRoot); | |
| await fs.mkdir(tempRoot, { recursive: true }); | |
| const dbPath = path.join(tempRoot, 'test.sqlite'); | |
| const backupDir = path.join(tempRoot, 'backups'); | |
| const guildId = 'g1'; | |
| const userId = 'u1'; | |
| const db = await createDatabase(dbPath); | |
| const now = new Date().toISOString(); | |
| try { | |
| await db.run( | |
| 'INSERT INTO players (guild_id, user_id, username, display_name, role, elo, wins, losses, matches_played, current_streak, best_win_streak, last_result, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', | |
| [guildId, userId, 'user1', 'Original Name', 'mid', 1200, 1, 0, 1, 1, 1, 'win', now, now], | |
| ); | |
| const backup = await createBackup({ | |
| databasePath: dbPath, | |
| backupDirectory: backupDir, | |
| retentionCount: 5, | |
| prefix: 'dota-bot', | |
| }); | |
| await db.run( | |
| 'UPDATE players SET display_name = ?, elo = ?, updated_at = ? WHERE guild_id = ? AND user_id = ?', | |
| ['Changed Name', 9999, new Date().toISOString(), guildId, userId], | |
| ); | |
| await restoreBackupIntoDatabase({ | |
| database: db, | |
| databasePath: dbPath, | |
| backupDirectory: backupDir, | |
| retentionCount: 5, | |
| backupFileName: backup.fileName, | |
| }); | |
| const restored = await db.get('SELECT display_name, elo FROM players WHERE guild_id = ? AND user_id = ?', [guildId, userId]); | |
| const backups = await listBackups(backupDir, 'dota-bot'); | |
| if (restored.display_name !== 'Original Name' || Number(restored.elo) !== 1200) { | |
| throw new Error('restore did not recover original record'); | |
| } | |
| if (!Array.isArray(backups) || backups.length < 1) { | |
| throw new Error('backup listing failed'); | |
| } | |
| } finally { | |
| await db.close(); | |
| await safeRemove(tempRoot); | |
| } | |
| } | |
| async function runWebSmokeTest() { | |
| const tempRoot = path.join(process.cwd(), 'tmp-smoke-web'); | |
| await safeRemove(tempRoot); | |
| await fs.mkdir(tempRoot, { recursive: true }); | |
| const dbPath = path.join(tempRoot, 'test.sqlite'); | |
| const backupDir = path.join(tempRoot, 'backups'); | |
| const guildId = 'g1'; | |
| const token = 'test-token'; | |
| const now = new Date().toISOString(); | |
| const db = await createDatabase(dbPath); | |
| let mockServer = null; | |
| let runtime = null; | |
| try { | |
| await db.run( | |
| 'INSERT INTO players (guild_id, user_id, username, display_name, role, elo, wins, losses, matches_played, current_streak, best_win_streak, last_result, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', | |
| [guildId, 'u1', 'user1', 'User One', 'mid', 1234, 5, 3, 8, 2, 3, 'win', now, now], | |
| ); | |
| await db.run( | |
| 'INSERT INTO admin_audit_log (guild_id, action, actor_id, actor_label, actor_source, target_type, target_id, status, details_json, error_message, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', | |
| [guildId, 'createBackup', 'admin1', 'Admin One', 'token', 'system', guildId, 'success', JSON.stringify({ ok: true }), null, now], | |
| ); | |
| mockServer = http.createServer(async (req, res) => { | |
| const chunks = []; | |
| for await (const chunk of req) { | |
| chunks.push(chunk); | |
| } | |
| const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {}; | |
| let result = null; | |
| if (req.url === '/admin/list-backups') { | |
| result = [{ fileName: 'dota-bot-2026-03-31T00-00-00-000Z.sqlite', sizeBytes: 2048, createdAt: now, updatedAt: now }]; | |
| } else if (req.url === '/admin/create-backup') { | |
| result = { fileName: 'dota-bot-created.sqlite', destination: path.join(backupDir, 'dota-bot-created.sqlite'), prunedFiles: [] }; | |
| } else if (req.url === '/admin/restore-backup') { | |
| result = { restoredFrom: body.backupFileName, safetyCopy: 'pre-restore-test.sqlite' }; | |
| } else { | |
| res.writeHead(404, { 'Content-Type': 'application/json' }); | |
| res.end(JSON.stringify({ ok: false, error: 'not found' })); | |
| return; | |
| } | |
| res.writeHead(200, { 'Content-Type': 'application/json' }); | |
| res.end(JSON.stringify({ ok: true, result })); | |
| }); | |
| await new Promise((resolve) => mockServer.listen(0, '127.0.0.1', resolve)); | |
| const mockAddress = mockServer.address(); | |
| runtime = await bootstrapWebServer({ | |
| database: db, | |
| config: { | |
| webHost: '127.0.0.1', | |
| webPort: 0, | |
| webTitle: 'Smoke', | |
| webRefreshMs: 15000, | |
| webLiveHeartbeatMs: 25000, | |
| webDbWatchDebounceMs: 100, | |
| webDefaultGuildId: guildId, | |
| webAdminAllowedGuildIds: [], | |
| webAdminToken: token, | |
| webAdminActorId: 'admin1', | |
| discordOauthClientId: '', | |
| discordOauthClientSecret: '', | |
| discordOauthRedirectUri: '', | |
| botControlHost: '127.0.0.1', | |
| botControlPort: mockAddress.port, | |
| botControlToken: token, | |
| botControlUrl: `http://127.0.0.1:${mockAddress.port}`, | |
| databasePath: dbPath, | |
| backupDirectory: backupDir, | |
| }, | |
| logger: { | |
| info() {}, | |
| warn() {}, | |
| error(...args) { | |
| console.error(...args); | |
| }, | |
| debug() {}, | |
| }, | |
| }); | |
| const webPort = runtime.server.address().port; | |
| const request = (pathname, options = {}) => fetch(`http://127.0.0.1:${webPort}${pathname}`, { | |
| ...options, | |
| headers: { | |
| Authorization: `Bearer ${token}`, | |
| ...(options.headers || {}), | |
| }, | |
| }); | |
| const auditJson = await request(`/api/admin/audit-export?format=json&guildId=${guildId}`); | |
| const auditJsonBody = await auditJson.text(); | |
| const auditCsv = await request(`/api/admin/audit-export?format=csv&guildId=${guildId}`); | |
| const auditCsvBody = await auditCsv.text(); | |
| const backupsResponse = await request(`/api/admin/backups?guildId=${guildId}`); | |
| const backupsPayload = await backupsResponse.json(); | |
| const actionResponse = await request('/api/admin/action', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| action: 'restoreBackup', | |
| guildId, | |
| backupFileName: 'dota-bot-2026-03-31T00-00-00-000Z.sqlite', | |
| }), | |
| }); | |
| const actionPayload = await actionResponse.json(); | |
| if (!auditJson.ok || !String(auditJson.headers.get('content-disposition') || '').includes('.json') || !auditJsonBody.includes('createBackup')) { | |
| throw new Error('audit json route failed'); | |
| } | |
| if (!auditCsv.ok || !String(auditCsv.headers.get('content-disposition') || '').includes('.csv') || !auditCsvBody.includes('createBackup')) { | |
| throw new Error('audit csv route failed'); | |
| } | |
| if (!backupsResponse.ok || !Array.isArray(backupsPayload.backups) || backupsPayload.backups.length !== 1) { | |
| throw new Error('backups route failed'); | |
| } | |
| if (!actionResponse.ok || !actionPayload.ok || actionPayload.result.restoredFrom !== 'dota-bot-2026-03-31T00-00-00-000Z.sqlite') { | |
| throw new Error('admin action route failed'); | |
| } | |
| } finally { | |
| if (runtime) { | |
| await runtime.close(); | |
| } | |
| if (mockServer) { | |
| await new Promise((resolve) => mockServer.close(resolve)); | |
| } | |
| await db.close(); | |
| await safeRemove(tempRoot); | |
| } | |
| } | |
| (async () => { | |
| await runBackupSmokeTest(); | |
| await runWebSmokeTest(); | |
| })().catch((error) => { | |
| console.error(error); | |
| process.exit(1); | |
| }); | |
| EOF |