Представь: вышла версия 1.4.0, прод горит, тебе пишут "откатись на тот релиз". А у тебя в истории три тысячи коммитов с хешами вида a3f9c21. Какой из них был релизом? Без меток ты будешь искать по дате сборки, по чату, по памяти - и ошибёшься. Тег решает ровно эту боль: ты прибиваешь к нужному коммиту читаемое имя v1.4.0, и теперь "откатись на релиз" - это git checkout v1.4.0, а не археология по reflog.
Ветка тоже именованная метка, но ветка движется: сделал коммит - и main уехала вперёд. Тег - метка неподвижная. Поставил v1.4.0 на коммит - он там и останется навсегда, что бы ты потом ни коммитил. Это и есть смысл версионирования git: зафиксировать точку в истории так, чтобы через год её можно было найти, собрать, развернуть - байт в байт ту же самую.
В этом уроке разберём, как создать тег git правильно, чем annotated отличается от lightweight, как git describe вытаскивает имя версии из любого коммита, и как двумя командами - git archive и git bundle - превратить тег в готовый артефакт релиза без всего служебного мусора .git.

Два вида тегов: git tag annotated против lightweight
Внутри Git теги бывают двух сортов, и разница не косметическая - это разные сущности в объектной модели.
Lightweight тег - это просто файл в .git/refs/tags/ с хешем коммита внутри. Ничего больше. Ни кто поставил, ни когда, ни зачем. Создаётся так:
Код: Выделить всё
git tag v0.1-draft
Код: Выделить всё
$ cat .git/refs/tags/v0.1-draft
7c3a1f9e0b2d4a6c8e0f1a2b3c4d5e6f7a8b9c0d
$ git cat-file -t v0.1-draft
commit
Annotated тег (git tag annotated) - это полноценный объект в базе, как коммит или дерево. У него есть собственный SHA, автор, дата, сообщение, и опционально - подпись. Создаётся флагом -a:
Код: Выделить всё
git tag -a v1.4.0 -m "Release 1.4.0: новый биллинг, фикс гонки в кеше"
Код: Выделить всё
$ git cat-file -t v1.4.0
tag
$ git cat-file -p v1.4.0
object 7c3a1f9e0b2d4a6c8e0f1a2b3c4d5e6f7a8b9c0d
type commit
tag v1.4.0
tagger Anton K <anton@example.com> 1781712000 +0300
Release 1.4.0: новый биллинг, фикс гонки в кеше
Подписанный тег - тот же annotated, но с криптоподписью, чтобы любой мог проверить "это собрал именно мейнтейнер, а не подменили". В 2026 рекомендуемый простой путь - SSH-подпись:
Код: Выделить всё
git config gpg.format ssh
git config user.signingkey ~/.ssh/id_ed25519.pub
git tag -s v1.4.0 -m "Release 1.4.0"
git tag -v v1.4.0
Смотрим, отправляем и удаляем теги
Список всех тегов - git tag без аргументов. С фильтром по маске:
Код: Выделить всё
$ git tag -l "v1.4.*"
v1.4.0
v1.4.1
v1.4.2
Код: Выделить всё
$ git show v1.4.0
tag v1.4.0
Tagger: Anton K <anton@example.com>
Date: Tue Jun 17 12:00:00 2026 +0300
Release 1.4.0: новый биллинг, фикс гонки в кеше
commit 7c3a1f9e0b2d4a6c8e0f1a2b3c4d5e6f7a8b9c0d
Author: ...
Отправить конкретный тег:
Код: Выделить всё
git push origin v1.4.0
Код: Выделить всё
git push origin --tags
Код: Выделить всё
git push --follow-tags
Удаление. Локально:
Код: Выделить всё
git tag -d v1.4.0-rc1
Код: Выделить всё
git push origin --delete v1.4.0-rc1
Ты собираешь бинарь из произвольного коммита - не обязательно с тега. Что писать в --version у программы? Тут выходит git describe. Он берёт коммит, идёт по истории назад до ближайшего тега и строит человекочитаемое имя:
Код: Выделить всё
$ git describe
v1.4.0-14-g7c3a1f9
- v1.4.0 - ближайший annotated-тег позади тебя;
- 14 - сколько коммитов ты ушёл вперёд от этого тега;
- g7c3a1f9 - буква g (git) плюс короткий хеш текущего коммита.
Важная тонкость про describe и тему annotated vs lightweight. По умолчанию git describe видит только annotated-теги. Если все твои релизы - lightweight, чистый git describe скажет "fatal: No annotated tags can describe". Тогда нужен флаг --tags:
Код: Выделить всё
$ git describe --tags
v1.3.0-99-g7c3a1f9
Код: Выделить всё
$ git describe --dirty
v1.4.0-14-g7c3a1f9-dirty
git archive: снимок релиза без .git
Тег у тебя есть. Теперь нужно отдать код наружу - на сервер, в Docker-образ, в архив для заказчика. Делать git clone и тащить всю историю не надо: команда git archive экспортирует снимок дерева по тегу в tar или zip, без папки .git, без истории, только файлы как они есть на этом коммите.
Код: Выделить всё
git archive --format=tar.gz --prefix=myapp-1.4.0/ v1.4.0 -o myapp-1.4.0.tar.gz
Код: Выделить всё
$ tar tzf myapp-1.4.0.tar.gz | head
myapp-1.4.0/
myapp-1.4.0/README.md
myapp-1.4.0/src/
myapp-1.4.0/src/main.go
Часто в архив не должно попадать что-то, что нужно только в репозитории - CI-конфиги, внутренние заметки, тесты для заказчика. Для этого есть атрибут export-ignore. Создаёшь файл .gitattributes в корне:
Код: Выделить всё
.github/ export-ignore
tests/ export-ignore
RELEASE_NOTES_INTERNAL.md export-ignore
git bundle: весь репозиторий одним файлом
archive отдаёт снимок без истории. Но иногда историю надо сохранить, а сети нет - например, передать репозиторий на флешке в изолированный контур, или отдать релиз туда, где нет доступа к твоему серверу. Тут выходит git bundle: он упаковывает коммиты, ветки и теги в один файл, который сам по себе является валидным remote - из него можно клонировать и в него можно тянуть.
Упаковать всё:
Код: Выделить всё
git bundle create myrepo.bundle --all
Код: Выделить всё
$ git clone myrepo.bundle myrepo
Cloning into 'myrepo'...
Receiving objects: 100% (1284/1284), done.
$ cd myrepo && git log --oneline -1
7c3a1f9 (HEAD -> main, tag: v1.4.0) Release 1.4.0
Код: Выделить всё
git bundle create update.bundle v1.3.0..v1.4.0
Код: Выделить всё
$ git bundle verify myrepo.bundle
The bundle contains these 2 refs:
7c3a1f9... refs/heads/main
7c3a1f9... refs/tags/v1.4.0
The bundle records a complete history.
myrepo.bundle is okay
SemVer и почему опубликованный тег нельзя двигать
Имена релизов берут с потолка редко - есть стандарт SemVer (Semantic Versioning): MAJOR.MINOR.PATCH, например 2.7.3.
- MAJOR (2) - ломающие обратную совместимость изменения;
- MINOR (7) - новая функциональность без поломки старого;
- PATCH (3) - только багфиксы.
И последнее, самое важное правило. Опубликованный тег двигать нельзя. Технически можно: git tag -f v1.4.0 другой-коммит перебьёт метку. Но если ты уже сделал push, у коллег и в CI v1.4.0 указывает на старый коммит, а у тебя теперь на новый. Git при fetch по умолчанию не обновит уже существующий тег - он не двигается за тобой, как ветка. В итоге у разных людей "версия 1.4.0" - это физически разные коммиты. Сборки расходятся, баг невоспроизводим, доверие к номерам версий рушится. Тег - это обещание "вот эта точка, навсегда". Ошибся в релизе - не двигай старый, выпусти новый: v1.4.1. Так работает весь мир, и так не приходится по ночам выяснять, чей именно 1.4.0 был на проде.
Мини-лаба: от тега до артефакта релиза
Повтори руками по шагам - так уляжется надёжнее любого конспекта.
Код: Выделить всё
mkdir tag-lab && cd tag-lab
git init -b main
echo "v1" > app.txt
git add app.txt && git commit -m "first feature"
git tag draft-fast
git tag -a v1.0.0 -m "Release 1.0.0"
git cat-file -t draft-fast
git cat-file -t v1.0.0
echo "v2" >> app.txt
git commit -am "more work"
git describe --tags
printf "secret.txt export-ignore\n" > .gitattributes
echo "internal" > secret.txt
git add . && git commit -m "add ignore + secret"
git archive --format=tar -o rel.tar v1.0.0
tar tf rel.tar
git bundle create lab.bundle --all
git bundle verify lab.bundle
- git cat-file -t покажет commit для draft-fast и tag для v1.0.0 - вот она разница lightweight против annotated;
- git describe --tags выдаст что-то вроде v1.0.0-1-gХХХХ - один коммит после релиза;
- в rel.tar по версии v1.0.0 не будет ни secret.txt (export-ignore), ни .git;
- verify подтвердит, что bundle - самодостаточный репозиторий.
- Ты сделал git tag -a v2.0.0 и git push. Коллега тег не видит. Почему и как починить одной командой?
- Чем git archive по тегу отличается от git bundle? В каком случае что брать?
- git describe выдаёт "No annotated tags can describe". Что это значит и какой флаг чаще всего спасает?
- Почему нельзя передвинуть уже опубликованный тег v1.4.0 на новый коммит, даже если очень хочется?
Теги - это неподвижные метки релизов: для всего публичного делай annotated (а лучше подписанный), lightweight оставь для личных закладок. Помни, что push не шлёт теги сам - включи push.followTags. git describe превращает любой коммит в человеческий номер версии. git archive отдаёт чистый снимок по тегу для деплоя, git bundle - всю историю одним файлом для офлайна. И железное правило версионирования git: опубликованный тег - обещание навсегда, ошибся - выпускай следующий, а не переписывай прошлый.