#!/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], before: str = '', after: 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()}") # If the payload commits list was empty (e.g. GitHub PR merge), derive # changed files from the before/after SHAs instead. if not changed_files and before and after and before != '0' * 40: diff_result = subprocess.run( ['git', 'diff', '--name-only', before, after], capture_output=True, text=True, timeout=15 ) if diff_result.returncode == 0: changed_files = [f for f in diff_result.stdout.splitlines() if f] logger.info(f"Derived changed files from git diff: {changed_files}") # 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") # Relay gateway: restart if SHARED/relay_gateway/ files changed relay_gateway_changed = any( f.startswith('SHARED/relay_gateway/') for f in changed_files ) if relay_gateway_changed: processes_to_restart.append('relay-gateway') logger.info("Relay gateway files changed, will restart relay-gateway") # TSSBOT API: restart if TSSBOT/web/ files changed tssbot_api_changed = any( f.startswith('TSSBOT/web/') for f in changed_files ) if tssbot_api_changed: processes_to_restart.append('tssbot-api') logger.info("TSSBOT API files changed, will restart tssbot-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') before = payload.get('before', '') after = payload.get('after', '') 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, before, after) 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('SREBOT_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)