改一行代码上线要几步?
打包、登录宝塔、找到站点目录、上传覆盖、清缓存——周末改个错别字也得走一遍。更惨的是某次改完急着走,本地推了,服务器没拉,线上跑着三天前的版本,用户反馈的 bug 你在这边怎么都复现不出来。
这篇把这个环节彻底砍掉:**本地 `git push` 之后,服务器自己把代码拉下来,全程你不用再登面板。**

## 01 先搞清它在干什么
原理一句话:**代码仓库被 push 时,会往你留的一个网址发一条通知(HTTP 请求),宝塔收到通知就去执行一句脚本,脚本做的事就是 `git pull`。**
三个角色:
| 角色 | 干什么 |
| ----------------------- | -------------------------------------- |
| Gitee / GitHub / GitLab | 事件源:你 push 了,它就发通知 |
| 宝塔 WebHook 插件 | 接收器:守着一个网址,收到请求就跑脚本 |
| 同步脚本(shell) | 执行者:`git pull` + 修权限 + 写日志 |
所以整套东西没有任何黑科技,难的全在细节:**脚本放哪、谁来跑、权限怎么给、失败了去哪看日志。**
> **顺带说个安全提醒**:宝塔给的 WebHook 地址里带一串密钥(access_key),**谁拿到谁就能让你的服务器拉代码**。别把它贴到公开仓库、群聊、截图里;服务器上有防火墙的,建议只放行码云/GitHub 的回调 IP。
## 02 服务器上先把 Git 和钥匙准备好
进宝塔 → 终端,先看有没有 git(宝塔机器一般自带):
```bash
git --version
```

没有就装:
```bash
# CentOS / 宝塔常见
yum install -y git
# Ubuntu / Debian
apt-get install -y git
```
生成 SSH 钥匙(一路回车三下,不要设密码,否则脚本拉代码会卡住):
```bash
ssh-keygen -t rsa -C "bt-deploy"
cat ~/.ssh/id_rsa.pub
```
把 `cat` 出来的那一整串复制下来(以 `ssh-rsa` 开头、以你的主机名结尾),粘到 **Gitee → 设置 → SSH 公钥**,标题会自动生成。

验证服务器和码云是否握手成功(这一步不能跳过):
```bash
ssh -T git@gitee.com
# 看到 Hi XXX! You've successfully authenticated 就成了
```
这一步的意义:让服务器有了"免密拉代码"的身份。后面脚本用 SSH 地址拉代码,就不会在深夜因为要输密码而静默失败。
## 03 站点目录:先把仓库 clone 下来
进 `/www/wwwroot`(宝塔站点默认都在这),用 **SSH 地址** clone:
```bash
cd /www/wwwroot
git clone git@gitee.com:你的用户名/你的仓库名.git my-site
```
> ⚠️ 目录名记住它——**它就是后面 WebHook 地址里要传的参数**。仓库名和站点目录名保持一致,整套配置最省事。
clone 完务必把归属改回 `www`(宝塔站点的运行用户,不改的话 Web 访问可能 403):
```bash
chown -R www:www /www/wwwroot/my-site
```
再放行一次 git 的目录安全检查(避免后面脚本报错 `detected dubious ownership`):
```bash
git config --global --add safe.directory /www/wwwroot/my-site
```
## 04 装宝塔 WebHook,填脚本
宝塔面板 → 软件商店 → 搜 **WebHook** → 安装。装完打开它,**添加 Hook**,名称随便写(比如 `sync-site`),执行脚本贴下面这份:
```bash
#!/bin/bash
##############################################################################
# 橙码酱 · 宝塔 WebHook 自动同步脚本
#
# v2 相比网上流传的版本修了三件事,都是真踩过的坑:
# 1. 同一秒被触发多次 → 加并发锁,只跑一个,其余打印 [SKIP] 直接退出
# 2. SSH 认证失败时 git 会卡在"等你输密码",脚本永远不返回 → BatchMode,失败立刻报错
# 3. git 的报错走 stderr,不进宝塔日志 → exec 2>&1,报错一并可见
##############################################################################
exec 2>&1 # 让 git 的报错也进宝塔日志,否则你只能看到一半
# ====================== 只需改这三行 ======================
GIT_USER="你的码云用户名" # gitee.com 后面的用户名
BRANCH="master" # 要同步的分支(main / master / dev …)
WWW_ROOT="/www/wwwroot" # 站点根目录
# =========================================================
LOG_DIR="/www/wwwlogs/deploy" # 部署日志目录,排错全靠它
# 防卡死:SSH 不弹密码框、连不上就 10 秒超时报错;https 地址也不弹凭据框
export GIT_SSH_COMMAND="ssh -o BatchMode=yes -o ConnectTimeout=10"
export GIT_TERMINAL_PROMPT=0
echo ""
echo "===== $(date "+%Y-%m-%d %H:%M:%S") Start ====="
# ---------- 0. 取参数:param 传的就是站点目录名 ----------
if [ -z "$1" ]; then
echo "[ERROR] param 为空:WebHook 地址请带 ¶m=站点目录名"
echo "===== End ====="
exit 1
fi
SITE="$1"
gitPath="$WWW_ROOT/$SITE"
gitHttp="git@gitee.com:${GIT_USER}/${SITE}.git" # SSH 地址;注释用 #,不能用 //
echo "[STEP1] 参数 OK:站点目录 = $gitPath"
# ---------- 1. 并发锁:同一秒被触发多次时只跑一个 ----------
LOCK="/tmp/bt_sync_${SITE}.lock"
if ! mkdir "$LOCK" 2>/dev/null; then
echo "[SKIP] 上一次同步还没跑完,本次跳过(避免 git 抢锁卡死)"
echo "===== End ====="
exit 0
fi
trap 'rm -rf "$LOCK"' EXIT
echo "[STEP2] 拿到锁,开始同步"
# ---------- 2. 目录不存在就创建并克隆 ----------
if [ ! -d "$gitPath" ]; then
echo "[STEP3] 目录不存在,创建并克隆"
mkdir -p "$gitPath"
cd "$gitPath" || exit 1
git clone -b "$BRANCH" "$gitHttp" tmp_clone
mv tmp_clone/.git ./
rm -rf tmp_clone
fi
cd "$gitPath" || exit 1
# ---------- 3. 放行 git 安全目录检查(避免 dubious ownership 报错)----------
git config --global --add safe.directory "$gitPath"
echo "[STEP4] 进入站点目录,开始拉取"
# ---------- 4. 强制对齐远端 ----------
# 部署目录不该有人手改,所以用 reset --hard 而不是 pull:
# pull 在本地有改动/换行符差异时会因冲突失败,reset 则是"和仓库一模一样"
git fetch --all
git reset --hard "origin/$BRANCH"
echo "[STEP5] 代码已对齐"
# ---------- 5. 归属还给 www(宝塔站点运行用户)----------
chown -R www:www "$gitPath"
# ---------- 6. 记一笔日志,方便事后查 ----------
mkdir -p "$LOG_DIR"
{
echo "----- $(date "+%Y-%m-%d %H:%M:%S") -----"
echo "站点目录:$gitPath"
echo "当前版本:$(git rev-parse --short HEAD)"
git log -1 --pretty=format:"%h %an %ad %s" --date=format:"%Y-%m-%d %H:%M"
echo ""
} >> "$LOG_DIR/${SITE}.log" 2>&1
echo "[STEP6] 已更新到:$(git rev-parse --short HEAD)"
echo "===== End ====="
exit 0
```

保存之后,**再点一次「编辑」,确认脚本原样还在**。宝塔有时候会过滤掉部分字符,脚本残缺时它照样返回 200,但服务器什么都没做——这个坑能让你排查一晚上。
## 05 拿到地址,回填到仓库

保存后,WebHook 界面会给你一条地址,长这样:
```text
http://你的服务器IP:8888/hook?access_key=一串密钥¶m=aaa
```
把最后的 **`aaa` 改成你的站点目录名**(也就是 03 步 clone 出来的那个名字),然后整条复制。
打开 **Gitee 仓库 → 管理 → WebHooks → 添加**,把地址粘进去,事件勾 **Push**(有需要再勾 Tag Push),保存。
改完面板记得重启一次宝塔,让权限和环境变量彻底生效:
```bash
/etc/init.d/bt restart
```
**GitHub / GitLab 同款操作**:都是把这条 URL 填到仓库的 Webhooks → Payload URL 里,Content type 选默认的即可——宝塔那边不解析请求体,只看 URL 里的 param。
## 06 验证:别急着改代码
最快的验证不需要 push——直接浏览器访问那条 WebHook 地址(或用 `curl`):
```bash
curl "http://你的服务器IP:8888/hook?access_key=一串密钥¶m=my-site"
```
回来看三样东西:
| 看哪 | 期望结果 |
| ----------------------------- | ------------------------------------------------ |
| Gitee → WebHooks → 查看更多 | 状态码 **200** |
| 宝塔 WebHook → 日志 | 出现你写的时间戳、`Reset` 结果、最新 commit 号 |
| 站点目录文件 | 修改时间和刚才一致 |
三样都对上了,再在本地改一行、commit、push,亲眼看着服务器目录自己变。那一刻的爽感,值这一篇的收藏。
## 07 日志只有 Start,永远等不到 End
这是我自己在服务器上真遇到的一幕——日志长这样,整整九行 `Start`,一行 `End` 都没有:
```text
===== 2026-10-04 21:45:13 Start =====
===== 2026-10-04 21:45:13 Start =====
===== 2026-10-04 21:45:13 Start =====
===== 2026-10-04 21:45:13 Start =====
===== 2026-10-04 21:45:13 Start =====
===== 2026-10-04 21:45:19 Start =====
===== 2026-10-04 22:02:12 Start =====
===== 2026-10-04 22:03:15 Start =====
===== 2026-10-04 22:31:15 Start =====
```
先读这段日志:**同一个「21:45:13」触发了五次**。这本身就是最大的线索。
`Start` 是脚本第一行打印的,它出现了,说明「宝塔收到请求 → 拉起脚本 → 脚本开始跑」这条路是通的,问题**不在 WebHook 配置,而在脚本中途卡住/静默死掉**。卡住的位置按概率排:
| 排名 | 卡在哪 | 为什么会卡 |
| ---- | --------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| 1 | `git fetch`(等你输密码) | 用了 HTTPS 地址,或 SSH key 没配对。git 想问你要账号密码,可 WebHook 没有终端,它就一直等——等到天荒地老 |
| 2 | `git fetch`(抢 `.git/index.lock`) | 五条同一秒触发,五个进程同时摸同一个仓库,git 互相等锁 |
| 3 | 报错静默吞掉 | git 的报错走 stderr,宝塔日志只接 stdout,你看到的就是「啥也没有」 |
三条里前两条有一个共同点:**脚本一直在往 `End` 走,但永远走不到**。
所以加固版做了三件事(就是上面脚本里那三行看着"多余"的配置):
- `export GIT_TERMINAL_PROMPT=0` + `GIT_SSH_COMMAND="ssh -o BatchMode=yes"` → 认证失败**立刻报错返回**,绝不弹框等待
- `mkdir "$LOCK"` 原子并发锁 → 同一秒触发 N 次,只跑一个,其余打印 `[SKIP]` 直接 `End` 退出
- `exec 2>&1` → git 的报错并进日志,再也不会「一片空白」
外加 `[STEP1]`~`[STEP6]` 六步面包屑:**日志停在 STEP 几,就是卡在第几步**,不用再猜。
真排查时,别先改业务代码,先跑一遍探针脚本(`bt-webhook-probe.sh`,打印 `[P0]`~`[P6]` 的环境信息,跑到 `Probe End` 说明执行链路没问题)。另外记得清掉已经卡住的僵尸进程:
```bash
ps -ef | grep -E "git|hook" | grep -v grep
kill -9 上面查出来的进程号
rm -f /www/wwwroot/你的站点目录/.git/index.lock
```
## 排错清单:报错基本就这 6 种
| 现象 | 原因 | 一句解决 |
| ----------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------- |
| WebHook 日志只有 `[ERROR] param 为空` | 地址里没带 `param`,或拼错了参数名 | 在 URL 末尾补 `¶m=站点目录名` |
| `Permission denied (publickey)` | SSH 公钥没配对,或用了 HTTPS 地址 | 回到 02 步 `ssh -T git@gitee.com` 验一次 |
| `detected dubious ownership` | 仓库属主和执行者不是一个人 | `git config --global --add safe.directory 目录` |
| `sudo: command not found` / `sudo 需要 tty` | 网上教程习惯性加 sudo,面板里本来就是 root | **把 sudo 全删掉** |
| 返回 200 但代码没变 | 脚本被面板过滤,或者改的不是你拉的分支 | 重新编辑脚本确认;检查 `BRANCH` 变量 |
| 只有 `Start` 没有 `End` | git 在等你输密码、或多个进程抢 `.git/index.lock` | 换加固版脚本(BatchMode + 并发锁),再清 `index.lock` |
## 速查表
| 环节 | 关键命令 / 位置 |
| ------------ | --------------------------------------------------- |
| 生成钥匙 | `ssh-keygen -t rsa` → `cat ~/.ssh/id_rsa.pub` |
| 配公钥 | Gitee → 设置 → SSH 公钥 |
| 握手验证 | `ssh -T git@gitee.com` |
| 首次 clone | `/www/wwwroot` 下 `git clone git@…` |
| 权限 | `chown -R www:www 目录` |
| 放行安全目录 | `git config --global --add safe.directory 目录` |
| WebHook 地址 | `http://IP:8888/hook?access_key=…¶m=站点目录名` |
| 触发方式 | Gitee/GitHub 的 Push 事件,或手动 `curl` 那条地址 |
| 看日志 | 宝塔 → WebHook → 日志 |
一句话总结:**公钥打通身份,WebHook 打通通知,脚本负责最后那一下 `reset --hard`。** 三样都通了,push 就是上线。橙码酱,分享更有用的技术知识,助你成为更优秀的开发者。