const crypto = require('node:crypto') const fs = require('node:fs') const http = require('node:http') const https = require('node:https') const os = require('node:os') const path = require('node:path') const { spawn } = require('node:child_process') function loadEnvFile() { const envPath = path.join(__dirname, '.env') if (!fs.existsSync(envPath)) return const lines = fs.readFileSync(envPath, 'utf8').split(/\r?\n/) for (const line of lines) { const trimmed = line.trim() if (!trimmed || trimmed.startsWith('#')) continue const separatorIndex = trimmed.indexOf('=') if (separatorIndex === -1) continue const key = trimmed.slice(0, separatorIndex).trim() let value = trimmed.slice(separatorIndex + 1).trim() if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1) } if (key && (!process.env[key] || process.env[key] === '')) { process.env[key] = value } } } loadEnvFile() const PORT = Number(process.env.WEBHOOK_PORT || 3011) const SECRET = process.env.GITHUB_WEBHOOK_SECRET || '' const DISCORD_WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL || '' const DISCORD_INCLUDE_PATCH = /^(1|true|yes)$/i.test(String(process.env.DISCORD_INCLUDE_PATCH || '')) const RESTART_TARGETS = (process.env.PM2_RESTART_TARGETS || 'tssbot-web,tssbot-backend') .split(',') .map((target) => target.trim()) .filter((target) => /^[A-Za-z0-9_.:-]{1,80}$/.test(target)) .filter(Boolean) // This webhook's own PM2 process name — never reload it during its own deploy. const SELF_PM2_NAME = process.env.WEBHOOK_PM2_NAME || 'tssbot-webhook' const DIST_DIR = path.join(__dirname, 'dist') const NEXT_DIST_DIR = path.join(__dirname, 'dist-next') const PREVIOUS_DIST_DIR = path.join(__dirname, 'dist-previous') const MAX_WEBHOOK_BODY_BYTES = Number(process.env.WEBHOOK_MAX_BODY_BYTES || 1024 * 1024) const WEBHOOK_REQUEST_TIMEOUT_MS = Number(process.env.WEBHOOK_REQUEST_TIMEOUT_MS || 30000) const WEBHOOK_HEADERS_TIMEOUT_MS = Number(process.env.WEBHOOK_HEADERS_TIMEOUT_MS || 10000) const ALLOWED_REFS = new Set( (process.env.GITHUB_WEBHOOK_REFS || 'refs/heads/main') .split(',') .map((ref) => ref.trim()) .filter(Boolean), ) const ALLOWED_REPOSITORY = (process.env.GITHUB_WEBHOOK_REPOSITORY || '').trim() const RESTART_AFTER_MS = 24 * 60 * 60 * 1000 let deploying = false let queuedPush = null function json(res, status, body) { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }) res.end(JSON.stringify(body)) } function safeJsonParse(rawBody) { try { return JSON.parse(rawBody.toString('utf8')) } catch { return null } } function shortSha(value) { return value ? String(value).slice(0, 7) : 'unknown' } function branchName(ref) { return String(ref || '').replace(/^refs\/heads\//, '') || 'unknown' } function truncate(value, maxLength = 900) { const text = String(value || '') if (text.length <= maxLength) return text return `${text.slice(0, maxLength - 3)}...` } function codeBlock(value, language = '', maxLength = 1000) { const fence = language ? `\`\`\`${language}\n` : '```\n' const suffix = '\n```' const body = truncate(value || 'No diff returned', maxLength - fence.length - suffix.length) return `${fence}${body}${suffix}` } function validGitSha(value) { return /^[0-9a-f]{7,40}$/i.test(String(value || '')) } function verifySignature(rawBody, signature) { if (!SECRET) { console.error('GITHUB_WEBHOOK_SECRET is not set — rejecting webhook') return false } if (!signature || !signature.startsWith('sha256=')) return false const expected = `sha256=${crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex')}` const signatureBuffer = Buffer.from(signature) const expectedBuffer = Buffer.from(expected) return ( signatureBuffer.length === expectedBuffer.length && crypto.timingSafeEqual(signatureBuffer, expectedBuffer) ) } function executableExists(filePath) { try { return fs.existsSync(filePath) && fs.statSync(filePath).isFile() } catch { return false } } function cargoExecutableName() { return process.platform === 'win32' ? 'cargo.exe' : 'cargo' } function pathDirectories() { const pathValue = process.env.PATH || process.env.Path || '' return pathValue.split(path.delimiter).filter(Boolean) } function cargoCandidates() { const executableName = cargoExecutableName() const candidates = [] if (process.env.CARGO) { candidates.push(process.env.CARGO) } if (process.env.CARGO_HOME) { candidates.push(path.join(process.env.CARGO_HOME, 'bin', executableName)) } candidates.push(path.join(os.homedir(), '.cargo', 'bin', executableName)) if (process.platform !== 'win32') { try { for (const homeEntry of fs.readdirSync('/home', { withFileTypes: true })) { if (homeEntry.isDirectory()) { candidates.push(path.join('/home', homeEntry.name, '.cargo', 'bin', executableName)) } } } catch { // Some hosts do not expose /home to this process; the fixed paths below still cover system installs. } candidates.push( '/root/.cargo/bin/cargo', '/usr/local/cargo/bin/cargo', '/usr/local/bin/cargo', '/usr/bin/cargo', ) } for (const directory of pathDirectories()) { candidates.push(path.join(directory, executableName)) } return [...new Set(candidates)] } function cargoCommand() { for (const candidate of cargoCandidates()) { if (executableExists(candidate)) return candidate } return null } function commandFor(command) { if (command === 'cargo') { const resolvedCommand = cargoCommand() if (!resolvedCommand) throw new Error(commandNotFoundMessage(command)) return resolvedCommand } if (process.platform !== 'win32') return command if (command === 'npm') return 'npm.cmd' if (command === 'pm2') return 'pm2.cmd' return command } function commandNotFoundMessage(command) { if (command !== 'cargo') return `${command} was not found` return [ 'cargo was not found by the deploy webhook', 'Install Rust on the host, or set CARGO to the absolute cargo binary path', `Checked ${cargoCandidates().join(', ')}`, ].join('. ') } function restartTargetsInclude(target) { return RESTART_TARGETS.some((candidate) => candidate === target) } function pushTouchesWebhookRuntime(push) { const runtimeFiles = new Set(['webhook.cjs', 'ecosystem.config.cjs']) const commits = Array.isArray(push?.commits) ? push.commits : [] return commits.some((commit) => { const changed = [ ...(Array.isArray(commit.added) ? commit.added : []), ...(Array.isArray(commit.modified) ? commit.modified : []), ...(Array.isArray(commit.removed) ? commit.removed : []), ] return changed.some((file) => runtimeFiles.has(String(file || '').replace(/^\/+/, ''))) }) } function scheduleSelfReload(reason) { let resolvedCommand try { resolvedCommand = commandFor('pm2') } catch (error) { console.error(`could not schedule ${SELF_PM2_NAME} reload:`, error.message) return } console.log(`scheduling ${SELF_PM2_NAME} reload: ${reason}`) setTimeout(() => { const child = spawn( resolvedCommand, ['reload', 'ecosystem.config.cjs', '--only', SELF_PM2_NAME, '--update-env'], { cwd: __dirname, env: process.env, detached: true, stdio: 'ignore', shell: process.platform === 'win32', }, ) child.unref() }, 1000).unref() } function run(command, args, options = {}) { return new Promise((resolve, reject) => { const label = [command, ...args].join(' ') console.log(`deploy step started: ${label}`) let resolvedCommand try { resolvedCommand = commandFor(command) } catch (error) { reject(error) return } const child = spawn(resolvedCommand, args, { cwd: __dirname, env: { ...process.env, ...options.env }, shell: process.platform === 'win32', stdio: 'inherit', }) child.on('error', (error) => { if (error.code === 'ENOENT') { reject(new Error(commandNotFoundMessage(command))) return } reject(error) }) child.on('exit', (code) => { if (code === 0) { console.log(`deploy step completed: ${label}`) resolve() } else { reject(new Error(`${label} exited with ${code}`)) } }) }) } function runCapture(command, args, options = {}) { return new Promise((resolve, reject) => { const label = [command, ...args].join(' ') let resolvedCommand try { resolvedCommand = commandFor(command) } catch (error) { reject(error) return } const child = spawn(resolvedCommand, args, { cwd: __dirname, env: { ...process.env, ...options.env }, shell: process.platform === 'win32', stdio: ['ignore', 'pipe', 'pipe'], }) let stdout = '' let stderr = '' child.stdout.on('data', (chunk) => { stdout += chunk }) child.stderr.on('data', (chunk) => { stderr += chunk }) child.on('error', reject) child.on('exit', (code) => { if (code === 0) { resolve(stdout.trim()) } else { reject(new Error(`${label} exited with ${code}: ${truncate(stderr || stdout, 500)}`)) } }) }) } // Files that, when changed, require a fresh `npm ci`. package.json is included // because npm ci refuses to run against a package.json/lockfile that are out of // sync, so a change to either should trigger a reinstall. const DEPENDENCY_FILES = ['package.json', 'package-lock.json'] // Resolve the modules the build relies on. Returns the first missing module's // name, or null if all are present. Used both to validate after an install and // to decide whether a skipped install left an incomplete node_modules. function missingBuildDependency() { try { require.resolve('@vitejs/plugin-react') } catch { return '@vitejs/plugin-react' } if (process.platform === 'linux' && process.arch === 'x64') { const libc = process.report?.getReport?.().header?.glibcVersionRuntime ? 'gnu' : 'musl' const rollup = `@rollup/rollup-linux-x64-${libc}` try { require.resolve(rollup) } catch { return rollup } } return null } // True when any dependency file changed between previousHead and the current // HEAD. Defaults to true (reinstall) whenever we can't determine the range, so // an uncertain state never skips a needed install. async function dependencyFilesChanged(previousHead) { if (!validGitSha(previousHead)) return true try { const changed = await runCapture('git', [ 'diff', '--name-only', `${previousHead}..HEAD`, '--', ...DEPENDENCY_FILES, ]) return changed.trim().length > 0 } catch { return true } } async function ensureBuildDependencies(previousHead) { const depsChanged = await dependencyFilesChanged(previousHead) // Skip the clean reinstall (which wipes node_modules and rebuilds native // modules like better-sqlite3) only when no dependency files changed AND the // existing node_modules already has everything the build needs. const canSkip = !depsChanged && missingBuildDependency() === null if (canSkip) { console.log('deploy step skipped: npm ci (no package.json/package-lock.json changes)') } else { await run('npm', ['ci'], { env: { NODE_ENV: 'development', npm_config_omit: '', npm_config_production: 'false', npm_config_fund: 'false', npm_config_audit: 'false', }, }) const missing = missingBuildDependency() if (missing) { throw new Error(`${missing} is missing after npm install; dependencies were not installed`) } } } function copyMissingFiles(fromDir, toDir) { if (!fs.existsSync(fromDir) || !fs.existsSync(toDir)) return for (const entry of fs.readdirSync(fromDir, { withFileTypes: true })) { const source = path.join(fromDir, entry.name) const target = path.join(toDir, entry.name) if (entry.isDirectory()) { fs.mkdirSync(target, { recursive: true }) copyMissingFiles(source, target) continue } if (!fs.existsSync(target)) { fs.copyFileSync(source, target) } } } function promoteBuiltDist() { const previousAssetsDir = path.join(DIST_DIR, 'assets') const nextAssetsDir = path.join(NEXT_DIST_DIR, 'assets') let movedCurrentDist = false copyMissingFiles(previousAssetsDir, nextAssetsDir) fs.rmSync(PREVIOUS_DIST_DIR, { recursive: true, force: true }) try { if (fs.existsSync(DIST_DIR)) { fs.renameSync(DIST_DIR, PREVIOUS_DIST_DIR) movedCurrentDist = true } fs.renameSync(NEXT_DIST_DIR, DIST_DIR) } catch (error) { if (movedCurrentDist && !fs.existsSync(DIST_DIR) && fs.existsSync(PREVIOUS_DIST_DIR)) { fs.renameSync(PREVIOUS_DIST_DIR, DIST_DIR) } throw error } } function syncVehicleIcons() { // Point the served icon dir at the bot-managed SHARED/ICONS/VEHICLES so the // scoreboard can render vehicle icons without bloating this repo. Symlink when // possible; fall back to skipping (icons hide gracefully) if the source is absent. const src = process.env.VEHICLE_ICONS_SRC || '/home/deploy/BOTS/SHARED/ICONS/VEHICLES' const dst = path.resolve( __dirname, process.env.VEHICLE_ICONS_DIR || path.join('dist', 'vehicle-icons'), ) if (!fs.existsSync(src)) { console.warn(`vehicle icons source missing, skipping: ${src}`) return } try { fs.rmSync(dst, { recursive: true, force: true }) fs.mkdirSync(path.dirname(dst), { recursive: true }) fs.symlinkSync(src, dst) console.log(`linked vehicle icons ${dst} -> ${src}`) } catch (error) { console.error(`vehicle icon sync failed: ${error.message}`) } } function validateBuiltDist() { const indexPath = path.join(NEXT_DIST_DIR, 'index.html') if (!fs.existsSync(indexPath)) { throw new Error('Frontend build did not produce dist-next/index.html') } const html = fs.readFileSync(indexPath, 'utf8') if (/\sintegrity=(["'])sha(?:256|384|512)-[^"']+\1/i.test(html)) { throw new Error('Frontend build contains unsupported integrity attributes') } const assetPaths = [...html.matchAll(/(?:src|href)=(["'])\/(assets\/[^"']+)\1/g)] .map((match) => match[2]) for (const assetPath of assetPaths) { if (!fs.existsSync(path.join(NEXT_DIST_DIR, assetPath))) { throw new Error(`Frontend build references missing asset: /${assetPath}`) } } } function postDiscordWebhook(payload) { if (!DISCORD_WEBHOOK_URL) return Promise.resolve() return new Promise((resolve) => { let url try { url = new URL(DISCORD_WEBHOOK_URL) } catch (error) { console.error('Discord restart webhook URL is invalid:', error.message) resolve() return } const body = Buffer.from(JSON.stringify(payload)) const client = url.protocol === 'http:' ? http : https const req = client.request( url, { method: 'POST', headers: { 'content-type': 'application/json', 'content-length': body.length, 'user-agent': 'tssbot-webhook', }, timeout: 5000, }, (res) => { res.resume() res.on('end', resolve) }, ) req.on('timeout', () => { req.destroy(new Error('Discord restart webhook timed out')) }) req.on('error', (error) => { console.error('Discord restart webhook failed:', error.message) resolve() }) req.end(body) }) } function notifyDiscordRestart() { const startedAt = new Date() sendDiscordEmbed({ title: 'Webhook listener restarted', color: 0x00f2ff, fields: [ { name: 'Host', value: os.hostname(), inline: true }, { name: 'Port', value: String(PORT), inline: true }, { name: 'Restart targets', value: RESTART_TARGETS.join(', ') || 'none', inline: false }, ], timestamp: startedAt.toISOString(), }) } function sendDiscordEmbed(embed) { return postDiscordWebhook({ username: 'tssbot webhook', embeds: [embed], }) } function deployFields(push) { const headCommit = push?.head_commit const fields = [ { name: 'Host', value: os.hostname(), inline: true }, { name: 'Branch', value: branchName(push?.ref), inline: true }, { name: 'Commit', value: shortSha(push?.after || headCommit?.id), inline: true }, { name: 'Restart targets', value: RESTART_TARGETS.join(', ') || 'none', inline: false }, ] if (push?.sender?.login) { fields.push({ name: 'Sender', value: push.sender.login, inline: true }) } if (headCommit?.message) { fields.push({ name: 'Message', value: truncate(headCommit.message), inline: false }) } return fields } async function deployDiff(push) { const before = push?.before const after = push?.after if (!validGitSha(before) || !validGitSha(after) || /^0+$/.test(before)) { return { summary: 'Diff unavailable for this push range', patch: push?.compare ? `Compare: ${push.compare}` : '', } } const range = `${before}..${after}` const [nameStatus, shortStat, patch] = await Promise.all([ runCapture('git', ['diff', '--name-status', '--find-renames', range]), runCapture('git', ['diff', '--shortstat', range]), runCapture('git', ['diff', '--find-renames', '--unified=1', range]), ]) return { summary: [shortStat, nameStatus].filter(Boolean).join('\n'), patch, } } function diffFields(diff) { if (!diff) return [] const fields = [] if (diff.summary) { fields.push({ name: 'Diff summary', value: codeBlock(diff.summary, 'diff'), inline: false }) } if (DISCORD_INCLUDE_PATCH && diff.patch) { fields.push({ name: 'Patch preview', value: codeBlock(diff.patch, 'diff'), inline: false }) } return fields } async function notifyDeployStarted(push) { await sendDiscordEmbed({ title: 'GitHub push deploy started', color: 0xf4ee3e, fields: deployFields(push), timestamp: new Date().toISOString(), }) } async function notifyDeployCompleted(push, diff) { await sendDiscordEmbed({ title: 'GitHub push deploy completed', color: 0x00f2ff, fields: [...deployFields(push), ...diffFields(diff)], timestamp: new Date().toISOString(), }) } async function notifyDeployFailed(push, error, diff) { await sendDiscordEmbed({ title: 'GitHub push deploy failed', color: 0xe82517, fields: [ ...deployFields(push), ...diffFields(diff), { name: 'Error', value: truncate(error?.message || error), inline: false }, ], timestamp: new Date().toISOString(), }) } async function deploy(push) { let diff = null try { await notifyDeployStarted(push) const previousHead = await runCapture('git', ['rev-parse', 'HEAD']).catch(() => null) await run('git', ['pull', '--ff-only']) diff = await deployDiff(push) await ensureBuildDependencies(previousHead) await run('npm', ['run', 'build', '--', '--outDir', '../dist-next']) validateBuiltDist() await run('cargo', ['build', '--manifest-path', 'backend/Cargo.toml', '--release']) promoteBuiltDist() syncVehicleIcons() // Reload via the ecosystem file (not by bare name) with --only so each deploy // re-reads the committed env blocks (e.g. VEHICLE_* paths). `pm2 reload // --update-env` would only merge the CLI's process.env and ignore the file. // Exclude this webhook process from the awaited reload: killing the process // running this deploy mid-command can interrupt the remaining reloads. const reloadTargets = RESTART_TARGETS.filter((t) => t !== SELF_PM2_NAME) if (reloadTargets.length) { await run('pm2', [ 'reload', 'ecosystem.config.cjs', '--only', reloadTargets.join(','), '--update-env', ]) } await notifyDeployCompleted(push, diff) if (restartTargetsInclude(SELF_PM2_NAME) || pushTouchesWebhookRuntime(push)) { scheduleSelfReload( restartTargetsInclude(SELF_PM2_NAME) ? `${SELF_PM2_NAME} is listed in PM2_RESTART_TARGETS` : 'webhook runtime files changed', ) } } catch (error) { error.deployDiff = diff throw error } } async function runDeployQueue(initialPush) { let push = initialPush while (push) { queuedPush = null try { await deploy(push) console.log('GitHub push deploy completed') } catch (error) { console.error('GitHub push deploy failed:', error) try { await notifyDeployFailed(push, error, error.deployDiff) } catch (notificationError) { console.error('Failed to send deploy failure notification:', notificationError) } } push = queuedPush } } const webhookServer = http.createServer((req, res) => { if (req.method === 'GET' && req.url === '/health') { json(res, 200, { ok: true, deploying, queued: Boolean(queuedPush), restart_targets: RESTART_TARGETS, }) return } if (req.method !== 'POST' || req.url !== '/github') { json(res, 404, { error: 'Not found' }) return } const chunks = [] let size = 0 let tooLarge = false req.on('data', (chunk) => { size += chunk.length if (size > MAX_WEBHOOK_BODY_BYTES) { tooLarge = true json(res, 413, { error: 'Webhook body too large' }) req.destroy() return } chunks.push(chunk) }) req.on('end', () => { if (tooLarge) return const rawBody = Buffer.concat(chunks) const push = safeJsonParse(rawBody) if (!verifySignature(rawBody, req.headers['x-hub-signature-256'])) { json(res, 401, { error: 'Invalid signature' }) return } if (req.headers['x-github-event'] !== 'push') { json(res, 202, { skipped: true, reason: 'Only push events trigger deploys' }) return } const pushRef = String(push?.ref || '') if (!ALLOWED_REFS.has(pushRef)) { json(res, 202, { skipped: true, reason: `Ignoring ref ${pushRef || '(missing)'}` }) return } if (ALLOWED_REPOSITORY) { const repoFullName = String(push?.repository?.full_name || '') if (repoFullName !== ALLOWED_REPOSITORY) { json(res, 202, { skipped: true, reason: `Ignoring repository ${repoFullName || '(missing)'}` }) return } } if (push?.deleted) { json(res, 202, { skipped: true, reason: 'Ignoring branch-delete push' }) return } if (deploying) { queuedPush = push json(res, 202, { queued: true, deploying: true }) return } deploying = true json(res, 202, { accepted: true, restart_targets: RESTART_TARGETS }) runDeployQueue(push) .finally(() => { deploying = false }) }) }) webhookServer.requestTimeout = WEBHOOK_REQUEST_TIMEOUT_MS webhookServer.headersTimeout = WEBHOOK_HEADERS_TIMEOUT_MS webhookServer.listen(PORT, '0.0.0.0', () => { console.log(`tssbot webhook listening on http://localhost:${PORT}/github`) console.log(`restart targets: ${RESTART_TARGETS.join(', ')}`) notifyDiscordRestart() }) setTimeout(() => { console.log('24 hour webhook refresh reached; exiting for PM2 restart') process.exit(0) }, RESTART_AFTER_MS).unref()