想象一下:你在本地写完代码,git push 敲下去,服务器上自动 git pull,项目重启,前端页面上的版本号自动更新。全程不用碰服务器,爽不爽?这篇文章手把手教你搞定!

整体流程概览

① 本地开发

写代码 → git commit → git push

② GitHub

收到 push → 触发 Webhook

③ 服务器

Webhook 接口收到请求 → git pull → 重启服务

④ 前端展示

Django 读取 Git 版本号 → 渲染到页面底部

第一步:本地开发环境准备

1.1 初始化 Git 仓库并关联 GitHub

# 进入你的 Django 项目目录
cd /path/to/your/project

# 初始化 git(如果还没有)
git init

# 添加远程仓库
git remote add origin git@github.com:yourname/yourproject.git

# 首次推送
git add .
git commit -m "🎉 初始化项目"
git push -u origin main

推荐使用 SSH 方式连接 GitHub,后续服务器自动 pull 更安全。如果你还没配 SSH key:

# 本地生成密钥
ssh-keygen -t ed25519 -C "your@email.com"

# 复制公钥
cat ~/.ssh/id_ed25519.pub

# 粘贴到 GitHub → Settings → SSH and GPG keys → New SSH key

第二步:GitHub 配置 Webhook

2.1 创建 Webhook

进入你的 GitHub 仓库 → Settings → Webhooks → Add webhook

配置项填写内容说明
Payload URLhttps://your-server.com/webhook/deploy/服务器上接收 webhook 的接口地址
Content typeapplication/json选 JSON 格式
Secret一个随机字符串(如 openssl rand -hex 32 生成)用于签名验证,防止伪造请求
Which events?Just the push event只有 push 时才触发自动部署

2.2 Webhook 安全:签名验证原理

为什么需要 Secret?

没有签名验证的话,任何人都可以往你的接口发请求,相当于把服务器大门敞开。GitHub 会在请求头里带上用 Secret 计算的 HMAC-SHA256 签名,服务器收到后自己也算一遍,对上了才执行 pull。

import hmac
import hashlib

WEBHOOK_SECRET = b"your-random-secret-here"

def verify_signature(payload_body: bytes, signature_header: str) -> bool:
    """验证 GitHub Webhook 签名"""
    if not signature_header:
        return False
    hash_hex = hmac.new(
        WEBHOOK_SECRET,
        msg=payload_body,
        digestmod=hashlib.sha256
    ).hexdigest()
    expected = f"sha256={hash_hex}"
    return hmac.compare_digest(expected, signature_header)

第三步:服务器接收 Webhook + 自动部署

3.1 Django 视图处理 Webhook

import json
import hmac
import hashlib
import subprocess
import os
from django.http import HttpResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST


PROJECT_DIR = "/www/yourproject"
WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "").encode()


@csrf_exempt
@require_POST
def github_webhook(request):
    """接收 GitHub push 事件,自动 git pull"""
    # 1. 验证签名
    signature = request.headers.get("X-Hub-Signature-256", "")
    if not verify_signature(request.body, signature):
        return HttpResponseForbidden("Invalid signature")

    # 2. 解析 payload 判断分支
    try:
        payload = json.loads(request.body)
        branch = payload.get("ref", "")
    except json.JSONDecodeError:
        return HttpResponse("Invalid JSON", status=400)

    # 3. 只处理 main 分支的 push
    if branch != "refs/heads/main":
        return HttpResponse(f"Ignored branch: {branch}")

    # 4. 执行 git pull
    try:
        output = subprocess.check_output(
            ["git", "-C", PROJECT_DIR, "pull", "origin", "main"],
            stderr=subprocess.STDOUT,
            timeout=30
        ).decode()
    except subprocess.CalledProcessError as e:
        return HttpResponse(f"Git pull failed: {e.output.decode()}", status=500)

    # 5. 重启 Gunicorn / 收集静态文件
    subprocess.run(["systemctl", "restart", "yourproject-gunicorn"])

    return HttpResponse(f"Deployed! \n{output}")


def verify_signature(body: bytes, signature: str) -> bool:
    if not signature:
        return False
    expected = "sha256=" + hmac.new(
        WEBHOOK_SECRET, body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

3.2 配置 URL 路由

from django.urls import path
from . import views

urlpatterns = [
    path("webhook/deploy/", views.github_webhook, name="github-webhook"),
]

3.3 服务器环境变量设置

不要把 Secret 写死在代码里!

用环境变量或 .env 文件配合 python-decouple / django-environ 来管理。

# 在 /etc/environment 或 ~/.bashrc 中
export WEBHOOK_SECRET="your-random-secret-from-step-2"

3.4 别忘了:服务器也要配 SSH Key

服务器需要能免密 pull 你的 GitHub 仓库:

# 生成 deploy key
ssh-keygen -t ed25519 -C "deploy@your-server"

# 把公钥添加到 GitHub 仓库的 Deploy Keys
# GitHub → Settings → Deploy keys → Add deploy key
cat ~/.ssh/id_ed25519.pub

第四步:Django 读取 Git 版本号并显示在前端

4.1 生成版本号文件

创建一个工具函数,从 Git 标签和提交哈希生成语义化版本号:

import subprocess
import os
from pathlib import Path


def get_git_version() -> str:
    """从 Git 仓库获取版本号,格式: v1.2.3-4-gabc1234"""
    project_dir = Path(__file__).resolve().parent.parent  # Django 根目录
    try:
        # git describe: 生成类似 v1.0-3-gab12cd 的版本号
        tag = subprocess.check_output(
            ["git", "-C", str(project_dir), "describe", "--tags", "--always",
             "--dirty=-dirty"],
            stderr=subprocess.DEVNULL,
            timeout=5
        ).decode().strip()
        return tag
    except (subprocess.CalledProcessError, FileNotFoundError):
        return "unknown"


def get_build_info() -> dict:
    """获取构建信息"""
    project_dir = Path(__file__).resolve().parent.parent
    info = {"version": "unknown", "branch": "unknown", "commit": "unknown"}

    try:
        info["version"] = subprocess.check_output(
            ["git", "-C", str(project_dir), "describe", "--tags", "--always"],
            stderr=subprocess.DEVNULL
        ).decode().strip()
    except Exception:
        pass

    try:
        info["branch"] = subprocess.check_output(
            ["git", "-C", str(project_dir), "rev-parse", "--abbrev-ref", "HEAD"],
            stderr=subprocess.DEVNULL
        ).decode().strip()
    except Exception:
        pass

    try:
        info["commit"] = subprocess.check_output(
            ["git", "-C", str(project_dir), "rev-parse", "--short", "HEAD"],
            stderr=subprocess.DEVNULL
        ).decode().strip()
    except Exception:
        pass

    return info

4.2 创建 Context Processor 注入版本号

from .utils.version import get_build_info

def version_context(request):
    """将版本号注入到所有模板的 context 中"""
    info = get_build_info()
    return {
        "APP_VERSION": info["version"],
        "GIT_BRANCH": info["branch"],
        "GIT_COMMIT": info["commit"],
    }

4.3 注册 Context Processor

TEMPLATES = [
    {
        # ... 其他配置 ...
        "OPTIONS": {
            "context_processors": [
                # ... Django 默认的 processors ...
                "yourproject.context_processors.version_context",  # ← 新增
            ],
        },
    },
]

4.4 前端模板中显示版本号

<footer class="footer">
  <div class="container">
    <span title="Branch: {{ GIT_BRANCH }} | Commit: {{ GIT_COMMIT }}">
      🏷️ {{ APP_VERSION }}
    </span>
  </div>
</footer>

4.5 进阶:写入版本文件避免运行时依赖 Git

性能优化小技巧:每次请求都调用 git describe 会有 IO 开销。推荐在部署时把版本号写入一个文件,Django 启动时读取一次就够了。

# 写入版本文件
git describe --tags --always > /www/yourproject/VERSION
from pathlib import Path

def get_version() -> str:
    version_file = Path(__file__).resolve().parent.parent / "VERSION"
    if version_file.exists():
        return version_file.read_text().strip()
    return "unknown"

第五步:完整的 Webhook 部署脚本

如果想用独立的部署脚本而不是 Django 视图,可以用 Flask / FastAPI 做个微型服务,或者写一个 shell 脚本 + nginx 代理:

import hmac
import hashlib
import json
import subprocess
import os
from http.server import HTTPServer, BaseHTTPRequestHandler

SECRET = os.environ["WEBHOOK_SECRET"].encode()
PROJECT_DIR = "/www/yourproject"


class DeployHandler(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path != "/webhook/deploy/":
            self.send_error(404)
            return

        body = self.rfile.read(int(self.headers["Content-Length"]))

        # 验证签名
        sig = self.headers.get("X-Hub-Signature-256", "")
        expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
        if not hmac.compare_digest(expected, sig):
            self.send_error(403)
            return

        # 解析事件类型
        payload = json.loads(body)
        ref = payload.get("ref", "")

        if ref != "refs/heads/main":
            self.send_response(200)
            self.end_headers()
            self.wfile.write(f"Ignored {ref}".encode())
            return

        # 拉代码
        result = subprocess.run(
            ["git", "-C", PROJECT_DIR, "pull"],
            capture_output=True, text=True, timeout=30
        )

        # 写版本号
        subprocess.run(
            f"git -C {PROJECT_DIR} describe --tags --always > {PROJECT_DIR}/VERSION",
            shell=True
        )

        # 重启服务
        subprocess.run(["systemctl", "restart", "yourproject-gunicorn"])

        self.send_response(200 if result.returncode == 0 else 500)
        self.end_headers()
        self.wfile.write(result.stdout.encode())


if __name__ == "__main__":
    server = HTTPServer(("0.0.0.0", 9000), DeployHandler)
    print("🚀 Deploy server on :9000")
    server.serve_forever()

用 systemd 管理这个服务,再用 nginx 反向代理到 :9000,就相当于一个独立的 Webhook 服务。

最终效果

到此,整个流程就打通了:

  • 你在本地改完代码 → git commit -m "修复登录bug" → git push
  • GitHub 收到 push → 调用 Webhook 接口
  • 服务器收到请求 → 验证签名 → git pull → 写版本文件 → 重启 Gunicorn
  • 用户刷新页面 → Django 读取版本号 → 页面底部显示 v1.5.3-2-ga8f12cd

一行命令,全自动搞定。你只管写代码,剩下的交给 Webhook。

常见问题排查

问题解决方法
Webhook 不触发去 GitHub → Settings → Webhooks → Recent Deliveries 查看发送历史和响应内容
签名验证失败确认服务器上的 SECRET 和 GitHub 配置的完全一致(注意末尾换行符)
git pull 无权限服务器上的 SSH key 是否已添加为 Deploy Key?用 ssh -T git@github.com 测试
Gunicorn 重启失败用 journalctl -u yourproject-gunicorn -n 50 查看日志
版本号显示 unknown服务器上有没有 .git 目录?是否用 git clone 而非直接复制文件?
git pull 有本地修改冲突在 pull 前加 git reset --hard && git clean -fd(注意会丢掉服务器上未提交的修改!)