4.3Теги и Go-модули
Поймёшь, как теги становятся версиями Go-модуля и почему опубликованный тег нельзя без последствий передвигать. Разберёшь псевдоверсии, приватные модули и major version в пути import.
- Чем lightweight тег отличается от annotated и почему после
git pushтега на сервере нет. - Как go по пути модуля находит репозиторий и зачем ему
GOPRIVATEиGOINSECURE. - Какие теги go считает версиями и что попадёт в
go.modпослеgo get …@main. - Из чего собрана псевдоверсия и почему её не пишут руками.
- Что увидят потребители, если переставить опубликованный тег, и как выпускать исправление.
- Зачем
/v2в пути модуля, откуда+incompatibleи как ставить теги, если модулей в репозитории несколько.
Lightweight и annotated: какой тег доедет до сервера
Примеры главы идут на библиотеке money, которую Боря пишет для сервиса shop из главы
4.1. Путь Go-модуля начинается с имени хоста, и в этом имени нужна точка, поэтому сервер здесь другой:
git.localhost на той же машине (имена *.localhost ведут на неё саму). Он отдаёт
репозитории через git http-backend, отвечает на служебный запрос go, как это делает GitLab, и
работает без TLS.
Седьмого сентября Боря отправил в main форматирование сумм и ставит первый релизный тег.
Объект тега разобран в главе 1.1, а при push теги ведут себя так:
$ git tag v0.1.0
$ git push --follow-tags
Everything up-to-date
$ git ls-remote --tags origin
$ git describe
fatal: No annotated tags can describe '006f72e1893b463c889b6d594a8d00db9d09c1f8'.
However, there were unannotated tags: try --tags.
git tag без -a создал lightweight тег: ссылку refs/tags/v0.1.0 прямо на
коммит, без автора, даты и сообщения. --follow-tags отправляет только annotated теги, так что сервер
ничего не получил. describe без --tags такие теги тоже не замечает. Тег никто ещё не
видел, и его можно пересоздать:
$ git tag -d v0.1.0
Deleted tag 'v0.1.0' (was 006f72e)
$ git tag -a v0.1.0 -m "money v0.1.0"
$ git push --follow-tags
To http://git.localhost/shop/money.git
* [new tag] v0.1.0 -> v0.1.0
$ git ls-remote --tags origin
cc891989e4d43a94407f28d680c0224bea129845 refs/tags/v0.1.0
006f72e1893b463c889b6d594a8d00db9d09c1f8 refs/tags/v0.1.0^{}
Первая строка ведёт к объекту тега, вторая с суффиксом ^{} к коммиту, на который он указывает.
Annotated тег хранит, кто, когда и с каким сообщением выпустил версию, его можно подписать
(git tag -s), а describe и --follow-tags по умолчанию видят только такие. Релизы метят
им, а lightweight оставляют для личных закладок вроде before-rebase. Какие теги уйдут на сервер,
решает форма push:
| Команда | Какие теги уйдут |
|---|---|
git push, git push origin main | никакие |
git push origin v0.1.0 | названный, любого вида |
git push --follow-tags | annotated, которых нет на сервере, если их коммит уходит на сервер или уже там |
git push --tags | все локальные, включая lightweight-закладки |
git config push.followTags true (git 2.4) добавляет --follow-tags к каждому push. С
--tags осторожнее: в опыте на копии он унёс на сервер закладку tmp-before-refactor. Обратно
fetch сам забирает теги на коммитах всех веток сервера, lightweight тоже, а тег на коммите вне веток
обычный fetch не принесёт, нужен git fetch --tags. Самому go вид тега безразличен, lightweight v0.1.0 тоже
стал бы версией. Его легко забыть отправить.
Как go находит модуль: путь, git и три переменные
Восьмого сентября Аня подключает money к сервису. В go.mod сервиса стоит
module shop, а main.go импортирует git.localhost/shop/money:
$ cd shop
$ GOINSECURE=git.localhost go get git.localhost/shop/money@v0.1.0
go: downloading git.localhost/shop/money v0.1.0
go: git.localhost/shop/money@v0.1.0: verifying module: git.localhost/shop/money@v0.1.0: reading https://sum.golang.org/lookup/git.localhost/shop/money@v0.1.0: 404 Not Found
server response: not found: git.localhost/shop/money@v0.1.0: unrecognized import path "git.localhost/shop/money": https fetch: Get "https://git.localhost/shop/money?go-get=1": dial tcp: lookup git.localhost on 8.8.8.8:53: no such host
По пути модуля не видно, где лежит репозиторий, поэтому go спрашивает сам хост:
GET https://git.localhost/shop/money?go-get=1. В ответ приходит HTML с тегом
go-import, где записаны корень репозитория, система контроля версий и адрес. GitLab отвечает на
такой запрос сам. Искать и качать модуль по HTTP go согласен только для путей из GOINSECURE, иначе падает на
https fetch … connection refused, ведь TLS у нашего сервера нет.
Сам отказ случился уже после скачивания. По умолчанию GOPROXY=https://proxy.golang.org,direct, и
go сначала попросил модуль у публичного прокси. Тот ответил 404 (в go get -x это видно), и go пошёл в git сам, это и
значит direct. Скачав модуль, он стал сверять хеш с sum.golang.org, публичной базой
контрольных сумм (о ней статья «Go: язык»). Ни прокси, ни база не смогли даже разрешить имя
git.localhost, зато путь закрытого модуля ушёл в два внешних сервиса. Обе беды снимает одна переменная:
$ export GOPRIVATE=git.localhost GOINSECURE=git.localhost
$ go env GONOPROXY GONOSUMDB
git.localhost
git.localhost
$ go get git.localhost/shop/money@v0.1.0
go: downloading git.localhost/shop/money v0.1.0
go: added git.localhost/shop/money v0.1.0
$ go mod tidy
$ go run .
1234567.89 ₽
GOPRIVATE перечисляет шаблоны закрытых путей и заодно задаёт по умолчанию две более узкие
переменные: GONOPROXY (эти модули у прокси не просить) и GONOSUMDB (не сверять с публичной
базой). go.sum при этом работает как обычно. GOFLAGS=-insecure из старых инструкций
не поможет: флаг убрали из go get в Go 1.17, и Go 1.27 на него отвечает
go: -insecure flag is no longer supported; use GOINSECURE instead.
$ curl -s 'http://git.localhost/shop/money?go-get=1'
<html><head><meta name="go-import" content="git.localhost/shop/money git http://git.localhost/shop/money.git"></head><body></body></html>
$ ls $(go env GOMODCACHE)/cache/download/git.localhost/shop/money/@v
list v0.1.0.lock v0.1.0.zip
v0.1.0.info v0.1.0.mod v0.1.0.ziphash
Дальше работает обычный git, его команды печатает go get -x. go узнаёт через
git ls-remote, какие теги есть на сервере, заводит голый репозиторий в
$GOMODCACHE/cache/vcs/<хеш адреса>, забирает один коммит командой вида
git fetch --depth=1 origin refs/tags/v0.1.0:refs/tags/v0.1.0 и упаковывает его
git archive --format=zip. По архиву go считает хеш для go.sum, а сам архив,
go.mod версии и её описание ложатся в cache/download. Прокси модулей отдаёт по HTTP
файлы с той же раскладкой.
Какие теги go считает версиями и что выбирает go get
Список версий go собирает из тегов, которые вернул ls-remote. Версия выглядит как
vMAJOR.MINOR.PATCH, по желанию с пререлизом через дефис. На копии репозитория проверили и другие
имена:
| Тег | В go list -m -versions | Что даёт go get …@тег |
|---|---|---|
v0.1.0, lightweight или annotated | есть | v0.1.0 |
v0.7.0-rc.1 | есть, пререлиз | v0.7.0-rc.1 |
0.2.0, V0.6.0, release-5 | нет | псевдоверсию, как для любого имени ревизии |
v0.3 | нет | ошибку: @v0.3 читается как запрос «старшая из v0.3.x» |
После @ можно написать и имя ветки или хеш коммита. Днём восьмого сентября Боря влил в
main разделение разрядов, тега ещё нет, а Ане изменение нужно сейчас:
$ go get git.localhost/shop/money@main
go: downloading git.localhost/shop/money v0.1.1-0.20260908090000-02e7b62003d7
go: upgraded git.localhost/shop/money v0.1.0 => v0.1.1-0.20260908090000-02e7b62003d7
$ go run .
1 234 567.89 ₽
$ grep money go.mod
require git.localhost/shop/money v0.1.1-0.20260908090000-02e7b62003d7
В go.mod записалось не main, а псевдоверсия. Ветка двигается, а сборке нужна неподвижная точка, поэтому новые
коммиты main сами не приедут, их снова берут через @main. Хеш коммита go переводит
так же, а если на коммите стоит тег версии, пишет версию из тега. В три часа Боря выпускает пререлиз, и Аня
смотрит, что теперь видит go:
$ git tag -a v0.2.0-rc.1 -m "money v0.2.0-rc.1"
$ git push -q --follow-tags
$ go list -m -versions git.localhost/shop/money
git.localhost/shop/money v0.1.0 v0.2.0-rc.1
$ go list -m git.localhost/shop/money@latest
git.localhost/shop/money v0.1.0
$ go list -m git.localhost/shop/money@v0.1
git.localhost/shop/money v0.1.0
@latest выбирает старший релиз, пререлиз берёт, только если релизов нет, а без тегов вообще отдаёт
псевдоверсию вершины ветки по умолчанию. @v0.1 означает «старшая из v0.1.x», есть ещё
@patch и сравнения вроде @<v0.2.0. На какой ветке тег, неважно: в опыте
v0.9.0 на экспериментальной ветке стал ответом на @latest, так что версионные теги на
эксперименты не ставят.
Псевдоверсия: номер для коммита без тега
Утром девятого сентября Боря уточнил описание пакета. История money и версии, которые go даёт её
коммитам:
$ git log --oneline --decorate
45e26ec (HEAD -> main, origin/main) Уточнить описание пакета
02e7b62 (tag: v0.2.0-rc.1) Разделять разряды пробелом
006f72e (tag: v0.1.0) Форматировать копейки
e2926f0 Создать модуль money
$ go list -m git.localhost/shop/money@e2926f0
git.localhost/shop/money v0.0.0-20260906150000-e2926f05527f
$ go list -m git.localhost/shop/money@45e26ec
git.localhost/shop/money v0.2.0-rc.1.0.20260909060000-45e26ec1c418
В псевдоверсии три части: база, время и хеш. Время go берёт у коммиттера, а не у автора (читает
%ct), и переводит в UTC: коммит 45e26ec сделан в 09:00 по Москве, в номере стоит
060000. После rebase или cherry-pick у того же изменения будет новый номер. Хеш обрезан до
12 знаков. Базу go берёт от старшего по версии тега среди предков, и форма зависит от того, что это за тег:
| Старший тег-предок | Форма | Пример из истории money |
|---|---|---|
| нет | vX.0.0-время-хеш | v0.0.0-20260906150000-e2926f05527f |
релиз vX.Y.Z | vX.Y.(Z+1)-0.время-хеш | v0.1.1-0.20260908090000-02e7b62003d7 |
пререлиз vX.Y.Z-pre | vX.Y.Z-pre.0.время-хеш | v0.2.0-rc.1.0.20260909060000-45e26ec1c418 |
Формы подобраны так, чтобы псевдоверсия была старше своего тега и младше следующей версии:
v0.1.1-0.… новее v0.1.0, но уступает v0.1.1. MVS (статья «Go: язык») поэтому предпочтёт коммит после тега самому тегу. Номер у коммита не единственный:
02e7b62 в go.mod Ани записан как v0.1.1-0.…, а сейчас на нём стоит тег, и новый
go get напишет v0.2.0-rc.1. Старая запись остаётся рабочей.
На копии в main с тегом v0.5.0 влили ветку сопровождения с v0.4.2:
$ git describe --tags
v0.4.2-3-g05da56d
$ go list -m git.localhost/shop/money@main
git.localhost/shop/money v0.5.1-0.20260905070000-05da56d87f72
describe нашёл ближайший v0.4.2, go взял старший: иначе номер v0.4.3-0.…
был бы младше предка v0.5.0.
Каждую часть go сверяет с репозиторием:
$ go list -m git.localhost/shop/money@v0.2.0-rc.1.0.20260909070000-45e26ec1c418
go: git.localhost/shop/money@v0.2.0-rc.1.0.20260909070000-45e26ec1c418: invalid pseudo-version: does not match version-control timestamp (expected 20260909060000)
$ go list -m git.localhost/shop/money@v0.2.1-0.20260909060000-45e26ec1c418
go: git.localhost/shop/money@v0.2.1-0.20260909060000-45e26ec1c418: invalid pseudo-version: preceding tag (v0.2.0) not found
Время обязано совпасть с коммитом, тег базы существовать и быть его предком, а сам коммит достижим из
какой-нибудь ветки или тега сервера. Иначе любой записал бы v9.9.9-0.… и обошёл MVS.
Поэтому псевдоверсию не пишут руками, её получают из go get …@хеш или @ветка.
Перенесённый тег: git разрешит, go.sum нет
В 10:00 Боря выпускает v0.2.0 на коммите 45e26ec. Аня обновляется и пишет тест на строку
возврата в чеке:
$ go get git.localhost/shop/money@v0.2.0
go: downloading git.localhost/shop/money v0.2.0
go: upgraded git.localhost/shop/money v0.1.1-0.20260908090000-02e7b62003d7 => v0.2.0
$ go test
--- FAIL: TestRefundLine (0.00s)
refund_test.go:7: refundLine(50) = "Возврат: 0.-50 ₽", want "Возврат: -0.50 ₽"
FAIL
exit status 1
FAIL shop 0.448s
Отрицательные суммы печатаются криво. Аня отправляет тест, и CI сервиса падает так же. CI ходит за модулями
через командный прокси, у него GOPROXY=http://proxy.localhost:3000 и
GONOSUMDB=git.localhost. Так работают Athens и Artifactory, а в песочнице это маленький сервер,
который забирает версию из git один раз и дальше отдаёт из своего кэша. GOPRIVATE в CI не задан,
иначе он включил бы GONOPROXY и увёл закрытые модули мимо прокси.
Десятого сентября Боря чинит знак и решает не плодить версий: переставить v0.2.0 на исправление.
$ git tag -f -a v0.2.0 -m "money v0.2.0"
Updated tag 'v0.2.0' (was 2dc774d)
$ git push origin main v0.2.0
To http://git.localhost/shop/money.git
45e26ec..5f5390b main -> main
! [rejected] v0.2.0 -> v0.2.0 (already exists)
error: failed to push some refs to 'http://git.localhost/shop/money.git'
hint: Updates were rejected because the tag already exists in the remote.
$ git push --force origin v0.2.0
To http://git.localhost/shop/money.git
+ 2dc774d...3a592cb v0.2.0 -> v0.2.0 (forced update)
Существующий тег push без --force не трогает, даже если новый коммит потомок старого: перемотки для
тегов не бывает. 2dc774d и 3a592cb здесь объекты тегов, а не коммиты. В клоне
money, который Аня держит для чтения кода и где вчерашний fetch уже принёс v0.2.0, о
перестановке fetch молчит:
$ git fetch
From http://git.localhost/shop/money
45e26ec..5f5390b main -> origin/main
$ git fetch --tags
From http://git.localhost/shop/money
! [rejected] v0.2.0 -> v0.2.0 (would clobber existing tag)
$ git fetch --tags --force
From http://git.localhost/shop/money
t [tag update] v0.2.0 -> v0.2.0
Сам fetch приносит только новые теги, а существующий с git 2.20 без --force не перезаписывает.
git fetch --prune --prune-tags удаляет теги, которых нет на сервере, и по документации обновляет
разошедшиеся, но в git 2.54 отвечает тем же would clobber existing tag. Без --prune второй
флаг ничего не удаляет, но скачивает все теги сервера, как --tags. Теперь go:
$ go get git.localhost/shop/money@v0.2.0
$ go test
--- FAIL: TestRefundLine (0.00s)
refund_test.go:7: refundLine(50) = "Возврат: 0.-50 ₽", want "Возврат: -0.50 ₽"
FAIL
exit status 1
FAIL shop 0.134s
$ go clean -modcache
$ go test
go: downloading git.localhost/shop/money v0.2.0
verifying git.localhost/shop/money@v0.2.0: checksum mismatch
downloaded: h1:gtY1CNa/wNpdqTm6bRrQLcSMxz81tQgf+WUTGq/L90o=
go.sum: h1:dd4yaUkkrkYWf+E6chxUQQ4QJ+MvhrWzbYJVj3P3XaM=
SECURITY ERROR
This download does NOT match an earlier download recorded in go.sum.
The bits may have been replaced on the origin server, or an attacker may
have intercepted the download attempt.
For more information, see 'go help module-auth'.
go get ничего не скачал: версия v0.2.0 уже лежит в кэше модулей, а кэш go считает
неизменным. После очистки go взял из git новый коммит, посчитал хеш архива и сравнил с go.sum. Так
же упадёт сборка сервиса на чистой машине без прокси. CI с пустым кэшем через прокси снова получил
0.-50 ₽: прокси отдал архив, скачанный накануне, и в git не ходил. Исправленный код достанется только
новому потребителю без строки в go.sum, который идёт мимо прокси, и его хеш разойдётся со всеми.
go.sum, а сам архив лежит в кэше Ани и в прокси. Исправление не дошло ни в одно из трёх
мест, а сборка с чистого листа сломалась.
Выпущенную версию не исправляют, выпускают следующую. Боря возвращает v0.2.0 на
45e26ec, помечает его отозванным и ставит v0.2.1:
$ git tag -f -a v0.2.0 -m 'money v0.2.0' 45e26ec
Updated tag 'v0.2.0' (was 3a592cb)
$ git push --force origin v0.2.0
To http://git.localhost/shop/money.git
+ 3a592cb...01fd916 v0.2.0 -> v0.2.0 (forced update)
$ cat go.mod
module git.localhost/shop/money
go 1.27
// Отрицательные суммы печатаются как 0.-50 ₽.
retract v0.2.0
$ git commit -qam "Отозвать v0.2.0"
$ git tag -a v0.2.1 -m "money v0.2.1"
$ git push -q --follow-tags
Боря снова переставил тег, но на коммит, который уже разошёлся по кэшам: загрузка с чистым кэшем
снова дала h1:dd4y…. Директива retract в go.mod новой версии прячет
v0.2.0 от @latest, а тем, кто запросит её явно, go напечатает предупреждение с этим
комментарием. У Ани:
$ go list -m -u git.localhost/shop/money
git.localhost/shop/money v0.2.0 (retracted) [v0.2.1]
$ go get git.localhost/shop/money@latest
go: downloading git.localhost/shop/money v0.2.1
go: upgraded git.localhost/shop/money v0.2.0 => v0.2.1
$ go test
PASS
ok shop 0.288s
Внутри кэша модулей go держит голый клон репозитория, и тег там не обновляется. После того как Боря вернул
v0.2.0 на место, go test у Ани по-прежнему падал с checksum mismatch:
git rev-parse в cache/vcs показывал 5f5390b. Помог ещё один
go clean -modcache. У публичного модуля всё жёстче: sum.golang.org навсегда запоминает
хеш первой загрузки, а proxy.golang.org держит копию версии, даже когда тег удалён (FAQ
proxy.golang.org советует выпустить новую версию, а старую отозвать через retract).
v2 и дальше: /v2 в пути и +incompatible
Одиннадцатого сентября Боря меняет сигнатуру: Format теперь принимает Amount с
валютой. Старый код потребителей не соберётся, значит это новая major-версия. Боря ставит
v2.0.0, не трогая go.mod:
$ go get git.localhost/shop/money@v2.0.0
go: git.localhost/shop/money@v2.0.0: invalid version: module contains a go.mod file, so module path must match major version ("git.localhost/shop/money/v2")
$ go get git.localhost/shop/money/v2@v2.0.0
go: git.localhost/shop/money@v2.0.0: invalid version: module contains a go.mod file, so module path must match major version ("git.localhost/shop/money/v2")
Начиная с v2 путь модуля обязан кончаться на /v2: один путь импорта обещает совместимый код, так что
несовместимая версия получает новый путь (подробно в статье «Go: язык»). Тег v2.0.0 испорчен, а
переставлять его нельзя по причинам из прошлого раздела. Боря правит путь и выпускает v2.0.1:
$ go mod edit -module git.localhost/shop/money/v2
$ head -1 go.mod
module git.localhost/shop/money/v2
$ git commit -qam "Перейти на путь модуля /v2"
$ git tag -a v2.0.1 -m "money v2.0.1"
$ git push -q --follow-tags
$ go list -m -versions git.localhost/shop/money/v2
git.localhost/shop/money/v2 v2.0.0 v2.0.1
$ go list -m git.localhost/shop/money/v2@latest
git.localhost/shop/money/v2 v2.0.1
$ go list -m git.localhost/shop/money@latest
git.localhost/shop/money v0.2.1
Для go это теперь два модуля. @latest старого пути остался v0.2.1, и сервис перейдёт на
v2, только когда Аня поправит импорты. Если пакеты библиотеки импортируют друг друга, эти импорты тоже
переписывают на /v2. Битый v2.0.0 висит в списке, но запросить его нельзя. Есть и второй способ:
код копируют в подкаталог v2/ со своим go.mod, а тег ставят без префикса, просто
v2.0.0 (проверено на копии).
У старой библиотеки metrics go.mod нет вовсе: её выпускали тегами ещё до модулей, и
мажорная версия успела дойти до трёх.
$ go list -m -versions git.localhost/shop/metrics
git.localhost/shop/metrics v1.0.0 v2.0.0+incompatible v3.1.0+incompatible
$ go list -m git.localhost/shop/metrics@latest
git.localhost/shop/metrics v3.1.0+incompatible
$ go list -m git.localhost/shop/metrics/v3@latest
go: git.localhost/shop/metrics/v3@v3.1.0: invalid version: missing git.localhost/shop/metrics/go.mod and .../v3/go.mod at revision v3.1.0
Без go.mod правило пути проверять не по чему, и go делает исключение: пускает v2+ под старым путём с
пометкой +incompatible. На сам тег суффикс не ставят. Совместимость здесь никто не обещал, но
обновляются такие версии как обычные: в опыте go get -u поднял v1.0.0 сразу до
v3.1.0+incompatible.
Боря добавил в metrics файл go.mod со старым путём и поставил v3.2.0:
$ go list -m -versions git.localhost/shop/metrics
git.localhost/shop/metrics v1.0.0 v2.0.0+incompatible
$ go list -m git.localhost/shop/metrics@latest
git.localhost/shop/metrics v2.0.0+incompatible
$ go list -m git.localhost/shop/metrics@v3.2.0
go: git.localhost/shop/metrics@v3.2.0: invalid version: module contains a go.mod file, so module path must match major version ("git.localhost/shop/metrics/v3")
Последний тег серии v3 теперь с go.mod, и go выкидывает из списка всю серию, вместе с
v3.1.0+incompatible (так устроен modfetch/coderepo.go). @latest уехал на
v2. Документация Go при переходе на go.mod после v2 советует выпускать следующую major-версию с
суффиксом: в опыте /v4 с тегом v4.0.0 заработал сразу.
Несколько модулей в одном репозитории
Двенадцатого сентября Боря выносит клиент курсов ЦБ в отдельный модуль внутри того же репозитория, чтобы его
будущие зависимости не тянулись ко всем потребителям money:
$ cd rates && go mod init git.localhost/shop/money/rates && cd ..
go: creating new go.mod: module git.localhost/shop/money/rates
go: to add module requirements and sums:
go mod tidy
$ git add . && git commit -qm "Модуль rates"
$ git tag -a v2.1.0 -m "money v2.1.0"
$ git push -q --follow-tags
$ go list -m git.localhost/shop/money/rates@v2.1.0
go: git.localhost/shop/money/rates@v2.1.0: invalid version: unknown revision rates/v2.1.0
$ go list -m git.localhost/shop/money/rates@latest
git.localhost/shop/money/rates v0.0.0-20260912070000-59736032e2be
Версии модуля из подкаталога go ищет в тегах с префиксом этого каталога, для rates это
rates/v2.1.0. Тег v2.1.0 принадлежит только корневому модулю, а у rates своих
тегов нет, отсюда псевдоверсия без базы. Боря выпускает rates отдельно, а через час коммитит в него
таймаут:
$ git tag -a rates/v0.1.0 -m "rates v0.1.0"
$ git push --follow-tags
To http://git.localhost/shop/money.git
* [new tag] rates/v0.1.0 -> rates/v0.1.0
$ go list -m -versions git.localhost/shop/money/rates
git.localhost/shop/money/rates v0.1.0
$ go list -m git.localhost/shop/money/rates@main git.localhost/shop/money/v2@main
git.localhost/shop/money/rates v0.1.1-0.20260912090000-27bf1af3596b
git.localhost/shop/money/v2 v2.1.1-0.20260912090000-27bf1af3596b
$ go mod download git.localhost/shop/money/v2@v2.1.0
$ unzip -Z1 $(go env GOMODCACHE)/cache/download/git.localhost/shop/money/v2/@v/v2.1.0.zip
git.localhost/shop/money/v2@v2.1.0/doc.go
git.localhost/shop/money/v2@v2.1.0/go.mod
git.localhost/shop/money/v2@v2.1.0/money.go
Один коммит 27bf1af получил две псевдоверсии: у каждого модуля своя линия тегов. В архив корневого
модуля каталог rates не попал: вложенный модуль go вырезает целиком. Если money начнёт
импортировать rates, то потребует конкретную его версию, а править оба сразу помогает
go.work (глава 5.3).
Приватный репозиторий: доступ без вопросов в терминале
Четырнадцатого сентября анонимное чтение на git.localhost закрыли, как обычно и делают на
корпоративном сервере. Аня собирает сервис с пустым кэшем (путь к кэшу в выводе сокращён):
$ go clean -modcache
$ go test
go: downloading git.localhost/shop/money v0.2.1
# shop
main.go:6:2: reading git.localhost/shop/money/go.mod at revision v0.2.1: git ls-remote -q --end-of-options http://git.localhost/shop/money.git in ~/go/pkg/mod/cache/vcs/c971…: exit status 128:
fatal: could not read Username for 'http://git.localhost': terminal prompts disabled
Confirm the import path was entered correctly.
If this is a private repository, see https://go.dev/doc/faq#git_https for additional information.
FAIL shop [setup failed]
На ?go-get=1 сервер ответил без пароля, а git, придя за репозиторием, получил 401 и захотел спросить логин.
go запускает git с GIT_TERMINAL_PROMPT=0, чтобы сборка в CI или в IDE не зависла на невидимом
вопросе. Доступ надо настроить так, чтобы git проходил молча. По HTTPS git возьмёт токен из
~/.netrc или credential helper. Второй путь: ходить по SSH с ключом и переписать адрес в конфиге git.
$ git config --global url."ssh://git.localhost:2222/".insteadOf "http://git.localhost/"
$ git ls-remote --get-url http://git.localhost/shop/money.git
ssh://git.localhost:2222/shop/money.git
$ go test
go: downloading git.localhost/shop/money v0.2.1
PASS
ok shop 0.142s
url.<base>.insteadOf подменяет начало адреса во всех командах git, и в тех, что запускает go.
Адрес в go-import остался прежним, а трафик пошёл по SSH (SSH-сервер песочницы слушает порт 2222,
ключ Ани на нём уже есть). Для GitLab строка из его документации выглядит так, и --get-url
проверяет подмену без сети:
$ git config --global url."git@gitlab.example.com:".insteadOf "https://gitlab.example.com/"
$ git ls-remote --get-url https://gitlab.example.com/shop/money.git
git@gitlab.example.com:shop/money.git
Запрос ?go-get=1 go делает сам, без git, и insteadOf на него не действует. GitLab отвечает
на него без авторизации, пока проект лежит прямо в группе. Для приватной подгруппы нужен токен в
.netrc или GOAUTH, либо суффикс .git в пути модуля: с ним go идёт в git без
запроса.
Вопросы
4git push ветки теги не
отправляет вовсе, --follow-tags отправляет только annotated, так что забытый -a оставляет
релиз на твоей машине.Что лежит в репозитории
git tag v0.1.0 пишет refs/tags/v0.1.0 с хешем коммита. git tag -a v0.1.0 -m …
сначала создаёт объект tag (глава 1.1), и ссылка указывает на него. На сервере это видно по второй
строке ls-remote:
$ git ls-remote --tags origin
cc891989e4d43a94407f28d680c0224bea129845 refs/tags/v0.1.0
006f72e1893b463c889b6d594a8d00db9d09c1f8 refs/tags/v0.1.0^{}
Как теги попадают на сервер
| Команда | lightweight | annotated |
|---|---|---|
git push | нет | нет |
git push --follow-tags, push.followTags=true | нет | да, если коммит уходит на сервер или уже там |
git push origin v0.1.0 | да | да |
git push --tags | все | все |
Для go вид тега неважен: версией стал бы и lightweight, если бы дошёл до сервера.
Что ставить
Релизы: git tag -a или -s и push.followTags=true, чтобы тег уезжал вместе с
веткой. Lightweight оставь для личных закладок.
checksum mismatch, потому что в go.sum
записан хеш старого архива. Прокси продолжит отдавать старый архив. Надо было выпустить v0.2.1, а
v0.2.0 отозвать директивой retract.Три разных v0.2.0
| Где собирают | Что получили |
|---|---|
| машина, где версия уже в кэше | старый код без предупреждений, go get ничего не качает |
| чистый кэш, напрямую из git | verifying …@v0.2.0: checksum mismatch, SECURITY ERROR |
| чистый кэш, через командный прокси | старый код: прокси скачал версию раньше и в git не ходит |
| публичный модуль | proxy.golang.org отдаёт закэшированный архив, загрузка мимо него не сойдётся с хешем в sum.golang.org |
Как выпустить исправление
module git.localhost/shop/money
go 1.27
// Отрицательные суммы печатаются как 0.-50 ₽.
retract v0.2.0
Этот go.mod уходит в новую версию v0.2.1. @latest пропускает отозванную
версию, go list -m -u пишет v0.2.0 (retracted) [v0.2.1], а явный запрос
@v0.2.0 печатает предупреждение retracted by module author с комментарием. Если тег уже
переставили, его возвращают на исходный коммит: так прямые загрузки снова совпадут с go.sum и прокси.
Как чинить у себя
Не стирать строки из go.sum: в опыте после этого go mod tidy молча записал новый хеш,
тесты прошли, а под v0.2.0 у Ани и у CI остался разный код. Ошибка прямо говорит, что номер один,
а архивы разные. Узнать у автора, какой настоящий, перейти на исправленную версию и почистить кэш.
go.mod go пишет только
канонические версии, а ветка двигается. Псевдоверсия указывает на один коммит и сама не обновится: новые
коммиты ветки забирают повторным go get …@main.Из чего собрана
v0.1.1-0. значит «после релиза v0.1.0»: старший тег-предок с увеличенным патчем.
20260908090000 время коммиттера в UTC, 02e7b62003d7 первые 12 знаков хеша. Если
старший тег-предок пререлиз, база выглядит как v0.2.0-rc.1.0., если тегов нет, как
v0.0.0-. Номер старше базового тега и младше следующей версии, поэтому MVS может сравнивать его
с настоящими версиями.
Почему не ветка
MVS сравнивает версии, и через год сравнение должно дать тот же ответ. Ветка сегодня значит один коммит, завтра другой, поэтому go сразу переводит её в номер, а если на коммите есть тег версии, пишет тег.
Как обновлять
На копии репозитория, пока у Ани стояла псевдоверсия, а у Бори уже был пререлиз:
$ go get -u git.localhost/shop/money
go: downloading git.localhost/shop/money v0.2.0-rc.1
go: upgraded git.localhost/shop/money v0.1.1-0.20260908090000-02e7b62003d7 => v0.2.0-rc.1
$ go get git.localhost/shop/money@latest
go: downgraded git.localhost/shop/money v0.2.0-rc.1 => v0.1.0
Псевдоверсия сама считается пререлизом, и -u поднял её до пререлиза новее. @latest
означает старший релиз и откатил зависимость на v0.1.0, вместе с разделением разрядов. Чтобы
обновиться с ветки, явно пиши @main, а когда появится тег, переходи на него.
Коммит должен быть достижим из ветки или тега сервера. В опыте Аня взяла @parse, Боря влил ветку
через squash и удалил её на сервере (глава 4.1). С пустым кэшем go mod download ответил
invalid version: unknown revision be0950195bce: в main лежит новый коммит с тем же
кодом, а нужного на сервере больше нет. Прокси может хранить копию, но полагаться на это нельзя: зависимость на ветку
переводят на тег до того, как ветку удалят.
GOPRIVATE с путём группы, чтобы go не просил модуль у публичного
прокси и не сверял его с sum.golang.org. Дальше модуль качает git, и ему нужен доступ без вопросов в
терминале: токен в .netrc или GOAUTH, либо SSH через url.<base>.insteadOf.Переменные
Без GOPRIVATE go спросит proxy.golang.org, пойдёт в git и упадёт на сверке с
sum.golang.org (verifying module: … 404 Not Found), а путь модуля уйдёт в оба сервиса.
GOPRIVATE=gitlab.example.com/shop задаёт сразу GONOPROXY и GONOSUMDB. С
командным прокси ставят GOPROXY и GONOSUMDB без GOPRIVATE, иначе модули
пойдут мимо прокси. GOINSECURE нужен серверу без HTTPS или с самоподписанным сертификатом (git его всё равно проверит), а GOFLAGS=-insecure убран
в Go 1.17.
Доступ
go запускает git с GIT_TERMINAL_PROMPT=0, и без настроенного доступа ошибка кончается словами
could not read Username … terminal prompts disabled. Для SSH:
$ git config --global url."git@gitlab.example.com:".insteadOf "https://gitlab.example.com/"
$ git ls-remote --get-url https://gitlab.example.com/shop/money.git
git@gitlab.example.com:shop/money.git
go находит адрес https://… через ?go-get=1, а git сам заменяет его на SSH. В CI вместо
ключа обычно дают токен: .netrc с machine gitlab.example.com читают и go, и git.
Подгруппы GitLab
Для gitlab.example.com/shop/backend/money go сначала спрашивает ?go-get=1, и без авторизации
GitLab не раскрывает, где кончается путь проекта. Помогает токен для самого go (.netrc или
GOAUTH) или суффикс .git в пути модуля: по нему go сразу понимает, где корень репозитория.
Что стоит запомнить
- Lightweight tag - прямая ссылка; annotated tag - отдельный tag object с метаданными и подписью.
- Псевдоверсия кодирует базовую версию, время и сокращённый commit hash.
- С v2 путь Go-модуля и import path включает /v2.
Частые ошибки
- Обычный push не отправляет все tags автоматически.
- Перестановка опубликованного тега создаёт разные ответы у прокси и кэшей.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Сравни типы тегов
Что вернут обе команды для lightweight и annotated тегов и где хранится сообщение аннотированного тега?
git cat-file -t v1.2.0
git cat-file -t v1.2.0-annotated
Ответ и разбор
Для lightweight tag команда покажет commit, потому что ссылка указывает прямо на коммит. Для annotated tag покажет tag: это отдельный объект с именем, сообщением, tagger и ссылкой на целевой объект.
Код-ревью
2. Не передвигай выпущенный tag
В опубликованном v1.2.0 нашли ошибку. Предлагают передвинуть тег на исправленный commit. Что делать потребителям и автору вместо этого?
git tag -f v1.2.0 fix
git push --force origin v1.2.0
Ответ и разбор
Потребители и proxy уже могли закэшировать старый объект под v1.2.0; передвинутый тег создаёт разные содержимые для одной версии. Выпусти новую версию, например v1.2.1, а старый тег не меняй. Если тег уже передвинут, объяви инцидент версии и восстанови согласованный immutable release.
Применение
3. Разбери псевдоверсию
В go.mod появилась v0.2.1-0.20260908090000-02e7b62003d7 после запроса ветки. Какие части она кодирует и почему это не имя ветки?
Ответ и разбор
Это псевдоверсия: базовая версия до следующего тега, timestamp commit и сокращённый commit hash. Она фиксирует конкретный commit и сортируется в порядке SemVer; ветка может продвинуться, поэтому модуль не записывает подвижное имя.
Применение
4. Подключи приватный Go-модуль
Go пытается отправить путь приватного модуля в публичный proxy и checksum database. Какие настройки задают область приватности и доступ к репозиторию?
Ответ и разбор
Настрой GOPRIVATE для шаблона приватных путей; по умолчанию он также задаёт исключения для GOPROXY и GOSUMDB. Отдельно настрой Git credentials или SSH для самого репозитория и проверь, что секрет не попадает в логи. GOINSECURE не нужен для обычного приватного HTTPS и ослабляет проверку транспорта.