feat: 添加 Gitea webhook 自动部署功能

- 新增独立的 webhook 服务 (Go, 端口 9000)
- HMAC-SHA256 签名验证
- 零停机热更新部署
- 自动清理旧镜像
- 完整配置文档 WEBHOOK_SETUP.md
- 精简 README 版本历史为表格形式
This commit is contained in:
CHE LIANG ZHAO
2026-01-13 14:37:01 +08:00
parent 471bdeaf6b
commit 05ab270677
9 changed files with 1062 additions and 22 deletions

209
webhook/main.go Normal file
View File

@@ -0,0 +1,209 @@
package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// GiteaPayload Gitea webhook 推送事件
type GiteaPayload struct {
Repository struct {
Name string `json:"name"`
FullName string `json:"full_name"`
CloneURL string `json:"clone_url"`
} `json:"repository"`
Pusher struct {
Username string `json:"username"`
} `json:"pusher"`
Ref string `json:"ref"`
Commit string `json:"after"`
}
// Config webhook 服务配置
type Config struct {
Port string
Secret string
RepoPath string
ScriptPath string
}
var cfg Config
func init() {
// 从环境变量读取配置
cfg.Port = os.Getenv("WEBHOOK_PORT")
if cfg.Port == "" {
cfg.Port = "9000"
}
cfg.Secret = os.Getenv("WEBHOOK_SECRET")
if cfg.Secret == "" {
cfg.Secret = "your-webhook-secret"
}
cfg.RepoPath = os.Getenv("REPO_PATH")
if cfg.RepoPath == "" {
cfg.RepoPath = "/app"
}
cfg.ScriptPath = filepath.Join(cfg.RepoPath, "deploy.sh")
}
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Printf("🚀 Webhook 服务启动 | 监听端口: %s\n", cfg.Port)
log.Printf("📁 仓库路径: %s\n", cfg.RepoPath)
log.Printf("📜 部署脚本: %s\n", cfg.ScriptPath)
http.HandleFunc("/webhook", handleWebhook)
http.HandleFunc("/health", handleHealth)
if err := http.ListenAndServe(":"+cfg.Port, nil); err != nil {
log.Fatalf("❌ 服务器启动失败: %v", err)
}
}
// handleHealth 健康检查端点
func handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"time": time.Now().Format(time.RFC3339),
})
}
// handleWebhook 处理 Gitea webhook
func handleWebhook(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 读取请求体
body, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("❌ 读取请求体失败: %v", err)
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
defer r.Body.Close()
// 验证签名
if !verifySignature(r.Header.Get("X-Gitea-Signature"), body) {
log.Println("⚠️ 无效的签名")
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// 解析 payload
var payload GiteaPayload
if err := json.Unmarshal(body, &payload); err != nil {
log.Printf("❌ 解析 JSON 失败: %v", err)
http.Error(w, "Failed to parse JSON", http.StatusBadRequest)
return
}
// 只处理 master 分支的推送
if !strings.Contains(payload.Ref, "master") && !strings.Contains(payload.Ref, "main") {
log.Printf("⏭️ 忽略非主分支推送: %s", payload.Ref)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "非主分支,跳过部署",
})
return
}
log.Printf("✅ 收到有效的 Webhook 请求")
log.Printf(" 仓库: %s", payload.Repository.FullName)
log.Printf(" 推送者: %s", payload.Pusher.Username)
log.Printf(" 分支: %s", payload.Ref)
commitShort := payload.Commit
if len(commitShort) > 7 {
commitShort = commitShort[:7]
}
log.Printf(" 提交: %s", commitShort)
// 异步执行部署
go executeDeployment(payload)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
json.NewEncoder(w).Encode(map[string]string{
"message": "部署已启动",
"commit": payload.Commit,
})
}
// verifySignature 验证 Gitea webhook 签名
func verifySignature(signature string, body []byte) bool {
if cfg.Secret == "your-webhook-secret" {
log.Println("⚠️ 警告: 使用默认 secret建议设置 WEBHOOK_SECRET 环境变量")
return true
}
hash := hmac.New(sha256.New, []byte(cfg.Secret))
hash.Write(body)
expected := "sha256=" + hex.EncodeToString(hash.Sum(nil))
return signature == expected
}
// executeDeployment 执行部署
func executeDeployment(payload GiteaPayload) {
log.Println("\n🔄 开始部署流程...")
startTime := time.Now()
// 检查脚本是否存在
if _, err := os.Stat(cfg.ScriptPath); os.IsNotExist(err) {
log.Printf("❌ 部署脚本不存在: %s", cfg.ScriptPath)
return
}
// 执行部署脚本
cmd := exec.Command("bash", cfg.ScriptPath)
cmd.Dir = cfg.RepoPath
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
log.Printf("📜 执行部署脚本: %s", cfg.ScriptPath)
if err := cmd.Run(); err != nil {
log.Printf("❌ 部署脚本执行失败: %v", err)
if stderr.Len() > 0 {
log.Printf("错误输出:\n%s", stderr.String())
}
return
}
duration := time.Since(startTime).Seconds()
log.Printf("✅ 部署完成")
log.Printf(" 耗时: %.2f 秒", duration)
if stdout.Len() > 0 {
log.Printf("输出:\n%s", stdout.String())
}
log.Printf("📝 部署日志:\n")
log.Printf(" 仓库: %s", payload.Repository.FullName)
log.Printf(" 提交: %s", payload.Commit[:7])
log.Printf(" 分支: %s", payload.Ref)
log.Printf(" 推送者: %s", payload.Pusher.Username)
log.Printf(" 时间: %s", time.Now().Format(time.RFC3339))
}