用 git pre-push 钩子部署到 Vercel:组织仓库、Hobby 计划、无需 CI 密钥
在 Hobby 计划下,Vercel 的 Git 集成会拒绝由 GitHub 组织拥有的私有仓库,而 CI 方案又需要你可能无权添加的仓库密钥。这里有一个更轻的办法:本地 pre-push 钩子,用 Vercel CLI 部署刚刚推送的那个提交,而每个人仍以自己的名字 push。
仓库迁进了 GitHub 组织,Vercel 项目放在 Hobby 团队里,项目里的每个人都以自己的 GitHub 名字 push。我希望每次 push 都能部署——main 部署到生产环境,其他分支部署为预览——同时不改变提交归属于谁。
我的第一个方案是原生 Git 集成。它连续失败了三次,每次原因都不同:
Error: Failed to link your-org/your-repo. You need to add a Login Connection
to your GitHub account first. (400)
This action must be performed by an organization owner
Error: The repository "your-repo" is private and owned by an organization,
which is not supported on the Hobby plan. Upgrade to Pro to continue. (409)横在你与 Git 集成之间的三堵墙
- 没有 GitHub 登录连接。拥有该团队的 Vercel 账号从未连接过 GitHub,所以关联仓库时会报上面的第一个错误。解决办法在 Account Settings → Authentication——但点之前先想清楚:一个 GitHub 账号同一时间只能连接到一个 Vercel 账号。把你的账号连接到服务账号,就等于把它从你个人的 Vercel 上挪走,随后 Vercel 会拦住你个人项目的部署(“Git author must have access to the project”)。下面的钩子完全不需要 GitHub 连接,所以只有在你确实要走 Git 集成这条路时才这么做。
- 安装应用需要组织所有者。Vercel GitHub App 必须安装在组织上。普通成员只能申请,而设置页面会回应“This action must be performed by an organization owner”。
- Hobby 无法连接私有组织仓库。即使上面全部做完,Vercel 仍返回 409。这一条靠任何设置都解决不了:计划本身就是上限。
前两个是你可以去争取的权限。第三个意味着,在团队升级到 Pro 之前,Git 集成就别想了。此后的一切都是变通办法——所以要选一个坦然承认自己只是变通办法的。
从 push 的那台机器上部署
Vercel CLI 用登录或令牌鉴权,而不是提交作者,所以它从不执行拦住集成的成员检查。我上一篇文章在 GitHub Actions 里运行 CLI。那需要把 VERCEL_TOKEN 放进仓库的密钥,也就意味着要有仓库的管理员权限——还要在 runner 里对作者做一次 --amend,仪表盘才会显示提交。
pre-push 钩子两样都省了。它在开发者的机器上、git push 之后运行,部署刚推送的那个提交,且从不触碰提交本身:真正的作者保留在 GitHub 和仪表盘里。代价是它每台机器各自一份——下面会细说。
钩子本身
把它保存为 .git/hooks/pre-push 并设为可执行。Git 调用它时会传入 remote 名称和 URL,并通过 stdin 为每个被推送的 ref 输入一行。
#!/bin/bash
# Deploy to Vercel after a successful push. Local only: git never pushes .git/hooks.
# main -> production, any other branch -> preview. Runs detached; log: .git/vercel-deploy.log
case "$2" in
*your-org/your-repo*) ;;
*) exit 0 ;;
esac
repo_root="$(git rev-parse --show-toplevel)"
gh_path="${2#*github.com[:/]}"; gh_path="${gh_path%.git}"
gh_org="${gh_path%%/*}"; gh_repo="${gh_path#*/}"
log="$repo_root/.git/vercel-deploy.log"
push_pid=$PPID
[ -d "$repo_root/.vercel" ] || { echo "vercel-deploy: run 'vercel link' first, skipping deploy" >&2; exit 0; }
while read -r _ local_sha remote_ref _; do
[ "$local_sha" = "0000000000000000000000000000000000000000" ] && continue
branch="${remote_ref#refs/heads/}"
if [ "$branch" = "main" ]; then flag="--prod"; else flag=""; fi
(
# wait for the push itself to finish, then deploy exactly the pushed commit
while kill -0 "$push_pid" 2>/dev/null; do sleep 1; done
# a rejected or failed push must not deploy: only continue if the commit reached the remote
landed="$(git ls-remote "$2" "$remote_ref" 2>/dev/null | cut -f1)"
if [ "$landed" != "$local_sha" ]; then
echo "=== $(date '+%F %T') $branch @ ${local_sha:0:7} push did not land, deploy skipped" >> "$log"
exit 0
fi
tmp="$(mktemp -d)"
cd "$repo_root" || exit 1
git worktree add --detach "$tmp" "$local_sha" >/dev/null 2>&1 || exit 1
cp -R "$repo_root/.vercel" "$tmp/.vercel"
cd "$tmp" || exit 1
{
echo "=== $(date '+%F %T') $branch @ ${local_sha:0:7} ${flag:-preview}"
vercel deploy $flag --yes \
--build-env ENABLE_EXPERIMENTAL_COREPACK=1 \
-m "githubDeployment=1" \
-m "githubOrg=$gh_org" -m "githubRepo=$gh_repo" \
-m "githubCommitOrg=$gh_org" -m "githubCommitRepo=$gh_repo" \
-m "githubCommitMessage=$(git -C "$repo_root" log -1 --format=%s "$local_sha")" \
-m "githubCommitSha=$local_sha" \
-m "githubCommitRef=$branch" \
-m "githubCommitAuthorName=$(git -C "$repo_root" log -1 --format=%an "$local_sha")" \
-m "githubCommitAuthorEmail=$(git -C "$repo_root" log -1 --format=%ae "$local_sha")" \
2>&1 | tail -5
} >> "$log"
cd "$repo_root" && git worktree remove --force "$tmp"
) >/dev/null 2>&1 &
disown
done
exit 0关键在四个细节:
- 匹配 remote 的 URL,而不是名称。Git 把 URL 作为
$2传入。向其他任何 remote 的 push 会立即退出,所以镜像或 fork 永远不会触发部署。 - 等 push 结束,再确认它已落地。钩子在 push 完成之前运行,被拒绝的 push 也会运行它。所以它会转入后台,等待
git push进程退出,只有当git ls-remote显示你的 SHA 已在 remote 上时才部署。 - 部署被推送的提交,而不是你的工作区。在被推送的 SHA 处建一个一次性的
git worktree,意味着未提交的修改和写到一半的文件永远不会上线。它会复用你已有的.vercel项目关联。 main是生产环境,其余都是预览。只用一个--prod标志,由被推送的 ref 决定。
让仪表盘显示提交,而不是哈希
我最初的钩子部署在仪表盘里显示成类似 AowEm2XzF 的随机字符串,而不是提交信息。通过 API 回读这些部署后我才明白原因:显示了信息的那个,元数据里带有 githubOrg、githubRepo 和 githubDeployment。CLI 只有在正常 checkout 中识别出 GitHub remote 时才会添加这些——而在分离的 worktree 里,我只得到 gitCommit* 键,别无其他。
解决办法是自己用 -m 传入它们,根据 remote URL 推导,就像上面的钩子那样。信息、分支和作者随后都来自这些键。这是我在自己项目上观察到的;Vercel 并未记载这些键,所以第一次部署后请检查你的仪表盘,并把它们当作可能变化的东西。
鉴权:用令牌,别共享登录
钩子调用的是 vercel deploy,所以这台机器需要拥有该团队的那个账号的凭据。CLI 会自己从环境变量里读取 VERCEL_TOKEN:
# A token created for this purpose; the CLI reads it automatically
export VERCEL_TOKEN="..."优先用令牌,而不是 vercel login。令牌可以单独吊销;共享登录则无法只从一个人手里收回,除非对所有人都改掉。Vercel 文档里给自动化用的也正是令牌。
分享给团队
把脚本和一个单命令安装器提交进仓库,这样没人需要手动复制文件:
pnpm add -g vercel
vercel link --project my-project --scope <team-slug>
bash scripts/vercel-deploy/install.sh # copies the hook into .git/hooks钩子本身位于 .git/hooks,git 从不 push 这个目录——这就是安装器存在的原因。复用脚本前要改一行:顶部 case 里的仓库 URL 模式。
与 CI 方案相比如何
这并不是上一篇文章的严格升级版。它是用覆盖面换简单:
| GitHub Actions(上一篇) | pre-push 钩子(本篇) | |
|---|---|---|
| 需要仓库管理员权限(密钥) | 是 | 否 |
| 部署所有人的 push | 是 | 仅限装了钩子的机器 |
| 在 runner 里改写作者 | 是(amend,从不 push) | 否 |
| 设置 | 每个仓库一次 | 每位开发者一次 |
| 来自 GitHub 网页界面、机器人、其他机器的 push | 会部署 | 不会部署 |
| 失败信息出现在哪 | Actions 日志 | 本地日志文件 |
界线在哪里
钩子是变通办法,不是许可证。采用之前,先对自己坦白三件事:
- Hobby 仅限非商业用途。如果这是一家公司的产品,真正的问题就是计划本身,钩子只是把它掩盖了。解决办法是 Pro;钩子只是在有人批准账单之前争取时间。
- 别为了省席位费而共享登录。由一个账号持有团队的部署令牌是没问题的。多个人以同一个人的身份登录来获得仪表盘访问权,就是共享凭据。
- 它只部署你从自己机器上 push 的内容。没有钩子的队友 push 之后,生产环境就会过时,而没有任何机制能阻止这一点——要决定由谁负责部署。
要点
- 对于 Hobby 计划下的私有组织仓库,Vercel 的 Git 集成行不通:权限再多也解决不了计划上限。
- CLI 鉴权的是令牌,而不是提交作者,所以成员检查对它不适用。
pre-push钩子从开发者的机器部署,不需要仓库密钥,也从不改写提交。- 部署前先等 push 结束,并用
git ls-remote确认它已落地。 - 部署被推送 SHA 的 worktree,并自己传入
github*元数据,让仪表盘显示提交。 - 用令牌,不要共享登录——如果项目是商业性的,就把 Hobby 当作权宜之计。
- 一个 GitHub 登录同一时间只能连接到一个 Vercel 账号——别把你的挪到服务账号上;钩子不需要它。
在你能添加密钥时,CI 方案仍是更好的默认选择。钩子是为你不能的那一天准备的:几十行 shell,每位开发者设置一次,而每个提交仍带着它真正的作者。
发现错误了吗?
本文里有事实错误、别扭的翻译,或者哪里读起来不对?告诉我——用你自己的语言。