一台长期运行的云端 Mac 接连执行多个仓库的构建时,最危险的并不总是令牌直接出现在脚本里,而是上一个作业把凭据悄悄留给了下一个作业。常见残留点包括 Git 全局配置、带认证信息的远程地址、登录钥匙串,以及未删除的临时脚本。处理这类问题的目标不是“构建结束后删一个变量”,而是让凭据从一开始就只能存在于单次作业的边界内。
先确认凭据会落到哪里
不要急着改流水线。先在不输出秘密值的前提下,确认 Git 实际读取了哪些配置。--show-origin 会同时给出配置来源,能区分系统级、用户级与仓库级设置。
set +x
git config --show-origin --get-all credential.helper || true
git config --show-origin --get-regexp '^(credential|http)\.' || true
git config --show-origin --get-regexp '^url\..*\.insteadof$' || true
重点检查三类信号:
credential.helper是否指向持久化钥匙串;http.extraHeader是否被写进全局或仓库配置;url.*.insteadOf是否把普通地址改写成包含认证信息的地址。
远程地址也要检查,但不要原样写入日志。可以只判断是否存在 协议://用户信息@主机 结构,发现后立即失败,而不是打印完整地址。
remote_url="$(git remote get-url origin)"
case "$remote_url" in
*://*@*)
printf '%s
' "Remote URL contains embedded credentials" >&2
exit 1
;;
esac
审计脚本的第一原则是只报告“凭据可能存在”,不要为了证明问题而把凭据本身输出到构建日志。
为每个作业创建独立 HOME
Git 会从 HOME 推导用户级配置路径。让所有作业共用运行用户的 HOME,等于让它们共用 .gitconfig、凭据 helper 配置和大量工具状态。更稳妥的做法是为每次作业创建权限为 700 的临时 HOME,并显式指定 Git 全局配置文件。
set -eu
ORIGINAL_HOME="$HOME"
JOB_HOME="$(mktemp -d "${TMPDIR%/}/git-job.XXXXXX")"
chmod 700 "$JOB_HOME"
export HOME="$JOB_HOME"
export XDG_CONFIG_HOME="$JOB_HOME/.config"
export GIT_CONFIG_GLOBAL="$JOB_HOME/.gitconfig"
export GIT_TERMINAL_PROMPT=0
mkdir -p "$XDG_CONFIG_HOME"
这样做不会隔离系统级 Git 配置,因此仍要主动重置 helper 链。Git 支持多个 credential.helper 值;先写入空值,可以清除从低优先级配置继承的 helper,再追加本次作业自己的实现。
别把仓库也放进临时 HOME
HOME 是凭据与工具状态边界,不必同时承担工作区职责。源码目录应继续由 runner 的工作区管理,便于容量控制和产物收集。两者分开后,清理 HOME 不会误删构建结果,清理工作区也不会遗漏用户级认证配置。
如果流水线需要并发,每个并发槽都必须调用一次 mktemp,不能按仓库名复用固定目录。固定目录会在异常退出后被下一次作业继承,也可能让两个作业同时修改同一份 .gitconfig。
只在 Git 请求时提供令牌
令牌应由 CI 的秘密变量机制注入环境,脚本只引用变量名。不要把令牌拼进 clone 地址,也不要用 git config --global http.extraHeader 长期保存认证头。
下面的 helper 只响应 Git 的 get 请求,配置文件中保存的是变量引用逻辑,不是令牌值:
: "${CI_GIT_USER:?CI_GIT_USER is required}"
: "${CI_GIT_TOKEN:?CI_GIT_TOKEN is required}"
git config --global credential.helper ""
git config --global --add credential.helper \
'!f() {
if [ "$1" = get ]; then
printf "username=%s
password=%s
" \
"$CI_GIT_USER" "$CI_GIT_TOKEN"
fi
}; f'
关闭命令回显与交互回退
接触秘密变量前必须执行 set +x。否则 shell 会把变量展开后的命令写入日志。GIT_TERMINAL_PROMPT=0 同样重要:令牌缺失或失效时,作业应明确失败,不能停在不可见的交互提示上。
还要检查构建包装器是否会自动转储环境变量。诊断信息只应输出变量是否已设置,例如检查字符串长度是否大于零,不应输出变量内容、认证头或完整远程地址。
对于需要写权限的自动化任务,应把读取源码与推送产物拆成不同凭据。只执行 checkout 的作业不需要写权限;发布作业也不应获得超出目标仓库和必要操作范围的权限。
用 trap 覆盖所有退出路径
只在脚本最后写 rm -rf,无法覆盖中途失败、超时终止或人工取消。应在创建临时目录后立即注册 trap,并在清理函数中先清空变量,再删除作业 HOME。
cleanup_git_credentials() {
set +e
unset CI_GIT_TOKEN CI_GIT_USER
if [ -n "${JOB_HOME:-}" ] && [ -d "$JOB_HOME" ]; then
rm -rf "$JOB_HOME"
fi
}
trap cleanup_git_credentials EXIT HUP INT TERM
清理函数必须允许重复执行,目录已经不存在时也不能报错。删除目标还要满足两个条件:变量非空,并且确实指向目录。不要使用宽泛的钥匙串删除命令,也不要清空整个登录钥匙串,因为其中可能包含同一运行用户的其他必要项目。
若历史流水线曾使用持久化 helper,应先按主机与账户精确盘点,再安排一次性迁移。新旧方案并行期间,每次作业都要验证当前 helper 来源,避免临时 HOME 已启用,但系统包装脚本又把旧配置复制回来。
把残留检查变成构建门禁
清理是否成功不能只靠脚本没有报错。建议在 runner 外层增加退出后检查,或者让执行器在回收作业时验证以下项目:
- 临时 HOME 已删除;
- 工作区
.git/config中没有认证头或含用户信息的远程地址; - 作业日志没有秘密变量的已知指纹;
- Git 配置来源只包含预期的系统配置和本次临时配置;
- 后续空白作业无法读取上一次作业的仓库凭据。
可以为测试令牌设置一个无权限的固定标记值,在隔离环境中运行失败用例,再扫描日志与文件系统。这里检查的是“标记是否泄漏”,而不是验证真实令牌。失败用例至少覆盖 clone 失败、构建命令退出、收到终止信号和清理函数重复执行。
当多个团队共用一台物理节点时,还应把运行用户作为第二道边界。临时 HOME 解决的是作业级残留;独立用户解决的是不同信任域之间的进程、文件权限和钥匙串边界。两层同时存在,才不会把脚本清理当成唯一防线。
最终验收标准很简单:作业开始前没有可继承凭据,运行中只按需提供,任何退出路径都会清理,下一次作业无法证明上一枚令牌曾经存在。做到这一点,Git 凭据才真正属于作业,而不是属于那台长期运行的云端 Mac。
常见问题
为什么只在作业结束时 unset 令牌变量还不够?
因为凭据可能已经被 Git helper、远程地址、全局配置或钥匙串持久化。必须同时检查配置来源、远程地址和临时目录,并用 trap 覆盖异常退出。
长期运行的 CI 节点可以直接共用登录钥匙串吗?
不建议让多个仓库或作业共用同一组持久凭据。更稳妥的做法是按作业注入最小权限令牌,并为不同信任边界使用独立运行用户或独立钥匙串。
为开发、构建与远程桌面选择一台独享物理机
比较三档 Apple Silicon 配置,并在下单时选择节点、租期与存储附加项。