voidlearn
 

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-tagsannotated, которых нет на сервере, если их коммит уходит на сервер или уже там
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.ZvX.Y.(Z+1)-0.время-хешv0.1.1-0.20260908090000-02e7b62003d7
пререлиз vX.Y.Z-prevX.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. Старая запись остаётся рабочей.

коммит и тег что go запишет в go.mod 45e26ec 02e7b62 006f72e e2926f0 main v0.2.0-rc.1 v0.1.0 v0.2.0-rc.1.0.20260909060000-45e26ec1c418 пререлиз-предок: к нему дописано .0. v0.2.0-rc.1 до тега: v0.1.1-0.20260908090000-02e7b62003d7, эта запись в go.mod Ани остаётся рабочей v0.1.0 v0.0.0-20260906150000-e2926f05527f тегов среди предков нет: база v0.0.0 v0.2.0-rc.1.0. 20260909060000 -45e26ec1c418 база: старший тег-предок время коммиттера в UTC первые 12 знаков хеша
Номер для каждого коммита. Коммиту с тегом go отдаёт версию из тега, остальным собирает псевдоверсию от старшего тега среди предков. Когда на коммит ставят тег, новые запросы получают версию, а записанная раньше псевдоверсия продолжает работать.
База: старший тег, а не ближайший

На копии в 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, который идёт мимо прокси, и его хеш разойдётся со всеми.

git.localhost: money.git 45e26ec 5f5390b знак исправлен v0.2.0 было 9 сентября стало 10 сентября git push --force origin v0.2.0 go.sum сервиса shop v0.2.0 h1:dd4y… (архив 45e26ec) кэш модулей Ани v0.2.0 уже в кэше, go в git не идёт: тест падает по-старому чистый кэш, напрямую в git архив 5f5390b, h1:gtY1… не равен go.sum: SECURITY ERROR CI с чистым кэшем через прокси прокси отдаёт архив 45e26ec из своего кэша: тест падает по-старому
Один номер, три результата. Тег переставлен на исправленный коммит, но хеш старого архива уже записан в 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.

go.mod без /v3 откатывает @latest назад

Боря добавил в 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 без запроса.

Вопросы

4
Суть: lightweight тег просто ссылка на коммит. Annotated тег ведёт к отдельному объекту с автором, датой, сообщением и, по желанию, подписью. Релиз метят annotated. git 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^{}

Как теги попадают на сервер

Командаlightweightannotated
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 ничего не качает
чистый кэш, напрямую из gitverifying …@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 и ослабляет проверку транспорта.