Files
SREBOT/github_webhook_updater.py
T
NotSoToothless 2f6a9687b1 Auto merge dev → main (#1226)
* 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>
2026-05-13 23:49:54 -07:00

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)