📚 Git 深入课(以 sourcepack 仓库为标本)· 第 11/12 章
全部示例均来自真机实测;实验请在隔离克隆里做。
本章目标:搞清"自动化检查该放在哪一层"——本地钩子、服务端钩子、还是 CI?
学完你能解释:为什么钩子替代不了 CI、为什么发布要用签名 tag、
以及 sourcepack 的发布链路为什么长成现在这样(含一次真实事故的复盘)。
11.1 钩子的机制:就是可执行文件
钩子(hook)是 Git 在特定时机调用的可执行文件,默认放在 .git/hooks/:
$ ls .git/hooks | head -4
applypatch-msg.sample
commit-msg.sample
pre-commit.sample # ← .sample 后缀不会被调用
pre-push.sample
启用方式就是去掉 .sample 并给执行权限(第 3 章:Git 的很多"机制"都是文件系统约定)。
实测:一个 pre-commit 拦截
$ cat .git/hooks/pre-commit
#!/bin/sh
if grep -rn "TODO-FORBIDDEN" --include="*.txt" . >/dev/null 2>&1; then
echo "❌ pre-commit: 发现禁止的内容 TODO-FORBIDDEN" >&2
exit 1
fi
$ chmod +x .git/hooks/pre-commit
$ printf 'ok\n' > ok.txt && git add ok.txt && git commit -m "正常提交"
[main abc1234] 正常提交 # ✅ 通过
$ printf 'TODO-FORBIDDEN\n' > bad.txt && git add bad.txt && git commit -m "坏提交"
❌ pre-commit: 发现禁止的内容 TODO-FORBIDDEN
$ echo $?
1 # ✅ 被拦下
实测:--no-verify 可以绕过
$ git commit --no-verify -m "绕过钩子"
[main 2b2cba1] 绕过钩子 # ⚠️ 钩子形同虚设
实测:钩子不进版本库
$ git ls-files .git/hooks | wc -l
0 # 克隆不会带走钩子
想让团队共享钩子,常见做法是把脚本放进仓库并设置路径:
$ git config core.hooksPath .githooks # 团队成员各自设置一次
11.2 钩子的位置:客户端 vs 服务端
| 层 | 代表钩子 | 何时跑 | 能否绕过 |
|---|---|---|---|
| 客户端(本地) | pre-commit、commit-msg、pre-push、pre-rebase |
你执行对应命令时 | ✅ --no-verify / 直接不装 |
| 服务端(仓库侧) | pre-receive、update、post-receive |
收到推送时 | ❌ 客户端无法绕过 |
实测:服务端 pre-receive 是真正的闸
# 在裸仓库里放一个钩子
$ cat /tmp/hook-lab-remote.git/hooks/pre-receive
#!/bin/sh
while read oldrev newrev refname; do
echo "服务端收到推送: $refname" >&2
if [ "$refname" = "refs/heads/forbidden" ]; then
echo "❌ pre-receive: 禁止推送 forbidden 分支" >&2
exit 1
fi
done
exit 0
$ git push origin allowed
remote: 服务端收到推送: refs/heads/allowed
$ echo $?
0
$ git push origin forbidden
remote: ❌ pre-receive: 禁止推送 forbidden 分支
! [remote rejected] forbidden -> forbidden (pre-receive hook declined)
$ echo $?
1
$ git push --no-verify origin forbidden # 试试绕过
remote: ❌ pre-receive: 禁止推送 forbidden 分支
! [remote rejected] ... # ← 依然被拒:服务端钩子不理会 --no-verify
这张对照表是本章的核心结论:
| 需求 | 正确的位置 |
|---|---|
| 我自己的快速检查(格式化、拼写) | 客户端 pre-commit(可绕过,只当便利) |
| 团队必须遵守的规范(禁止大文件、密钥扫描) | CI 或服务端钩子(不可绕过) |
| 保护分支(必须 review / 必须绿) | 托管平台的分支保护规则 |
| 发布前把关 | CI 的发布闸门(见 11.4) |
11.3 签名:证明"这个提交/标签确实是你发的"
Git 支持两种签名(Git ≥ 2.34 起 SSH 签名很常用):
| 方式 | 配置 | 说明 |
|---|---|---|
| GPG | user.signingkey=<keyid> |
传统方式,需要 gpg 与密钥环 |
| SSH | gpg.format=ssh + user.signingkey=~/.ssh/id_ed25519.pub |
复用你已有的 SSH key,不需要 gpg |
实测:用 SSH key 签提交与 tag(本机没有 gpg,正好演示 SSH 方式)
$ git config gpg.format ssh
$ git config user.signingkey ~/.ssh/id_ed25519.pub
$ git commit -S -m "签名的提交"
$ git cat-file -p HEAD | sed -n '/^gpgsig/,+2p'
gpgsig -----BEGIN SSH SIGNATURE-----
U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgR6Nur4hH58/4CmcLlBzmLDQl8c
GAuNZ4rHTTTirZNHMAAAADZ2aXQAAAAAAAAABzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5
验证需要一份"允许签名者"清单(gpg.ssh.allowedSignersFile):
$ echo "t@x.c $(cat ~/.ssh/id_ed25519.pub)" > /tmp/allowed_signers
$ git config gpg.ssh.allowedSignersFile /tmp/allowed_signers
$ git verify-commit HEAD
Good "git" signature for t@x.c with ED25519 key SHA256:sS7Y056TZOqIQfVXqPXfjPubt0Qs9+yvTscib7XbLR4
签 tag(发布用):
$ git tag -a -s v9.9.9-test -m "测试签名标签"
$ git tag -v v9.9.9-test
Good "git" signature for t@x.c with ED25519 key SHA256:sS7Y...
$ git cat-file -t v9.9.9-test
tag # ← 附注标签是独立对象(第 3 章)
为什么要签发布 tag:
| 威胁 | 签名的作用 |
|---|---|
| 账号被盗后发布恶意版本 | 没有你的私钥就签不出有效 tag |
| 中间人替换 tag 指向的提交 | 签名覆盖 tag 对象(含它指向的提交哈希) |
| 下游无法追溯来源 | 用户可 git tag -v 自行验证 |
本仓库的现状(诚实说明):commit.gpgsign 未设置,提交与 tag 都没有签名。
对个人项目可接受;一旦涉及分发(npm 包、二进制),
至少给发布 tag 签名是低成本高收益的加固。
11.4 sourcepack 的发布链路
触发方式:推 tag
# .github/workflows/release.yml
on:
push:
tags: ['v*']
为什么用 tag 而不是分支:第 9 章实测过——改写历史时 tag 不跟随,
所以 tag 是稳定的"发布锚点";分支会动,缓存/引用分支名会失效。
三段式流水线(needs 串起来)
push tag v* → gate → goreleaser → npm-publish(+ 两段冒烟)
↓ ↓ ↓
全套检查 构建 6 个平台归档 发布 npm 并装回来验证
① gate(发布闸门):
gate:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: '1.24.x' }
- run: go vet ./...
- name: gofmt
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then echo "$unformatted"; exit 1; fi
- run: go test -race ./...
- run: node --test test/
为什么会有这个 job:见 11.5 的事故复盘。它的注释里写着原因:
v1.8.0 就是"打 tag 直接构建发布"放出去的,当天暴露三个线上可见问题
② goreleaser(构建与发布归档):
goreleaser:
needs: gate # ← 闸门没过就不构建
permissions: { contents: write } # ← 权限按 job 最小化
steps:
- uses: actions/setup-go@v5
with: { go-version: '1.24.x' } # ← 固定版本,不用 'stable'
.goreleaser.yaml 的产物矩阵:
builds:
- env: [CGO_ENABLED=0]
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
main: .
ldflags: ['-X main.versionStr=v{{.Version}}'] # 版本号注入二进制
archives:
- name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
wrap_in_directory: true
format_overrides: [{ goos: windows, format: zip }]
checksum: { name_template: 'checksums.txt' }
注意 {{ .Os }} 是 Go 的 GOOS(windows),不是 Node 的 win32——
第 6 章的命名差异问题正是这个模板与 npm 安装脚本不一致造成的,
两个平台名混用直接让 Windows 用户装不上(v1.8.0 事故之一)。
③ npm-publish(发布到 npm + 端到端冒烟):
- uses: actions/setup-node@v4
with:
registry-url: 'https://registry.npmjs.org' # ← 官方写法
- run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} # ← 不再 echo token 到 ~/.npmrc
然后是两段冒烟(这是"发布闸门"的延伸,把验证做到用户路径上):
| 阶段 | 做什么 | 覆盖什么 |
|---|---|---|
| 冒烟 1/2 | 用包里的 download-binary.js 拉真实 Release 资产,校验落地名/可执行/--version/-h |
不依赖 npm registry 传播,确定性最高;正好覆盖 v1.8.0 那类"归档名不一致" |
| 冒烟 2/2 | 轮询等 registry 传播后 npm i -g,再跑一遍 |
验证发布出去的 tarball(含 postinstall) |
为什么分两段:v1.8.2 首次上线时冒烟因为 npm 传播慢而在 90 秒内一直 ETARGET 失败——
那是脚本设计问题,不是包的问题。现在改成"先做不依赖传播的确定性检查,再做传播后的验证"。
11.5 事故复盘:v1.8.0 的"打 tag 直接发布"
| 时间 | 事件 |
|---|---|
| v1.8.0 发布当天 | 三个线上可见问题:Windows 完全装不上、--staged 计数恒为 0、-h 丢失自定义说明 |
| 当天 | 修完发 v1.8.1 |
| v1.8.2 | 补上 gate job + 固定 Go 版本 + 权限最小化 + npm 认证现代化 + 两段冒烟 |
| 同批 | CI 矩阵加入 windows-latest——加完当天就抓到两个真实平台问题 |
这条链条的因果很清楚:
发布流程缺闸门 → 问题留到用户手上
缺少 Windows CI → 平台问题根本没机会被发现
缺少安装冒烟 → "装完即挂"这类问题只能靠用户反馈
修复后的实测收益(第 4/6 章引用的两件事):
| 加的检查 | 抓到了什么 |
|---|---|
windows-latest 矩阵 |
filepath.Match 在 Windows 让 * 跨过 /;CRLF 让 gofmt 闸门假失败 |
go test -race |
并发/管道问题(我们有 io.Pipe 与 goroutine) |
| 两段冒烟 | 归档名/落地名一致性(v1.8.0 的 Windows 事故) |
对你自己项目的启示:发布前至少跑一遍"用户实际会走的那条路"
(装包 → 跑命令 → 看输出),而不只是跑单元测试。
v1.8.0 的三个问题里,有两个(Windows 安装、-h)都能被一条安装冒烟拦住。
11.6 发布史:42 个 tag 与"附注 tag 就是发布说明"
$ git tag -l 'v*' | wc -l
42
$ git for-each-ref --sort=creatordate --format='%(refname:short) %(creatordate:short) %(subject)' refs/tags | tail -4
v1.8.0 2026-10-11 v1.8.0: Git 变更打包(-g / -G),打包未提交改动与最近 N 个提交
v1.8.1 2026-10-12 v1.8.1: Windows 安装修复、整文件 UTF-8 判定、中文 Token 估算、godoc.go → main.go
v2.0.0 2026-10-12 v2.0.0: -d 非法路径明确报错(行为变更);-h 新增 Examples 示例段;参数手册 PARAMETERS.md
v2.0.1 2026-10-12 v2.0.1: -h 末尾换成参数手册博客版(https://s.want.biz/sourcepack)
注意第三列:%(subject) 取的是 tag 对象的说明(第 3 章:附注标签是独立对象)。
所以 git tag -a -m "..." 写的那段话,就是发布说明——
git tag -l 看不到它,git for-each-ref/git show <tag> 才能。
这也是为什么发布要用 -a(附注)而不是轻量 tag:
轻量 tag 只是一个指向 commit 的引用,没有地方放发布说明,也无法签名。
11.7 回到 sourcepack:我们没有钩子,CI 就是我们的钩子
实测现状:
$ ls .git/hooks | grep -v '\.sample$' | wc -l
0
$ git config core.hooksPath || echo "(未设置)"
(未设置)
为什么没有钩子(有意的设计):
| 原因 | 说明 |
|---|---|
| 钩子是本地的 | 克隆不带走(11.1 实测),团队规范必须放到 CI |
| 钩子可绕过 | --no-verify 一句话失效 |
| 我们的质量闸门在发布链路里 | gate job + windows 矩阵 + 两段冒烟(11.4),这些谁都绕不过 |
| 工具本身不该改仓库 | sourcepack 是只读工具(第 9 章);给它装钩子等于污染用户仓库 |
"我们的钩子"= CI 矩阵 + 发布闸门,对应关系:
| 传统钩子 | sourcepack 的做法 |
|---|---|
pre-commit(格式化/静态检查) |
CI 里的 go vet + gofmt -l(不可绕过) |
pre-push(跑测试) |
CI 里的 go test -race ./... + node --test test/ |
服务端 pre-receive(禁止坏提交) |
发布闸门 gate + 分支保护 |
| 发布后验证 | 两段式冒烟(钩子做不到这一层) |
11.8 动手实验 [lab]
# A. 客户端钩子与它的边界
rm -rf /tmp/l11 && mkdir /tmp/l11 && cd /tmp/l11 && git init -q -b main . \
&& git config user.email t@t.c && git config user.name T
cat > .git/hooks/pre-commit <<'EOF'
#!/bin/sh
if grep -rn "FORBIDDEN" . --include="*.txt" >/dev/null 2>&1; then echo "❌ 拦截" >&2; exit 1; fi
EOF
chmod +x .git/hooks/pre-commit
printf 'ok\n' > ok.txt && git add ok.txt && git commit -m ok # 通过
printf 'FORBIDDEN\n' > bad.txt && git add bad.txt && git commit -m bad ; echo "退出码 $?" # 被拦
git commit --no-verify -m bypass ; git log --oneline -1 # 绕过成功
git ls-files .git/hooks | wc -l # 0:不进版本库
# B. 服务端钩子(不可绕过)
git init -q --bare /tmp/l11-remote.git
cat > /tmp/l11-remote.git/hooks/pre-receive <<'EOF'
#!/bin/sh
while read o n ref; do
case "$ref" in *forbidden*) echo "❌ 服务端禁止 $ref" >&2; exit 1;; esac
done
EOF
chmod +x /tmp/l11-remote.git/hooks/pre-receive
git remote add origin /tmp/l11-remote.git
git push origin main
git push --no-verify origin main:forbidden ; echo "退出码 $?" # 仍被拒
# C. SSH 签名(本机无 gpg 也能做)
git config gpg.format ssh && git config user.signingkey ~/.ssh/id_ed25519.pub
git commit -S --allow-empty -m "签名提交"
git cat-file -p HEAD | head -5 # 看 gpgsig 段
echo "t@x.c $(cat ~/.ssh/id_ed25519.pub)" > /tmp/allowed_signers
git config gpg.ssh.allowedSignersFile /tmp/allowed_signers
git verify-commit HEAD
git tag -a -s v0.0.1-test -m "签名标签" && git tag -v v0.0.1-test
# D. 看自己项目的发布链路
cat .github/workflows/release.yml # 或你项目的等价物
git tag -l 'v*' | wc -l
git for-each-ref --sort=creatordate --format='%(refname:short) %(subject)' refs/tags | tail -5
自测题:
- 客户端钩子与服务端钩子,哪个
--no-verify能绕过?为什么? - 为什么"团队规范"不能只靠
pre-commit?给出两条理由。 - SSH 签名与 GPG 签名各有什么取舍?验证签名需要什么额外文件?
- 为什么发布要用附注 tag 而不是轻量 tag?(提示:发布说明放在哪)
.goreleaser.yaml的{{ .Os }}与 Node 的os.platform()有什么区别?这个区别在 v1.8.0 造成了什么后果?- sourcepack 的发布链路有哪三段?每段拦住什么类型的问题?
11.9 小结
- 钩子就是可执行文件(
.git/hooks/),启用=去.sample+ 加执行权限; - 客户端钩子可被
--no-verify绕过、不进版本库(克隆不带走)→ 只能当便利;
服务端钩子不可绕过(实测! [remote rejected] (pre-receive hook declined));
团队规范应放在 CI / 服务端 / 分支保护; - 签名可以用 SSH(
gpg.format=ssh,不需要 gpg):实测gpgsig段落、
git verify-commit/git tag -v输出Good "git" signature;
验证需要allowedSignersFile;发布 tag 应当签名(本仓库当前未签名,属已知短板); - sourcepack 的发布链路:推 tag → gate(vet/gofmt/race/node)→ goreleaser(6 平台归档)
→ npm publish → 两段冒烟;每段都用needs串起来,权限按 job 最小化; - 发布用 tag 不用分支:tag 不跟随历史改写(第 9 章实测),是稳定锚点;
附注 tag 自带发布说明(42 个 tag 的第三列就是它); - v1.8.0 事故的教训:没有闸门、没有 Windows CI、没有安装冒烟,
三个线上问题因此漏出——修复后同批加的 windows 矩阵当天就抓到两个真实平台 bug; - sourcepack 有意不装钩子:钩子本地/可绕过/会污染用户仓库,
我们的闸门在 CI 与发布链路上。
下一章(最后一章):把前 11 章的结论收拢回代码——
gitRunner 的每一条 hardening 对应本章/前章的哪个坑、-z 解析为什么必需、
--src-prefix 与 --no-textconv 防的是什么、只读命令清单,
以及"我们的测试夹具为什么要自己合成合并提交、中文路径与坏 textconv"。