diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml.disabled similarity index 100% rename from .gitea/workflows/deploy.yaml rename to .gitea/workflows/deploy.yaml.disabled diff --git a/scripts/deploy-dev.sh b/scripts/deploy-dev.sh new file mode 100755 index 0000000..a18af00 --- /dev/null +++ b/scripts/deploy-dev.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# push → Gitea Webhook → 本脚本:仅部署 dev 到本机 Docker Compose +set -euo pipefail + +APP_DIR="${APP_DIR:-/opt/AirShelf}" +BRANCH="${BRANCH:-dev}" +COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}" +ENV_FILE="${ENV_FILE:-core/backend/.env}" +LOG_DIR="${LOG_DIR:-/var/log/airshelf}" +LOCK_FILE="${LOCK_FILE:-/tmp/airshelf-deploy-dev.lock}" + +mkdir -p "$LOG_DIR" +LOG_FILE="${LOG_DIR}/deploy-dev.log" + +exec 9>"$LOCK_FILE" +if ! flock -n 9; then + echo "$(date '+%F %T') another deploy is running, skip" | tee -a "$LOG_FILE" + exit 0 +fi + +{ + echo "======== $(date '+%F %T') deploy start ========" + cd "$APP_DIR" + + if [[ ! -d .git ]]; then + echo "ERROR: $APP_DIR is not a git repo. Clone from your Gitea first." + exit 1 + fi + + # .env 在仓库里被跟踪,pull 会覆盖服务器上的生产配置;先备份再还原 + ENV_BACKUP="" + if [[ -f "$ENV_FILE" ]]; then + ENV_BACKUP="$(mktemp)" + cp -a "$ENV_FILE" "$ENV_BACKUP" + echo "backed up $ENV_FILE" + fi + + git fetch --prune origin + git checkout "$BRANCH" + git reset --hard "origin/$BRANCH" + + if [[ -n "$ENV_BACKUP" ]]; then + cp -a "$ENV_BACKUP" "$ENV_FILE" + rm -f "$ENV_BACKUP" + echo "restored $ENV_FILE" + fi + + docker compose -f "$COMPOSE_FILE" up -d --build + + echo "======== $(date '+%F %T') deploy ok ========" +} >>"$LOG_FILE" 2>&1 diff --git a/scripts/webhook/airshelf-webhook.service b/scripts/webhook/airshelf-webhook.service new file mode 100644 index 0000000..49b4971 --- /dev/null +++ b/scripts/webhook/airshelf-webhook.service @@ -0,0 +1,16 @@ +[Unit] +Description=AirShelf Gitea deploy webhook +After=network.target docker.service + +[Service] +Type=simple +Environment=WEBHOOK_SECRET=CHANGE_ME +Environment=WEBHOOK_HOST=127.0.0.1 +Environment=WEBHOOK_PORT=9000 +Environment=DEPLOY_SH=/opt/AirShelf/scripts/deploy-dev.sh +ExecStart=/usr/bin/python3 /opt/AirShelf/scripts/webhook/receiver.py +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/scripts/webhook/receiver.py b/scripts/webhook/receiver.py new file mode 100755 index 0000000..02ebd48 --- /dev/null +++ b/scripts/webhook/receiver.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Gitea push webhook → only refs/heads/dev → scripts/deploy-dev.sh""" +from __future__ import annotations + +import hashlib +import hmac +import json +import os +import subprocess +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +SECRET = os.environ.get("WEBHOOK_SECRET", "").encode() +DEPLOY_SH = os.environ.get("DEPLOY_SH", "/opt/AirShelf/scripts/deploy-dev.sh") +HOST = os.environ.get("WEBHOOK_HOST", "0.0.0.0") +PORT = int(os.environ.get("WEBHOOK_PORT", "9000")) + + +def verify(sig: str | None, body: bytes) -> bool: + if not SECRET or not sig: + return False + digest = hmac.new(SECRET, body, hashlib.sha256).hexdigest() + return hmac.compare_digest(digest, sig.strip()) + + +def run_deploy() -> None: + subprocess.run([DEPLOY_SH], check=False) + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, fmt: str, *args) -> None: + print(f"[webhook] {self.address_string()} - {fmt % args}") + + def do_POST(self) -> None: + if self.path not in ("/hooks/deploy-dev", "/deploy-dev", "/"): + self.send_response(404) + self.end_headers() + return + length = int(self.headers.get("Content-Length", "0")) + body = self.rfile.read(length) + sig = self.headers.get("X-Gitea-Signature") or self.headers.get("X-Hub-Signature-256", "") + if sig.startswith("sha256="): + sig = sig[7:] + if not verify(sig, body): + self.send_response(401) + self.end_headers() + self.wfile.write(b"invalid signature") + return + try: + payload = json.loads(body.decode("utf-8") or "{}") + except json.JSONDecodeError: + self.send_response(400) + self.end_headers() + return + ref = payload.get("ref", "") + if ref != "refs/heads/dev": + self.send_response(200) + self.end_headers() + self.wfile.write(f"ignored ref={ref}".encode()) + return + threading.Thread(target=run_deploy, daemon=True).start() + self.send_response(200) + self.end_headers() + self.wfile.write(b"deploy-dev accepted") + + def do_GET(self) -> None: + self.send_response(200) + self.end_headers() + self.wfile.write(b"airshelf webhook ok") + + +if __name__ == "__main__": + if not SECRET: + raise SystemExit("WEBHOOK_SECRET is required") + print(f"listening on {HOST}:{PORT}, deploy={DEPLOY_SH}") + HTTPServer((HOST, PORT), Handler).serve_forever()