775 lines
23 KiB
JavaScript
775 lines
23 KiB
JavaScript
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)}`))
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
// Resolve the modules the build relies on. Returns the first missing module's
|
|
// name, or null if all are present. Used to validate after install.
|
|
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
|
|
}
|
|
|
|
async function ensureBuildDependencies() {
|
|
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)
|
|
await run('git', ['pull', '--ff-only'])
|
|
diff = await deployDiff(push)
|
|
await ensureBuildDependencies()
|
|
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 <name>
|
|
// --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)
|
|
const event = String(req.headers['x-github-event'] || '')
|
|
const pushRef = String(push?.ref || '')
|
|
const repoFullName = String(push?.repository?.full_name || '')
|
|
const delivery = String(req.headers['x-github-delivery'] || 'unknown')
|
|
|
|
if (!verifySignature(rawBody, req.headers['x-hub-signature-256'])) {
|
|
console.warn(`webhook delivery rejected: invalid signature delivery=${delivery} event=${event || '(missing)'}`)
|
|
json(res, 401, { error: 'Invalid signature' })
|
|
return
|
|
}
|
|
|
|
console.log(`webhook delivery received: delivery=${delivery} event=${event || '(missing)'} ref=${pushRef || '(missing)'} repo=${repoFullName || '(missing)'}`)
|
|
|
|
if (event !== 'push') {
|
|
console.log(`webhook delivery skipped: delivery=${delivery} reason=non-push event=${event || '(missing)'}`)
|
|
json(res, 202, { skipped: true, reason: 'Only push events trigger deploys' })
|
|
return
|
|
}
|
|
|
|
if (!ALLOWED_REFS.has(pushRef)) {
|
|
console.log(`webhook delivery skipped: delivery=${delivery} reason=ref ref=${pushRef || '(missing)'} allowed=${[...ALLOWED_REFS].join(',')}`)
|
|
json(res, 202, { skipped: true, reason: `Ignoring ref ${pushRef || '(missing)'}` })
|
|
return
|
|
}
|
|
|
|
if (ALLOWED_REPOSITORY) {
|
|
if (repoFullName !== ALLOWED_REPOSITORY) {
|
|
console.log(`webhook delivery skipped: delivery=${delivery} reason=repository repo=${repoFullName || '(missing)'} allowed=${ALLOWED_REPOSITORY}`)
|
|
json(res, 202, { skipped: true, reason: `Ignoring repository ${repoFullName || '(missing)'}` })
|
|
return
|
|
}
|
|
}
|
|
|
|
if (push?.deleted) {
|
|
console.log(`webhook delivery skipped: delivery=${delivery} reason=branch-delete ref=${pushRef}`)
|
|
json(res, 202, { skipped: true, reason: 'Ignoring branch-delete push' })
|
|
return
|
|
}
|
|
|
|
if (deploying) {
|
|
queuedPush = push
|
|
console.log(`webhook deploy queued: delivery=${delivery} ref=${pushRef} repo=${repoFullName || '(missing)'}`)
|
|
json(res, 202, { queued: true, deploying: true })
|
|
return
|
|
}
|
|
|
|
deploying = true
|
|
console.log(`webhook deploy accepted: delivery=${delivery} ref=${pushRef} repo=${repoFullName || '(missing)'} restart_targets=${RESTART_TARGETS.join(',') || 'none'}`)
|
|
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()
|