Git Tag 指向了错误的 commit

release tag 打到了错误的 commit 上,而且已经 push 出去了。本文教你安全地移动 tag、让每个克隆都 force-fetch,并处理 npm、GitHub Release 等已经锁定旧代码的缓存。

你在错误的 commit 上执行了 git tag v2.4.0——版本号 bump 的那个 commit 还没合进当前分支,或者你在 CI hotfix 落地之前就打了 tag——然后 git push --tags 已经把它推到了 GitHub。现在 package registry 用错误的代码构建了,或者队友已经把这个坏 tag 克隆下去了。

最快的修复: 先找到正确的 commit SHA,再删除并重建 tag,强制推到远端:

git tag -d v2.4.0                        # 删除本地 tag
git tag -a v2.4.0 <CORRECT_SHA> -m "Release v2.4.0"
git push origin :refs/tags/v2.4.0        # 删除远端 tag
git push origin refs/tags/v2.4.0         # 推送修正后的 tag

关键陷阱:移动一个已经 push 的 tag 不会自动同步。任何已经 fetch 过 v2.4.0 的人手里还是旧的——git fetch --tags 默认不会覆盖它(下面会展开讲),而任何已经基于旧 tag 构建出来的产物(npm 包、Docker 镜像、GitHub Release)在你重新构建或重新发布之前都还是错的。下面的步骤把这三类情况都覆盖了。

你属于哪一种情况?

现象最可能的原因跳转
git show v2.4.0 --stat 里没有版本号 bump 的 diff早打了一个 commit原因 #1
tag 卡在 log 中间,而不在合入 main 的 merge commit 上打在了分支末端而非 merge commit原因 #2
git cat-file -p v2.4.0object 字段是个意料之外的 SHAgit tag -a 时贴错了 SHA原因 #3
tag 指向的代码后来 CI 挂了CI 在测试跑完前就打了 tag原因 #4
fetch 之后 git log v2.4.0..origin/main 还有 commit从过期的本地克隆打的 tag原因 #5
本地和远端的 v2.4.0 对不上tag 只在一边移动了原因 #6

常见原因

按命中率从高到低排列。

1. 在最终版本号 bump 的 commit 落地之前就打了 tag

发布流程会提交一个 package.json(或 pyproject.toml)的版本号 bump,但有人在那个 bump 被 merge 或 cherry-pick 之前,就早了一个 commit 执行了 git tag v2.4.0

怎么判断git show v2.4.0 --stat。如果 tag 指向的 commit 的 diff 里没有版本号文件的改动,说明 tag 早打了一个 commit。

2. 打在了分支上而不是 merge commit 上

tag 是在 release/2.4.0 分支末端创建的,而不是合入 main 的 merge commit 上。任何克隆 main 的人 checkout 出来的 commit 和 tag 指向的并不是同一个。

怎么判断git log --oneline --decorate main | head -5。如果 v2.4.0 出现在 log 中间而不是 merge commit 上,说明它打在了只属于分支的 commit 上。

3. annotated tag 用错了 SHA

git tag -a v2.4.0 -m "release" <wrong-sha>——SHA 是从 Slack 消息或 CI 日志里抄错的。tag 对象里存着 tagger、时间戳和一个对象指针,而这个指针是错的。

怎么判断git cat-file -p v2.4.0(仅 annotated tag)会打印一行 object。把那个 SHA 和你本来要发布的 commit 对比一下。

4. CI 在测试通过前就打了 tag

打 tag 的步骤和测试 job 是并行跑的。这次测试碰巧过了,但万一测试挂了,tag 就会指向坏代码——而以 tag 为触发条件的部署就会把它发出去。

怎么判断:打开 CI run 的时间线。如果 “create tag” 步骤比 “run tests” 步骤先结束,那么不管这次结果如何,这个顺序本身就不安全。

5. 从过期的本地克隆打的 tag

开发者本地的 main 落后远端几个 commit。git tag v2.4.0 && git push --tags 把 tag 打在了过期的本地 HEAD 上。

怎么判断git fetch --all,然后 git log --oneline v2.4.0..origin/main。这里列出的任何 commit,都是本应包含进这个 release 但被漏掉的。

6. tag 只在一边移动了(本地与远端不同步)

你在本地执行了 git tag -f v2.4.0 <new-sha> 但忘了 push;或者远端已经更新,但你的 git fetch 没把移动后的 tag 拉下来(默认就是拒绝覆盖的——见下面 FAQ)。

怎么判断:对比 git rev-parse v2.4.0(本地)和 git ls-remote origin refs/tags/v2.4.0(远端)。SHA 不一致,说明两边对不上。

逐步修复

Step 1:确认正确的目标 commit

git fetch --all
git log --oneline --decorate main | head -10

记下你本来要打的那个 SHA——记作 CORRECT_SHA。对于一个 annotated/merge 的 release,这通常就是合入 main 的 merge commit。

Step 2:备份旧 tag(可选但推荐)

万一之后有人质疑这次改动,你还能找回原来的指针:

git tag backup/v2.4.0-wrong "$(git rev-parse v2.4.0)"

Step 3:删除本地 tag

git tag -d v2.4.0

Step 4:在正确的 commit 上重建 tag

# lightweight tag
git tag v2.4.0 <CORRECT_SHA>

# annotated tag(release 推荐——会记录 tagger、日期、message)
git tag -a v2.4.0 <CORRECT_SHA> -m "Release v2.4.0: add OAuth2 support, fix session expiry"

# 如果你的 release 是签名的,用 signed annotated tag
git tag -s v2.4.0 <CORRECT_SHA> -m "Release v2.4.0"

Step 5:强制把修正后的 tag 推到远端

先删远端 tag,再推新的——这种写法清晰、便于 review:

git push origin :refs/tags/v2.4.0     # 删除远端 tag
git push origin refs/tags/v2.4.0      # 推送修正后的 tag

一行版的等价写法(会 force-push 所有本地 tag,所以只在干净的克隆上用):

git push origin --force --tags

Step 6:验证移动结果

git rev-parse v2.4.0~0                              # 本地解析到 CORRECT_SHA
git ls-remote origin refs/tags/v2.4.0              # 远端 SHA 匹配
git show v2.4.0 --stat                             # diff 里包含版本号 bump

对 annotated tag 来说,git ls-remote 会为这个 tag 打印两行——tag 对象本身,以及一行解析到 commit 的 ^{}。那行 ^{} 才是你该拿来和 CORRECT_SHA 对比的 commit SHA。

Step 7:通知每个队友 force-fetch

这是大家最容易漏掉的一步。普通的 git fetch --tags 不会替换本地已存在的 tag——Git 会拒绝并报 ! [rejected] v2.4.0 -> v2.4.0 (would clobber existing tag)。每个 fetch 过坏 tag 的人都必须执行:

git fetch origin --tags --force
# 或者只更新这一个 tag:
git fetch origin refs/tags/v2.4.0:refs/tags/v2.4.0 --force

Step 8:修复任何基于旧 tag 构建出来的产物

  • GitHub Release:release 是绑定到一个 tag 对象上的,编辑 tag 并不会刷新它。删掉这个 release(Releases > 对应 release > Delete),重新创建并选中修正后的 v2.4.0——或者直接编辑现有 release,重新选一遍 tag。
  • npm:已发布的版本号永远不能再用。如果 v2.4.0 已经在 npm 上了,那就发一个修正后的 v2.4.1,并 deprecate 掉那个坏的:
    npm deprecate "your-pkg@2.4.0" "Built from the wrong commit — use 2.4.1+"
    npm version patch && npm publish
    (在 72 小时内、且没有任何包依赖它时,你可以 npm unpublish your-pkg@2.4.0,但 2.4.0 这个版本号依然作废、不能重发——见下面 FAQ。)
  • Docker / OCI 镜像:镜像已经基于错误的 commit 存在了。从修正后的 tag 重新构建并 push,再按你 registry 的保留策略给坏镜像 digest 重新打标或删除。

预防建议

  • 在 CI 里打 tag,而不是本地。 流水线在每个 job 都通过之后才知道确切的 SHA;一台 main 已经过期的笔记本可不知道。
  • 把打 tag 的顺序排在最后——在测试和构建都成功之后,绝不要和它们并行跑。
  • 打完 tag 立刻验证:让 release job 跑 git show "$TAG" --stat,如果版本号文件缺失就让流水线失败。
  • 在 GitHub 上保护 release tag。 tag protection rules 已于 2024 年 8 月 30 日下线,由 Rulesets 取代。进入 Settings > Rules > Rulesets > New ruleset > New tag ruleset,把 Target tags 设成 v* 之类的 pattern,并勾上 Restrict creations / Restrict updates / Restrict deletions,这样就只有发布自动化才能移动它们。
  • 加一个 pre-push hook,拦截目标不在 main 历史里的 tag push。
  • 把整个发布流程自动化,用 semantic-release 或 Release Please,让版本号 bump、打 tag、生成 changelog 都来自同一个确定性步骤,而不是手敲一个 SHA。

如何确认已经修好

  1. git ls-remote origin refs/tags/v2.4.0 返回修正后的 commit(annotated tag 看 ^{} 那一行)。
  2. git show v2.4.0 --stat 里包含版本号 bump 的 diff。
  3. 重新 git clone 仓库后,git checkout v2.4.0 切出来的是正确的代码。
  4. GitHub Release 页面显示的是修正后的 tag,相关的 registry 产物也已重新发布或被 deprecate。

常见问答 (FAQ)

为什么 git fetch --tags 没有自动更新队友的 tag? 这是刻意设计的。从 Git 2.2.0 起,fetch 绝不会覆盖一个指向别处的本地已存在 tag——你会看到 (would clobber existing tag),旧 tag 原封不动。他们必须执行 git fetch --tags --force(或 --prune-tags)。正因如此,移动 tag 之后一定要明确通知团队。

能不能不用 force / delete 就移动 tag? 不能。tag 本就被设计成不可变,所以协议层面没有”原地更新”。移动 tag 永远意味着删掉远端 ref,再推一个同名的新 ref,本质上就是 force 操作。

我已经把 v2.4.0 发到 npm 了,能直接用正确代码重发 2.4.0 吗? 不能——一个用过的 package@version 永远不能再用,哪怕你 unpublish 了也不行。72 小时内、且没有任何包依赖它时,你可以 npm unpublish your-pkg@2.4.0;超过 72 小时则还要满足”上周下载量少于 300”且”只有一个 maintainer”,而且一旦 unpublish 掉一个包的所有版本,24 小时内不能再发布新版本。干净的做法是发 2.4.1npm deprecate 掉坏版本。

我的 tag 是 GPG 签名的,移动后需要重新签名吗? 需要。tag 对象里嵌着它指向的 SHA,所以旧签名覆盖不到新目标。用 git tag -s v2.4.0 <CORRECT_SHA> -m "..." 重建即可。

下游系统已经用错误的 tag 构建了 Docker 镜像,怎么办? 镜像已经冻结在错误的 commit 上了。从修正后的 tag 重新构建、重新 push、重新部署。再按保留策略给 registry 里那个坏镜像 digest 重新打标或删除——光移动 Git tag 永远不会重新构建镜像。

新 tag 在本地指向了正确的 commit,但远端还是旧 SHA,怎么回事? Step 5 没生效。你只在本地移动了 tag,远端 ref 没动——重新执行 git push origin :refs/tags/v2.4.0git push origin refs/tags/v2.4.0,并用 git ls-remote origin refs/tags/v2.4.0 确认。

相关阅读

标签: #git #version-control #排查