add SREBOT, SHARED, TSSBOT contents (fixup for #1223)
PR #1223 only staged the deletions of the old paths because the new top-level directories were still untracked when the commit was authored. This commit adds the actual restructured tree: SREBOT/ (existing bot), SHARED/ (vromfs, data_parser, ICONS/MAPS/FONTS, DAGOR_FILES, update_game_files), and TSSBOT/ (skeleton). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
#!/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 = []
|
||||
|
||||
# Bot: restart if SREBOT/BOT/, SHARED/, SREBOT/start_bot.py, ecosystem.config.js,
|
||||
# or SREBOT root .py files changed
|
||||
bot_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 bot_changed:
|
||||
processes_to_restart.append('srebot')
|
||||
logger.info("Bot files changed, will restart srebot")
|
||||
|
||||
# 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)
|
||||
Reference in New Issue
Block a user