2f6a9687b1
* add tssbot PM2 entry + extend webhook updater to handle TSSBOT - TSSBOT/ecosystem.config.js: defines just the tssbot app for now. Uses system python3 (skeleton has no deps); switch to .venv/bin/python once TSSBOT/BOT/botscript.py and a real dependency set exist. - TSSBOT/start_bot.py: change the stub from exit-immediately to an idle loop with SIGTERM/SIGINT handling so PM2 doesn't restart-loop the placeholder. - SREBOT/github_webhook_updater.py: same listener handles the whole monorepo. Add a tssbot restart rule that triggers on TSSBOT/BOT/, TSSBOT root .py, TSSBOT/start_bot.py, TSSBOT/ecosystem.config.js, or SHARED/ changes. A push to SHARED restarts both bots; pushes scoped to one bot only restart that bot. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * update game files and start tssbot --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
277 lines
10 KiB
Python
277 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
GitHub Webhook Handler for auto-deploying SREBOT on main branch pushes.
|
|
Listens for push events, pulls changes, and restarts pm2.
|
|
"""
|
|
|
|
import hmac
|
|
import hashlib
|
|
import logging
|
|
import subprocess
|
|
import os
|
|
from flask import Flask, request, jsonify
|
|
from dotenv import load_dotenv
|
|
|
|
load_dotenv()
|
|
|
|
# Configure logging
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s [%(levelname)s] %(message)s',
|
|
datefmt='%Y-%m-%d %H:%M:%S'
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
app = Flask(__name__)
|
|
|
|
WEBHOOK_SECRET = os.environ.get('GITHUB_WEBHOOK_SECRET', '')
|
|
REPO_PATH = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
WEBHOOK_FILENAME = 'SREBOT/github_webhook_updater.py'
|
|
|
|
|
|
def verify_signature(payload: bytes, signature: str) -> bool:
|
|
"""Verify GitHub webhook signature."""
|
|
if not WEBHOOK_SECRET:
|
|
logger.warning("No webhook secret configured")
|
|
return False
|
|
|
|
if not signature:
|
|
logger.warning("No signature provided in request")
|
|
return False
|
|
|
|
expected = 'sha256=' + hmac.new(
|
|
WEBHOOK_SECRET.encode(),
|
|
payload,
|
|
hashlib.sha256
|
|
).hexdigest()
|
|
|
|
return hmac.compare_digest(expected, signature)
|
|
|
|
|
|
def pull_and_restart(changed_files: list[str]) -> tuple[bool, str]:
|
|
"""Pull latest changes from main and restart pm2 processes."""
|
|
try:
|
|
# Change to repo directory
|
|
os.chdir(REPO_PATH)
|
|
logger.info(f"Changed to repo directory: {REPO_PATH}")
|
|
|
|
# Fetch and pull main branch
|
|
logger.info("Fetching from origin/main...")
|
|
subprocess.run(
|
|
['git', 'fetch', 'origin', 'main'],
|
|
capture_output=True, text=True, timeout=30
|
|
)
|
|
|
|
logger.info("Pulling from origin/main...")
|
|
pull_result = subprocess.run(
|
|
['git', 'pull', 'origin', 'main'],
|
|
capture_output=True, text=True, timeout=60
|
|
)
|
|
|
|
if pull_result.returncode != 0:
|
|
logger.error(f"Git pull failed: {pull_result.stderr}")
|
|
return False, f"Git pull failed: {pull_result.stderr}"
|
|
|
|
# Check if there were actual changes
|
|
if 'Already up to date' in pull_result.stdout:
|
|
logger.info("Already up to date, no restart needed")
|
|
return True, "Already up to date, no restart needed"
|
|
|
|
logger.info(f"Pull successful: {pull_result.stdout.strip()}")
|
|
|
|
# Determine which processes to restart based on changed files
|
|
processes_to_restart = []
|
|
|
|
# SREBOT bot: restart if SREBOT/BOT/, SHARED/, SREBOT/start_bot.py,
|
|
# SREBOT/ecosystem.config.js, or SREBOT root .py files changed.
|
|
srebot_changed = any(
|
|
f.startswith('SREBOT/BOT/')
|
|
or f.startswith('SHARED/')
|
|
or f == 'SREBOT/start_bot.py'
|
|
or f == 'SREBOT/ecosystem.config.js'
|
|
or (f.startswith('SREBOT/') and f.endswith('.py') and f.count('/') == 1 and not f.endswith('/' + WEBHOOK_FILENAME))
|
|
for f in changed_files
|
|
)
|
|
if srebot_changed:
|
|
processes_to_restart.append('srebot')
|
|
logger.info("SREBOT files changed, will restart srebot")
|
|
|
|
# TSSBOT bot: restart if TSSBOT/BOT/, TSSBOT/start_bot.py,
|
|
# TSSBOT/ecosystem.config.js, SHARED/, or TSSBOT root .py files changed.
|
|
tssbot_changed = any(
|
|
f.startswith('TSSBOT/BOT/')
|
|
or f.startswith('SHARED/')
|
|
or f == 'TSSBOT/start_bot.py'
|
|
or f == 'TSSBOT/ecosystem.config.js'
|
|
or (f.startswith('TSSBOT/') and f.endswith('.py') and f.count('/') == 1)
|
|
for f in changed_files
|
|
)
|
|
if tssbot_changed:
|
|
processes_to_restart.append('tssbot')
|
|
logger.info("TSSBOT files changed, will restart tssbot")
|
|
|
|
# API server: restart if SREBOT/server.js changed
|
|
api_changed = any(f == 'SREBOT/server.js' for f in changed_files)
|
|
if api_changed:
|
|
processes_to_restart.append('srebot-api')
|
|
logger.info("API server.js changed, will restart srebot-api")
|
|
|
|
# Web frontend: restart if SREBOT/web/ files changed
|
|
web_changed = any(f.startswith('SREBOT/web/') for f in changed_files)
|
|
if web_changed:
|
|
# Rebuild CSS if tailwind source or config changed
|
|
css_changed = any(
|
|
f in ('SREBOT/web/public/css/tailwind.css', 'SREBOT/web/tailwind.config.js')
|
|
for f in changed_files
|
|
)
|
|
if css_changed:
|
|
logger.info("Tailwind source changed, rebuilding CSS...")
|
|
build_result = subprocess.run(
|
|
['npm', 'run', 'build:css'],
|
|
capture_output=True, text=True, timeout=60,
|
|
cwd=os.path.join(REPO_PATH, 'SREBOT', 'web')
|
|
)
|
|
if build_result.returncode != 0:
|
|
logger.error(f"CSS build failed: {build_result.stderr}")
|
|
return False, f"CSS build failed: {build_result.stderr}"
|
|
logger.info("CSS build successful")
|
|
|
|
# Rebuild obfuscated JS if any JS source files changed
|
|
js_changed = any(
|
|
f.startswith('SREBOT/web/public/js/') and f.endswith('.js') and '/dist/' not in f
|
|
for f in changed_files
|
|
)
|
|
if js_changed:
|
|
logger.info("JS source files changed, rebuilding obfuscated JS...")
|
|
build_result = subprocess.run(
|
|
['node', 'build.js'],
|
|
capture_output=True, text=True, timeout=120,
|
|
cwd=os.path.join(REPO_PATH, 'SREBOT', 'web')
|
|
)
|
|
if build_result.returncode != 0:
|
|
logger.warning(f"JS build failed (non-fatal): {build_result.stderr}")
|
|
logger.warning("Continuing deploy — unobfuscated JS will be served as fallback")
|
|
else:
|
|
logger.info("JS build successful")
|
|
|
|
processes_to_restart.append('srebot-web')
|
|
logger.info("Web files changed, will restart srebot-web")
|
|
|
|
# Webhook: restart if this file changed
|
|
webhook_changed = any(WEBHOOK_FILENAME in f for f in changed_files)
|
|
if webhook_changed:
|
|
processes_to_restart.append('srebot-webhook')
|
|
logger.info(f"Webhook file changed, will restart srebot-webhook")
|
|
|
|
if not processes_to_restart:
|
|
logger.info(f"No relevant process files changed, skipping restart. Files: {changed_files}")
|
|
return True, "Pulled but no restart needed"
|
|
|
|
# Flush pm2 logs before restarting to free disk space
|
|
logger.info("Flushing PM2 logs...")
|
|
subprocess.run(
|
|
['pm2', 'flush'],
|
|
capture_output=True, text=True, timeout=30
|
|
)
|
|
|
|
# Restart pm2 processes
|
|
restarted = []
|
|
for process in processes_to_restart:
|
|
logger.info(f"Restarting PM2 process: {process}")
|
|
restart_result = subprocess.run(
|
|
['pm2', 'restart', process],
|
|
capture_output=True, text=True, timeout=30
|
|
)
|
|
if restart_result.returncode != 0:
|
|
logger.error(f"PM2 restart failed for {process}: {restart_result.stderr}")
|
|
return False, f"PM2 restart failed for {process}: {restart_result.stderr}"
|
|
restarted.append(process)
|
|
|
|
logger.info(f"PM2 restart successful: {restarted}")
|
|
return True, f"Pulled and restarted {restarted}"
|
|
|
|
except subprocess.TimeoutExpired:
|
|
logger.error("Command timed out")
|
|
return False, "Command timed out"
|
|
except Exception as e:
|
|
logger.exception(f"Unexpected error during pull_and_restart: {e}")
|
|
return False, f"Error: {str(e)}"
|
|
|
|
|
|
@app.route('/webhook', methods=['POST'])
|
|
def webhook():
|
|
"""Handle GitHub webhook POST requests."""
|
|
logger.info(f"Received webhook request from {request.remote_addr}")
|
|
|
|
# Verify signature
|
|
signature = request.headers.get('X-Hub-Signature-256', '')
|
|
if not verify_signature(request.data, signature):
|
|
logger.warning(f"Invalid signature from {request.remote_addr}")
|
|
return jsonify({'error': 'Invalid signature'}), 403
|
|
|
|
# Parse event type
|
|
event = request.headers.get('X-GitHub-Event', '')
|
|
logger.info(f"GitHub event type: {event}")
|
|
|
|
if event == 'ping':
|
|
logger.info("Received ping event, responding with pong")
|
|
return jsonify({'message': 'pong'}), 200
|
|
|
|
if event != 'push':
|
|
logger.info(f"Ignoring non-push event: {event}")
|
|
return jsonify({'message': f'Ignoring event: {event}'}), 200
|
|
|
|
# Parse JSON payload with error handling
|
|
try:
|
|
payload = request.get_json()
|
|
if payload is None:
|
|
logger.error("Failed to parse JSON payload (returned None)")
|
|
return jsonify({'error': 'Invalid JSON payload'}), 400
|
|
except Exception as e:
|
|
logger.error(f"Failed to parse JSON payload: {e}")
|
|
return jsonify({'error': 'Invalid JSON payload'}), 400
|
|
|
|
# Check if it's the main branch
|
|
ref = payload.get('ref', '')
|
|
logger.info(f"Push to ref: {ref}")
|
|
|
|
if ref != 'refs/heads/main':
|
|
logger.info(f"Ignoring push to non-main branch: {ref}")
|
|
return jsonify({'message': f'Ignoring branch: {ref}'}), 200
|
|
|
|
# Extract changed files from commits
|
|
commits = payload.get('commits', [])
|
|
pusher = payload.get('pusher', {}).get('name', 'unknown')
|
|
logger.info(f"Push from {pusher} with {len(commits)} commit(s)")
|
|
|
|
changed_files = []
|
|
for commit in commits:
|
|
changed_files.extend(commit.get('added', []))
|
|
changed_files.extend(commit.get('modified', []))
|
|
changed_files.extend(commit.get('removed', []))
|
|
|
|
logger.info(f"Changed files: {changed_files}")
|
|
|
|
# Pull and restart
|
|
success, message = pull_and_restart(changed_files)
|
|
|
|
if success:
|
|
logger.info(f"Webhook update successful: {message}")
|
|
return jsonify({'success': True, 'message': message}), 200
|
|
else:
|
|
logger.error(f"Webhook update failed: {message}")
|
|
return jsonify({'success': False, 'error': message}), 500
|
|
|
|
|
|
@app.route('/health', methods=['GET'])
|
|
def health():
|
|
"""Health check endpoint."""
|
|
return jsonify({'status': 'ok'}), 200
|
|
|
|
|
|
if __name__ == '__main__':
|
|
port = int(os.environ.get('WEBHOOK_PORT', 9000))
|
|
logger.info(f"Starting webhook server on port {port}")
|
|
logger.info(f"Repo path: {REPO_PATH}")
|
|
app.run(host='0.0.0.0', port=port)
|