무슨 일이 있었나
어제 릴리스 태그를 찍은 뒤에 고쳐야 할 것이 하나 더 나왔다. 수정을 머지하고, 원격 태그를 지우고, 같은 이름의 태그를 새 커밋에 다시 찍는 스크립트를 돌렸다. 그런데 스크립트가 처음 push한 태그는 예전 커밋을 가리키고 있었다. 이 저장소에서는 태그 push가 패키지 배포 CI를 시작하므로, 실행을 취소하고 태그를 다시 만들어야 했다. 취소가 늦었다면 예전 코드가 새 버전 번호로 배포됐을 것이다.
재현: 지운 원격 태그와 남아 있는 로컬 태그
git 2.34.1로 bare 원격 저장소와 클론을 만들고, 커밋 5311e7d0에 주석 태그 v1.0.0을 찍어 push했다. 그 뒤 수정 커밋 26e7294a를 올렸다. 태그를 다시 찍으려고 git push origin :refs/tags/v1.0.0으로 원격 태그를 지우자 git ls-remote --tags origin은 아무것도 출력하지 않았다.
이 상태에서 git push origin v1.0.0을 실행하면 * [new tag] v1.0.0 -> v1.0.0과 함께 성공한다. 경고는 없다. 그런데 git ls-remote --tags origin으로 보면 refs/tags/v1.0.0^{}는 5311e7d0, 즉 예전 커밋이었다. 원격 태그는 지웠지만 로컬 태그는 한 번도 옮기지 않았기 때문이다.
다시 만들기가 실패해도 스크립트는 계속 간다
로컬 태그를 새 커밋에 다시 만들려고 git tag -a v1.0.0 -m v1.0.0 26e7294a를 실행하면 fatal: tag 'v1.0.0' already exists로 실패한다. 스크립트가 set -e 없이 돌거나 이 오류를 무시하면, 다음 줄의 push는 예전 태그를 그대로 보낸다. 실패한 한 줄 뒤에서 성공한 push가 문제를 가린다.
git rev-parse의 함정
주석 태그에 git rev-parse v1.0.0을 실행하면 c50190e4가 나왔다. 이것은 커밋이 아니라 태그 객체의 sha다. 같은 태그에 git rev-parse 'v1.0.0^{commit}'을 실행해야 커밋 5311e7d0이 나온다. 태그를 커밋 sha와 비교하는 검사를 ^{commit} 없이 쓰면 항상 불일치가 나고, 그러면 사람들은 결국 그 검사를 꺼 버린다.
다른 클론은 예전 태그를 계속 들고 있다
git tag -f로 로컬 태그를 옮긴 뒤 원격에 같은 이름의 태그가 이미 있으면 그냥 git push는 Updates were rejected because the tag already exists in the remote.로 거절된다. git push -f origin v1.0.0으로 덮어써야 원격이 26e7294a로 바뀐다.
원격을 고친 뒤에도, 예전 태그를 받아 둔 다른 클론에서 git fetch origin은 아무 출력 없이 끝났고 태그는 여전히 5311e7d0이었다. git fetch --tags origin은 ! [rejected] v1.0.0 -> v1.0.0 (would clobber existing tag)로 거절했다. git fetch --tags --force origin을 실행해야 t [tag update]와 함께 26e7294a로 바뀌었다. 태그를 옮겼다면 그 태그를 받아 둔 모든 곳에 알려야 한다.
안전한 태그 다시 찍기
의도한 커밋을 이름이 아니라 sha로 정하고, 태그를 그 sha에 강제로 다시 만든 뒤, push 전후에 한 번씩 확인한다.
want=26e7294a568cffe972c7e6d3d183d0644fe7ad12git tag -f -a v1.0.0 -m v1.0.0 "$want"test "$(git rev-parse 'v1.0.0^{commit}')" = "$want" || exit 1git push -f origin refs/tags/v1.0.0test "$(git ls-remote origin 'refs/tags/v1.0.0^{}' | cut -f1)" = "$want" || exit 1
재현에서 이 순서로 다시 찍자 확인이 통과했고, 원격의 refs/tags/v1.0.0^{}는 26e7294a였다. 로컬 태그 이름을 아예 거치지 않으려면 git push origin 26e7294a…:refs/tags/v1.0.0처럼 sha를 직접 push할 수도 있다. 다만 이렇게 만든 태그는 주석 없는 경량 태그이므로, 배포 과정이 주석 태그를 요구한다면 위 순서를 쓴다.
사람이 잊어도 막기: pre-push 훅
태그 push일 때 태그가 가리키는 커밋을 EXPECT_COMMIT 환경 변수와 비교하고, 변수가 없거나 다르면 거절하는 .git/hooks/pre-push를 만들었다. 훅은 표준 입력으로 로컬 ref, 로컬 sha, 원격 ref, 원격 sha를 받으므로 refs/tags/*일 때 git rev-parse "$lsha^{commit}"를 비교하면 된다. 태그 삭제는 로컬 sha가 0으로만 채워져 오므로 건너뛴다.
재현에서 로컬 태그가 예전 커밋을 가리킨 채 EXPECT_COMMIT를 새 커밋으로 주고 push하자 pre-push: refs/tags/v1.0.0 -> 5311e7d, expected 26e7294; refusing으로 실패했고 원격에는 태그가 생기지 않았다. 변수 없이 push하면 pre-push: set EXPECT_COMMIT to push refs/tags/v1.0.0으로 실패했다. git push --dry-run도 훅을 실행해 같은 이유로 실패하므로 미리 시험해 볼 수 있다. --no-verify를 붙이면 훅을 건너뛴다. 실수를 막는 장치이지, 작정한 push를 막는 장치는 아니다.
정리
태그 이름은 커밋을 가리키는 포인터일 뿐이고, 원격 태그를 지워도 로컬 포인터는 그대로 남는다. 태그를 push하기 전에 ^{commit}으로 가리키는 커밋을 확인하고, push 뒤에는 git ls-remote로 원격이 같은 커밋을 가리키는지 다시 읽는다. 태그 push가 배포를 시작하는 저장소라면 이 두 줄이 배포 취소보다 싸다.
What happened
Yesterday, after a release tag was already cut, one more fix was needed. I merged the fix, deleted the remote tag, and ran a script to put a tag with the same name on the new commit. The first tag the script pushed still pointed at the old commit. In that repository a tag push starts the package publish CI, so I had to cancel the run and re-tag. Had the cancel been late, the old code would have shipped under the new version number.
Reproduction: a deleted remote tag and a local tag that stayed
With git 2.34.1 I created a bare remote and a clone, put the annotated tag v1.0.0 on commit 5311e7d0, and pushed it. Then I pushed a fix commit, 26e7294a. To re-cut the tag I deleted the remote one with git push origin :refs/tags/v1.0.0, and git ls-remote --tags origin printed nothing.
Running git push origin v1.0.0 in that state succeeds with * [new tag] v1.0.0 -> v1.0.0. There is no warning. But git ls-remote --tags origin showed refs/tags/v1.0.0^{} at 5311e7d0, the old commit. The remote tag was deleted, but the local tag was never moved.
The script keeps going when re-creating fails
Trying to re-create the local tag on the new commit with git tag -a v1.0.0 -m v1.0.0 26e7294a fails with fatal: tag 'v1.0.0' already exists. If the script runs without set -e, or ignores that error, the push on the next line sends the old tag unchanged. A successful push right after one failed line hides the problem.
The git rev-parse trap
For the annotated tag, git rev-parse v1.0.0 printed c50190e4. That is the sha of the tag object, not of a commit. Only git rev-parse 'v1.0.0^{commit}' printed the commit, 5311e7d0. A check that compares a tag to a commit sha without ^{commit} always reports a mismatch, and people end up switching that check off.
Other clones keep the old tag
After moving the local tag with git tag -f, a plain git push is refused with Updates were rejected because the tag already exists in the remote. while the remote still has a tag of that name. Only git push -f origin v1.0.0 overwrote it, and the remote moved to 26e7294a.
Even after the remote was fixed, git fetch origin in another clone that already had the old tag finished with no output, and the tag was still 5311e7d0. git fetch --tags origin refused with ! [rejected] v1.0.0 -> v1.0.0 (would clobber existing tag). Only git fetch --tags --force origin changed it to 26e7294a, printing t [tag update]. If you move a tag, everyone who already fetched it has to be told.
Re-cutting a tag safely
Choose the intended commit by sha, not by name, force-create the tag on that sha, and check once before and once after the push.
want=26e7294a568cffe972c7e6d3d183d0644fe7ad12git tag -f -a v1.0.0 -m v1.0.0 "$want"test "$(git rev-parse 'v1.0.0^{commit}')" = "$want" || exit 1git push -f origin refs/tags/v1.0.0test "$(git ls-remote origin 'refs/tags/v1.0.0^{}' | cut -f1)" = "$want" || exit 1
Re-cutting in this order in the reproduction passed both checks, and the remote refs/tags/v1.0.0^{} was 26e7294a. To skip the local tag name entirely you can push the sha directly, as in git push origin 26e7294a…:refs/tags/v1.0.0. A tag made this way is a lightweight tag with no annotation, so if your publish process expects an annotated tag, use the sequence above.
Stopping it when nobody remembers: a pre-push hook
I wrote a .git/hooks/pre-push that, for a tag push, compares the commit the tag points to with an EXPECT_COMMIT environment variable and refuses when the variable is missing or different. The hook receives the local ref, local sha, remote ref and remote sha on standard input, so for refs/tags/* it compares git rev-parse "$lsha^{commit}". A tag deletion arrives with an all-zero local sha, so the hook skips it.
In the reproduction, pushing with the local tag still on the old commit and EXPECT_COMMIT set to the new one failed with pre-push: refs/tags/v1.0.0 -> 5311e7d, expected 26e7294; refusing, and no tag appeared on the remote. Pushing without the variable failed with pre-push: set EXPECT_COMMIT to push refs/tags/v1.0.0. git push --dry-run also runs the hook and fails for the same reason, so you can try it first. --no-verify skips the hook. It guards against mistakes, not against a push someone is determined to make.
Summary
A tag name is only a pointer to a commit, and deleting the remote tag leaves the local pointer where it was. Before pushing a tag, check the commit it points to with ^{commit}; after the push, read git ls-remote again to confirm the remote points at the same commit. In a repository where a tag push starts a publish, those two lines cost less than cancelling a release.
发生了什么
昨天,发布标签已经打好之后,又冒出一个必须修的问题。我合并了修复,删掉远端标签,然后运行脚本,把同名标签重新打到新提交上。可是脚本第一次推送的标签仍然指向旧提交。在那个仓库里,推送标签会触发包的发布 CI,所以我只能取消那次运行,再重新打标签。如果取消得晚一点,旧代码就会以新的版本号发布出去。
重现:删掉的远端标签和留下来的本地标签
我用 git 2.34.1 建了一个 bare 远端仓库和一个克隆,在提交 5311e7d0 上打了附注标签 v1.0.0 并推送。之后推送了修复提交 26e7294a。为了重新打标签,我用 git push origin :refs/tags/v1.0.0 删掉远端标签,这时 git ls-remote --tags origin 什么也没输出。
在这个状态下运行 git push origin v1.0.0,会显示 * [new tag] v1.0.0 -> v1.0.0 并成功,没有任何警告。但用 git ls-remote --tags origin 一看,refs/tags/v1.0.0^{} 是 5311e7d0,也就是旧提交。远端标签删了,本地标签却从来没有移动过。
重新创建失败了,脚本还在往下走
想用 git tag -a v1.0.0 -m v1.0.0 26e7294a 把本地标签重新建到新提交上,会以 fatal: tag 'v1.0.0' already exists 失败。如果脚本没开 set -e,或者忽略了这个错误,下一行的推送就会原样送出旧标签。失败的那一行之后紧跟一个成功的推送,问题就被盖住了。
git rev-parse 的陷阱
对附注标签运行 git rev-parse v1.0.0,输出的是 c50190e4。这是标签对象的 sha,不是提交。只有 git rev-parse 'v1.0.0^{commit}' 才输出提交 5311e7d0。拿标签和提交 sha 比较的检查如果不加 ^{commit},永远报不一致,最后大家只会把这个检查关掉。
其他克隆还留着旧标签
用 git tag -f 移动本地标签之后,只要远端还有同名标签,普通的 git push 就会以 Updates were rejected because the tag already exists in the remote. 被拒绝。要用 git push -f origin v1.0.0 覆盖,远端才会变成 26e7294a。
远端修好之后,在一个早已拿到旧标签的克隆里,git fetch origin 没有任何输出就结束了,标签仍然是 5311e7d0。git fetch --tags origin 以 ! [rejected] v1.0.0 -> v1.0.0 (would clobber existing tag) 拒绝更新。要运行 git fetch --tags --force origin,显示 t [tag update] 后才变成 26e7294a。移动过的标签,必须告诉所有已经拿到它的人。
安全地重新打标签
用 sha 而不是名字确定想要的提交,在那个 sha 上强制重建标签,推送前后各确认一次。
want=26e7294a568cffe972c7e6d3d183d0644fe7ad12git tag -f -a v1.0.0 -m v1.0.0 "$want"test "$(git rev-parse 'v1.0.0^{commit}')" = "$want" || exit 1git push -f origin refs/tags/v1.0.0test "$(git ls-remote origin 'refs/tags/v1.0.0^{}' | cut -f1)" = "$want" || exit 1
在重现中按这个顺序重新打标签,两次确认都通过,远端的 refs/tags/v1.0.0^{} 是 26e7294a。如果想完全绕开本地标签名,也可以直接推送 sha,例如 git push origin 26e7294a…:refs/tags/v1.0.0。不过这样得到的是没有附注的轻量标签,如果发布流程需要附注标签,就用上面的顺序。
没人记得检查时也能拦住:pre-push 钩子
我写了一个 .git/hooks/pre-push:推送标签时,把标签指向的提交和环境变量 EXPECT_COMMIT 比较,变量缺失或不一致就拒绝。钩子从标准输入拿到本地 ref、本地 sha、远端 ref 和远端 sha,所以遇到 refs/tags/* 时比较 git rev-parse "$lsha^{commit}" 即可。删除标签时本地 sha 全是 0,钩子直接跳过。
在重现中,本地标签仍指向旧提交、EXPECT_COMMIT 设为新提交时推送,以 pre-push: refs/tags/v1.0.0 -> 5311e7d, expected 26e7294; refusing 失败,远端没有出现标签。不设变量推送,则以 pre-push: set EXPECT_COMMIT to push refs/tags/v1.0.0 失败。git push --dry-run 也会运行这个钩子并因同样的原因失败,可以先拿它试。加上 --no-verify 会跳过钩子。它防的是失误,而不是有人执意要做的推送。
小结
标签名只是指向某个提交的指针,删掉远端标签,本地指针还留在原地。推送标签之前,用 ^{commit} 确认它指向的提交;推送之后,再用 git ls-remote 读一遍,确认远端指向同一个提交。在推送标签就会触发发布的仓库里,这两行比取消一次发布便宜得多。
何が起きたか
昨日、リリースタグを打ったあとで、もう一つ直すべきものが出てきた。修正をマージし、リモートのタグを消し、同じ名前のタグを新しいコミットに打ち直すスクリプトを走らせた。ところが、スクリプトが最初に push したタグは古いコミットを指していた。このリポジトリではタグの push がパッケージ公開の CI を始めるので、実行を取り消してタグを作り直すことになった。取り消しが遅れていたら、古いコードが新しいバージョン番号で公開されていた。
再現:消したリモートタグと、残ったローカルタグ
git 2.34.1 で bare のリモートとクローンを作り、コミット 5311e7d0 に注釈付きタグ v1.0.0 を打って push した。そのあと修正コミット 26e7294a を上げた。タグを打ち直すために git push origin :refs/tags/v1.0.0 でリモートのタグを消すと、git ls-remote --tags origin は何も表示しなかった。
この状態で git push origin v1.0.0 を実行すると、* [new tag] v1.0.0 -> v1.0.0 と表示されて成功する。警告は出ない。ところが git ls-remote --tags origin で見ると、refs/tags/v1.0.0^{} は 5311e7d0、つまり古いコミットだった。リモートのタグは消したが、ローカルのタグは一度も動かしていなかったからだ。
作り直しが失敗しても、スクリプトは先へ進む
ローカルのタグを新しいコミットに作り直そうとして git tag -a v1.0.0 -m v1.0.0 26e7294a を実行すると、fatal: tag 'v1.0.0' already exists で失敗する。スクリプトが set -e なしで動いていたり、このエラーを無視したりすると、次の行の push は古いタグをそのまま送る。失敗した一行のすぐあとに成功する push が、問題を覆い隠す。
git rev-parse の落とし穴
注釈付きタグに git rev-parse v1.0.0 を実行すると c50190e4 が出た。これはコミットではなく、タグオブジェクトの sha だ。同じタグに git rev-parse 'v1.0.0^{commit}' を実行して、はじめてコミット 5311e7d0 が出る。タグとコミットの sha を比べるチェックを ^{commit} なしで書くと必ず不一致になり、結局そのチェックは外されてしまう。
ほかのクローンは古いタグを持ち続ける
git tag -f でローカルのタグを動かしても、リモートに同じ名前のタグが残っているかぎり、普通の git push は Updates were rejected because the tag already exists in the remote. で拒否される。git push -f origin v1.0.0 で上書きして、はじめてリモートが 26e7294a に変わった。
リモートを直したあとでも、古いタグをすでに取得していた別のクローンでは、git fetch origin は何も表示せずに終わり、タグは 5311e7d0 のままだった。git fetch --tags origin は ! [rejected] v1.0.0 -> v1.0.0 (would clobber existing tag) で拒否した。git fetch --tags --force origin を実行すると、t [tag update] と表示されて 26e7294a に変わった。タグを動かしたら、それを取得済みのすべての場所に知らせる必要がある。
タグを安全に打ち直す
意図したコミットを名前ではなく sha で決め、その sha にタグを強制的に作り直し、push の前と後に一度ずつ確かめる。
want=26e7294a568cffe972c7e6d3d183d0644fe7ad12git tag -f -a v1.0.0 -m v1.0.0 "$want"test "$(git rev-parse 'v1.0.0^{commit}')" = "$want" || exit 1git push -f origin refs/tags/v1.0.0test "$(git ls-remote origin 'refs/tags/v1.0.0^{}' | cut -f1)" = "$want" || exit 1
再現でこの順に打ち直すと両方の確認が通り、リモートの refs/tags/v1.0.0^{} は 26e7294a だった。ローカルのタグ名をまったく通したくないなら、git push origin 26e7294a…:refs/tags/v1.0.0 のように sha を直接 push することもできる。ただし、こうして作ったタグは注釈のない軽量タグなので、公開の手順が注釈付きタグを求めるなら、上の手順を使う。
誰も覚えていなくても止める:pre-push フック
タグの push のときに、タグが指すコミットを環境変数 EXPECT_COMMIT と比べ、変数がないか違っていれば拒否する .git/hooks/pre-push を作った。フックは標準入力でローカルの ref、ローカルの sha、リモートの ref、リモートの sha を受け取るので、refs/tags/* のときに git rev-parse "$lsha^{commit}" を比べればよい。タグの削除はローカルの sha がすべて 0 で届くので、フックは飛ばす。
再現では、ローカルのタグが古いコミットを指したまま EXPECT_COMMIT に新しいコミットを渡して push すると、pre-push: refs/tags/v1.0.0 -> 5311e7d, expected 26e7294; refusing で失敗し、リモートにタグはできなかった。変数なしで push すると pre-push: set EXPECT_COMMIT to push refs/tags/v1.0.0 で失敗した。git push --dry-run もフックを実行して同じ理由で失敗するので、先に試せる。--no-verify を付けるとフックは飛ばされる。ミスを止める仕組みであって、どうしても push しようとする人を止める仕組みではない。
まとめ
タグ名はコミットを指すポインタにすぎず、リモートのタグを消してもローカルのポインタは元の場所に残る。タグを push する前に ^{commit} で指しているコミットを確かめ、push のあとには git ls-remote をもう一度読んで、リモートが同じコミットを指しているかを確かめる。タグの push が公開を始めるリポジトリなら、この二行は公開の取り消しより安い。