你在错误的 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.0 的 object 字段是个意料之外的 SHA | git 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 掉那个坏的:
(在 72 小时内、且没有任何包依赖它时,你可以npm deprecate "your-pkg@2.4.0" "Built from the wrong commit — use 2.4.1+" npm version patch && npm publishnpm 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。
如何确认已经修好
git ls-remote origin refs/tags/v2.4.0返回修正后的 commit(annotated tag 看^{}那一行)。git show v2.4.0 --stat里包含版本号 bump 的 diff。- 重新
git clone仓库后,git checkout v2.4.0切出来的是正确的代码。 - 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.1 并 npm 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.0 再 git push origin refs/tags/v2.4.0,并用 git ls-remote origin refs/tags/v2.4.0 确认。
相关阅读
- Rebase 之后 commit 消失了
- Force push 覆盖了队友的 commit
- 找回 Git 历史里的旧版本文件
- AI 把 secret 推到公开仓库了
- 分支保护规则让我的 PR 合不了
- AI 意外修改了 Git 历史
标签: #git #version-control #排查