Skip to main content
← 返回博客
VercelGit HooksCI/CDDeploymentpnpm

用 git pre-push 钩子部署到 Vercel:组织仓库、Hobby 计划、无需 CI 密钥

在 Hobby 计划下,Vercel 的 Git 集成会拒绝由 GitHub 组织拥有的私有仓库,而 CI 方案又需要你可能无权添加的仓库密钥。这里有一个更轻的办法:本地 pre-push 钩子,用 Vercel CLI 部署刚刚推送的那个提交,而每个人仍以自己的名字 push。

发布于 2026年10月6日7 分钟阅读
《当提交者不是团队成员时,如何从 CI 部署到 Vercel》的更新。那篇文章从 GitHub Actions 部署,在你能添加仓库密钥时是更好的选择。这一篇则适用于你不能添加的情况——而且完全不需要改写提交。

仓库迁进了 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 集成之间的三堵墙

  1. 没有 GitHub 登录连接。拥有该团队的 Vercel 账号从未连接过 GitHub,所以关联仓库时会报上面的第一个错误。解决办法在 Account Settings → Authentication——但点之前先想清楚:一个 GitHub 账号同一时间只能连接到一个 Vercel 账号。把你的账号连接到服务账号,就等于把它从你个人的 Vercel 上挪走,随后 Vercel 会拦住你个人项目的部署(“Git author must have access to the project”)。下面的钩子完全不需要 GitHub 连接,所以只有在你确实要走 Git 集成这条路时才这么做。
  2. 安装应用需要组织所有者。Vercel GitHub App 必须安装在组织上。普通成员只能申请,而设置页面会回应“This action must be performed by an organization owner”。
  3. 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 输入一行。

.git/hooks/pre-push
#!/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,每位开发者设置一次,而每个提交仍带着它真正的作者。

发现错误了吗?

本文里有事实错误、别扭的翻译,或者哪里读起来不对?告诉我——用你自己的语言。