[Git] release tag가 잘못된 commit을 가리킬 때 안전하게 정정하기
Git annotated tag와 peeled commit을 구분하고, 공개 여부에 따라 같은 이름 재사용 또는 새 버전 tag 발행을 결정하는 방법을 설명합니다.
앞선 글 shallow clone 이력 복원은 CI가 tag와 ancestry를 충분히 내려받는 방법을 설명했지만, 그 tag가 올바른 commit을 가리킨다는 전제는 남겨 두었습니다. 브랜치·rebase·push·PR 병합 흐름도 branch 통합 이후 release 이름을 어떻게 고정하고 정정하는지는 다루지 않았습니다. 이번 글은 그 다음 단계로, 잘못된 tag가 이미 공유됐는지부터 판정해 같은 이름의 서로 다른 release가 생기는 사고를 막습니다.
대상 환경과 실패 증상
Windows PowerShell과 Git 2.x에서 annotated tag인 v1.4.0을 push한 뒤 CI가 예상과 다른 commit을 빌드했거나, 내 clone의 tag SHA와 원격 조회 결과가 다르게 보이는 상황입니다. 배포를 잠시 멈추고 tag 이름, tag object, tag가 최종적으로 가리키는 commit을 각각 기록한 뒤 복구합니다.

1. 배포를 멈추고 세 가지 object ID를 기록합니다
$tag = 'v1.4.0'
git status --short
git rev-parse "refs/tags/$tag"
git rev-parse "$tag^{commit}"
git ls-remote --tags origin "refs/tags/$tag*"기대 관찰값: annotated tag라면 첫 rev-parse는 tag object SHA를, 두 번째는 tag를 벗겨 낸 commit SHA를 출력합니다. 원격 ls-remote도 tag object 행과 ^{}가 붙은 peeled commit 행을 보여 줍니다. 네트워크·인증 오류가 나면 원격 tag가 없다고 판단하지 말고 접속부터 복구합니다.
2. annotated tag와 lightweight tag를 같은 SHA로 읽지 않습니다
git cat-file -t v1.4.0
git for-each-ref `
--format="%(refname:short) %(objecttype) %(objectname) %(*objectname)" `
refs/tags/v1.4.0
git show --no-patch --decorate v1.4.0
git show --no-patch --format="%H %s" "v1.4.0^{commit}"| 종류 | objecttype | 운영 의미 |
|---|---|---|
| annotated | tag | tagger·날짜·메시지·선택적 서명을 가진 release 기록 |
| lightweight | 보통 commit | 별도 tag object 없이 object를 가리키는 짧은 이름 |
release에는 메시지와 생성자 기록이 남는 annotated tag를 우선합니다. 단순히 git rev-parse v1.4.0만 비교하면 한쪽은 tag object, 다른 쪽은 commit을 비교해 오진할 수 있으므로, 배포 대상은 항상 ^{commit}으로 확인합니다.
3. tag를 만든 뒤 대상 commit의 diff와 테스트를 다시 묶어 봅니다
git show --no-patch --format="%H%n%P%n%ad%n%s" `
--date=iso-strict "v1.4.0^{commit}"
git diff --stat "v1.3.0^{commit}..v1.4.0^{commit}"
git branch --contains "v1.4.0^{commit}"
./scripts/test-release.ps1 "v1.4.0^{commit}"진단 분기: tag SHA가 예상과 같아도 release 산출물이 틀렸다면 CI가 tag 이름 대신 branch tip이나 cache를 사용했을 수 있습니다. 반대로 peeled commit부터 틀리면 tag 대상 선정 문제입니다. 테스트 스크립트는 예시이므로 실제 프로젝트의 빌드·서명·smoke test 명령으로 바꾸고, 실패하면 tag 수정 전에 배포 중단 상태를 유지합니다.
4. 원격에 한 번도 공개되지 않았다면 로컬에서만 다시 만듭니다
$tag = 'v1.4.0'
$correct = '<verified-release-commit>'
git ls-remote --exit-code --tags origin "refs/tags/$tag"
# 종료 코드 2이고 원격 조회 자체는 정상일 때만 아래 실행
git tag -fa $tag -m "release 1.4.0" $correct
git show --no-patch "$tag^{commit}"
git push origin "refs/tags/$tag"안전 조건: 원격에 tag가 없고, release 페이지·CI artifact·다른 mirror에도 공개되지 않았다는 증거가 있을 때만 같은 이름을 다시 사용합니다. --exit-code의 2는 통신은 성공했지만 일치 ref가 없다는 뜻입니다. 일반 오류 1이나 인증 실패를 “미공개”로 읽지 않습니다.
5. 이미 공유됐다면 기존 이름을 보존하고 새 tag로 정정합니다
$correct = '<verified-release-commit>'
git tag -a v1.4.1 -m "release 1.4.0 corrected target" $correct
git show --no-patch "v1.4.1^{commit}"
git push origin refs/tags/v1.4.1
git ls-remote --tags origin 'refs/tags/v1.4.1*'기대 관찰값: 원격에는 잘못된 v1.4.0과 정정된 v1.4.1이 함께 남고, v1.4.1의 peeled SHA가 검증한 commit과 같습니다. release note에는 v1.4.0을 사용 중지하고 v1.4.1로 교체한 이유와 이전·새 SHA를 남깁니다.
중단 기준: tag 보호 규칙이나 권한이 push를 거부하면 force로 우회하지 않습니다. 담당자의 release 절차를 따르고, tag와 branch를 함께 공개해야 하며 서버가 지원한다면 git push --atomic origin refs/heads/release/1.4 refs/tags/v1.4.1처럼 전부 성공하거나 전부 실패하게 묶습니다.
6. 같은 이름이 이미 강제 이동됐다면 stale clone을 명시적으로 복구합니다
git fetch --tags origin
# would clobber existing tag가 나오면 기존 대상을 먼저 보존
git tag archive/v1.4.0-old "v1.4.0^{commit}"
git tag -d v1.4.0
git fetch origin refs/tags/v1.4.0:refs/tags/v1.4.0
git rev-parse archive/v1.4.0-old^{commit}
git rev-parse v1.4.0^{commit}Git은 이미 받은 tag를 일반 fetch로 뒤에서 덮어쓰지 않습니다. 위 절차는 조직이 같은 이름의 tag를 이미 강제 이동했고 그 결정을 별도 채널에서 확인한 뒤, clone 하나를 맞추는 복구입니다. archive tag는 이전 commit을 보존하지만 원래 annotated tag의 메시지·서명 객체를 대체하지 않으므로, 삭제 전 git cat-file -p v1.4.0 출력과 tag object SHA도 사고 기록에 남깁니다.
격리 실험 결과
Git 2.54.0.windows.1: 로컬 bare 원격에서 annotated v1.4.0을 잘못된 C3에 push했습니다. 올바른 C2에는 새 v1.4.1을 만들어 push했고 원격 peeled SHA가 C2임을 확인했습니다. 같은 v1.4.0을 C2로 바꾼 일반 push는 종료 코드 1로 거부됐습니다. 실험 목적으로 force push한 뒤 기존 clone의 fetch --tags도 종료 코드 1과 clobber 거부를 보였고, 그 clone은 계속 C3을 가리켰습니다. 이전 C3을 archive tag로 보존하고 로컬 v1.4.0을 삭제한 뒤 exact refspec으로 다시 받자 C2로 일치했으며 working tree는 clean이었습니다. 실제 GitHub 보호 규칙·release page·배포 롤백은 이 로컬 실험 범위 밖입니다.
7. 넓은 tag push와 삭제 후 재생성을 기본 절차로 쓰지 않습니다
# 피할 기본값
git push --tags
git push --force --tags
git push --delete origin v1.4.0
# 권장: 검증한 하나만 명시
git push origin refs/tags/v1.4.1모든 로컬 tag를 한꺼번에 push하면 임시·개인 tag까지 공개될 수 있습니다. force와 delete는 이미 내려받은 clone을 자동으로 정리하지 않아 같은 이름의 서로 다른 release를 더 오래 남깁니다. 보안 사고처럼 기존 tag를 반드시 제거해야 한다면, 삭제 자체보다 영향받은 artifact·clone·cache 목록과 재발행 절차를 먼저 확정합니다.
운영 판단표
| 관찰 | 판단 | 조치 |
|---|---|---|
| 원격 ref 없음·소비 흔적 없음 | 미공개 | 로컬 tag 재생성 후 하나만 push |
| 원격·CI·release 중 하나라도 존재 | 공유됨 | 기존 보존, 새 버전 tag 발행 |
| fetch가 clobber 거부 | 같은 이름이 갈라짐 | 이전 대상 보존 후 exact refetch |
| tag는 맞지만 artifact가 다름 | CI 입력·cache 문제 | peeled SHA와 artifact provenance 비교 |
마무리
- tag 이름, tag object SHA, peeled commit SHA를 따로 기록합니다.
- 원격 조회 실패와 원격 tag 부재를 구분합니다.
- 미공개일 때만 같은 이름을 로컬에서 다시 만듭니다.
- 공유된 tag는 보존하고 새 버전 tag로 정정합니다.
- 강제 이동이 이미 일어났다면 clone별 이전 대상을 보존한 뒤 명시적으로 맞춥니다.
공식 출처
- Git 공식 문서: git-tag — annotated·lightweight tag, 검증, 재태깅과 공유 후 새 이름 원칙
- Git 공식 문서: git-push — tag refspec, force, delete, atomic, follow-tags 동작
- Git 공식 문서: git-ls-remote — 원격 tag object와 peeled object ID 조회
- Git 공식 문서: git-for-each-ref — ref objecttype, objectname, peeled field 출력
- Git 공식 문서: git-rev-parse — ^{commit}, ^{tag}, ^{} dereference 문법
- Git 공식 문서: git-show-ref — exact ref 확인과 tag dereference
- Git 공식 문서: git-show — tag 메시지와 참조 object 검사
- Pro Git: Tagging — tag 생성·공유·삭제의 기본 흐름