代码被推到云端 Mac 后,CI 通常会立即检出、构建和测试。问题在于,Git 提交里的作者姓名与邮箱只是可编辑文本:它们能说明提交声称由谁创建,却不能证明对象确实由该开发者签署。更可靠的做法是给提交加 SSH 签名,再由 CI 用一份受控的公钥清单验证从合并基线到当前提交的完整范围。
这道门禁不替代代码审查,也不判断代码是否安全。它解决的是更窄但重要的问题:进入构建链的 Git 对象是否完整、签名是否有效,以及签名密钥是否属于当前获准提交代码的人。
先定义验真的信任边界
提交验真至少包含三层判断:
- Git 对象内容与签名是否匹配。
- 签名公钥是否出现在允许签名者清单中。
- 该身份在提交发生时是否仍有提交权限。
第一层由密码学签名完成,第二层由仓库维护的清单完成,第三层仍需要团队的权限回收流程。只运行 git log --show-signature 并看到“Good signature”还不够,因为一个有效但未经授权的密钥同样可以产生有效签名。
邮箱用于展示和通知,公钥用于验真,允许签名者清单用于授权。不要把三者当成同一件事。
建议把清单放在仅允许受控变更的独立目录,例如 .ci/trusted_signers。对清单本身的修改应要求额外审查,避免提交者在同一个变更里同时加入自己的公钥并放行代码。
配置 Git SSH 提交签名
Git 2.34 及以上版本可以直接使用 SSH 密钥签名。开发者本地先指定签名格式和公钥路径:
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
git config --global tag.gpgSign true
这里配置的是公钥文件,实际签名由对应私钥完成。创建提交后,可先确认对象确实包含签名:
git cat-file commit HEAD | sed -n '/^gpgsig /,/^[^ ]/p'
允许签名者文件每行包含身份、可选约束和公钥。身份应使用稳定的团队标识,而不是会频繁变化的显示名称:
ci-release namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...
ios-team namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5BBBB...
CI 检出仓库后,将 Git 指向这份文件:
git config --local gpg.format ssh
git config --local gpg.ssh.allowedSignersFile .ci/trusted_signers
git verify-commit HEAD
不要把私钥放进仓库。CI 的验真步骤只需要公钥;只有创建签名提交或标签的环境才需要私钥。
对完整提交范围建立门禁
只验证 HEAD 是常见错误。攻击性或未经授权的改动可以藏在前一个提交中,再由最后一个已签名提交覆盖表面状态。门禁必须遍历可信基线之后的全部提交。
#!/bin/bash
set -euo pipefail
base="${MERGE_BASE_SHA:?missing MERGE_BASE_SHA}"
head="${HEAD_SHA:?missing HEAD_SHA}"
git cat-file -e "${base}^{commit}"
git cat-file -e "${head}^{commit}"
count=0
while IFS= read -r commit; do
git verify-commit "$commit"
count=$((count + 1))
done < <(git rev-list --reverse "${base}..${head}")
printf 'verified_commits=%s\n' "$count"
MERGE_BASE_SHA 应由 CI 根据目标分支和待合并分支计算或可靠传入,不能简单写成 HEAD~1。合并提交还要确认基线选择符合团队策略,否则可能漏掉第二父分支带入的对象。
门禁输出应记录提交哈希和失败阶段,但不要复制整个提交消息或环境变量到公开日志。失败时停止构建,比继续生成一个来源未确认的产物更容易审计。
处理浅克隆、标签与密钥轮替
浅克隆经常让脚本误报:基线对象不在本地时,rev-list 无法得到完整范围。应在验真前补足目标分支历史,并用 git cat-file -e 明确检查基线是否存在。不要在找不到基线时退化为只验 HEAD。
| 场景 | 正确处理 | 不应采用的捷径 |
|---|---|---|
| 基线不在浅克隆中 | 补取所需历史后重新计算范围 | 只验证最后一个提交 |
| 发布标签 | 使用带签名的注释标签并执行 git verify-tag |
只检查标签名称 |
| 新旧密钥交接 | 短期同时保留两把公钥 | 直接替换导致历史任务失败 |
| 密钥泄露 | 立即移除并复核其签署范围 | 仅修改显示身份 |
| 离组成员 | 删除其允许签名者记录 | 只关闭日常登录权限 |
历史提交是否应在密钥移除后继续通过,需要预先决定。最简单的严格模式是“按当前清单验真”,适合合并门禁;需要长期验证历史发布时,则应保存带生效区间的信任记录,并把发布标签、提交哈希和当时使用的清单版本一起归档。
上线前检查与故障定位
正式阻断合并前,可以先运行一段观察期,只记录失败但不放行未经签名的发布。观察期应重点检查以下项目:
- 开发者 Git 版本支持 SSH 签名。
- 普通提交、合并提交和自动生成提交都能找到明确签名者。
- CI 已取得完整基线,而不是依赖固定深度。
- 允许签名者文件的修改需要独立审查。
- 发布标签与其指向的提交分别通过验证。
- 机器人身份使用独立密钥,不能与个人共用。
- 密钥轮替、泄露和成员离组都有可执行步骤。
排查失败时,先用 git verify-commit --raw <hash> 区分“对象没有签名”“签名损坏”和“公钥不在允许清单”。再检查 gpg.format、gpg.ssh.allowedSignersFile 的仓库级配置,以及清单中的命名空间、密钥类型和换行。这样可以把身份授权问题与 Git 历史不完整问题分开,而不是反复重签同一个提交。
当逐提交验证、标签验证和清单变更审查都进入流水线后,构建系统就获得了一条可复核的来源链:它不能证明代码没有缺陷,但能明确回答哪些对象经过了哪把获准密钥的签署,以及未经确认的对象为何没有进入后续构建。
常见问题
提交显示作者邮箱正确,为什么仍要验证签名?
作者姓名和邮箱只是提交对象中的普通文本,任何能够创建提交的人都可以填写。有效签名只能证明提交对象由对应私钥签署,随后还要用允许签名者清单判断该密钥是否属于获准身份。
CI 只验证分支最后一个提交是否足够?
不够。合并范围中较早的提交同样会进入最终源码,应从可信基线到当前头提交逐个执行 verify-commit;发布标签还应单独执行 verify-tag。
签名密钥轮换时怎样避免所有构建突然失败?
先把新公钥加入受版本控制的允许签名者清单,完成双钥过渡并验证新提交,再移除旧公钥。若旧钥泄露,应立即删除并重新评估它曾签署的提交范围。
为下一项开发任务配置云端 Mac
选择 Oak M4、Oak M4 Plus 或 Oak M4 Pro,并按团队位置从五个物理节点中配置租用方案。