TSHARPS-CI/ci-webhook.py
Claude BM cfaf67c4c6 Bind webhook server to 0.0.0.0 instead of 127.0.0.1
Required for Gitea webhook delivery via the server's public IP.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 16:47:25 +00:00

159 lines
5.4 KiB
Python
Executable File

#!/usr/bin/env python3
"""
TSHARPS CI — Webhook Receiver
Lightweight HTTP server that listens for Gitea push webhooks and spawns
the CI runner as a background process. Returns 200 immediately.
If the runner subprocess crashes, sends a distinct "CI RUNNER ERROR" alert.
"""
import hashlib
import hmac
import json
import os
import subprocess
import sys
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
CONFIG_PATH = SCRIPT_DIR / "ci-config.json"
def load_config():
with open(CONFIG_PATH) as f:
return json.load(f)
CONFIG = load_config()
PORT = CONFIG.get("webhook_port", 9500)
SECRET = CONFIG.get("webhook_secret", "").encode()
def verify_signature(payload: bytes, signature: str) -> bool:
"""Verify Gitea webhook HMAC signature."""
if not SECRET:
return True # No secret configured — accept all
expected = hmac.new(SECRET, payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(f"sha256={expected}", signature)
class WebhookHandler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path != "/ci":
self.send_response(404)
self.end_headers()
return
content_length = int(self.headers.get("Content-Length", 0))
payload = self.rfile.read(content_length)
# Verify signature if configured
signature = self.headers.get("X-Gitea-Signature", "")
if SECRET and not verify_signature(payload, signature):
self.send_response(403)
self.end_headers()
self.wfile.write(b"Invalid signature")
return
# Parse push event
try:
data = json.loads(payload)
except json.JSONDecodeError:
self.send_response(400)
self.end_headers()
self.wfile.write(b"Invalid JSON")
return
# Extract branch, commit, actor
ref = data.get("ref", "")
branch = ref.replace("refs/heads/", "") if ref.startswith("refs/heads/") else ref
commits = data.get("commits", [])
commit = commits[-1]["id"][:7] if commits else data.get("after", "")[:7]
actor = data.get("pusher", {}).get("login", "unknown")
# Check if branch is in our config
if branch not in CONFIG.get("branches", {}):
self.send_response(200)
self.end_headers()
self.wfile.write(f"Branch '{branch}' not configured — skipping".encode())
return
print(f"[CI] Push to {branch} ({commit}) by {actor} — spawning runner")
# Spawn ci-runner.sh as background process
runner_path = SCRIPT_DIR / "ci-runner.sh"
try:
proc = subprocess.Popen(
["bash", str(runner_path), branch, commit, actor],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
cwd=str(SCRIPT_DIR),
)
# Non-blocking — don't wait for completion
# But set up a thread to catch crashes
import threading
def _monitor(p, branch, commit, actor):
stdout, _ = p.communicate()
if p.returncode != 0:
print(f"[CI] RUNNER CRASHED for {branch} ({commit}): exit {p.returncode}")
# Send runner error notification
notify_path = SCRIPT_DIR / "ci-notify.sh"
subprocess.run(
["bash", str(notify_path), branch, commit, "error",
f"Runner crashed with exit code {p.returncode}", "", "0s", actor],
cwd=str(SCRIPT_DIR),
)
else:
print(f"[CI] Runner completed for {branch} ({commit})")
t = threading.Thread(target=_monitor, args=(proc, branch, commit, actor), daemon=True)
t.start()
except Exception as e:
print(f"[CI] Failed to spawn runner: {e}")
# Send error notification directly
notify_path = SCRIPT_DIR / "ci-notify.sh"
subprocess.run(
["bash", str(notify_path), branch, commit, "error",
f"Failed to spawn runner: {e}", "", "0s", actor],
cwd=str(SCRIPT_DIR),
)
# Return 200 immediately
self.send_response(200)
self.end_headers()
self.wfile.write(f"CI triggered for {branch} ({commit})".encode())
def do_GET(self):
"""Health check endpoint."""
if self.path == "/health":
self.send_response(200)
self.end_headers()
self.wfile.write(json.dumps({
"status": "healthy",
"branches": list(CONFIG.get("branches", {}).keys()),
"port": PORT,
}).encode())
return
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
print(f"[CI-Webhook] {args[0]}")
def main():
print(f"[CI] TSHARPS CI Webhook Receiver starting on port {PORT}")
print(f"[CI] Configured branches: {list(CONFIG.get('branches', {}).keys())}")
print(f"[CI] Signature validation: {'enabled' if SECRET else 'disabled'}")
server = HTTPServer(("0.0.0.0", PORT), WebhookHandler)
try:
server.serve_forever()
except KeyboardInterrupt:
print("[CI] Shutting down")
server.shutdown()
if __name__ == "__main__":
main()