Files
transcriber/openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md
T
av 09228f23d8 Go обновлён до 1.26, а расхождение версий теперь роняет гейт
- шаг go-version в task gate сверяет объявленную версию в go.mod, Dockerfile,
  CLAUDE.md и README.md; судит по репозиторию, go не зовёт, docker и сети не
  требует
- заведена capability toolchain: до сих пор спеки нормировали только поведение
  сервиса, теперь и инструмент сборки. Причина и цена — в двух ADR
- закрыт дефект 2026-08-12: образ на golang:1.24-alpine разошёлся с go.mod и
  перестал собираться, а восемь шагов гейта и шесть проходов ревью были зелёными
2026-08-12 10:57:50 +03:00

20 KiB
Raw Blame History

Context

Версия Go названа в проекте четырежды, и сегодня четыре места расходятся: go.mod требует go 1.25.0, Dockerfile собирает на golang:1.25-alpine, CLAUDE.md обещает «Go 1.25», а README.md не называет версию вовсе. На машине разработки стоит go1.26.5.

Число 1.25 никем не назначалось: go mod tidy поднял требование модуля, следуя за PocketBase, а образ подтянули следом. Ровно этот же механизм 2026-08-12 породил дефект — требование модуля уехало на 1.25, Dockerfile остался на golang:1.24-alpine с GOTOOLCHAIN=local, и образ перестал собираться. Восемь шагов набора проверок и шесть проходов ревью показали зелёное: go build ./... идёт на хостовом Go, а образ не собирает ни один шаг. Случай записан в docs/review.md за 2026-08-12 и там же назван способ починки — сравнение строк вместо сборки образа.

Ограничения, в которых работаем: набор проверок обязан оставаться дешёвым и работать без сети и без docker; выкладку это изменение не запускает; сборку образа в набор проверок не заводим — отказ записан.

Goals / Non-Goals

Goals:

  • одно число версии Go во всех четырёх местах;
  • шаг набора проверок, который краснеет на расхождении и называет оба числа;
  • шаг стоит доли секунды и не зависит ни от docker, ни от сети.

Non-Goals:

  • сборка образа шагом набора проверок — дорого, отказ записан в docs/review.md;
  • проверка того, что объявленная версия вообще существует в реестре образов, — это требует сети;
  • сверка версий прочих инструментов (golangci-lint, task, ffmpeg) — их расхождение так не ломает, и заводить перечень впрок незачем;
  • shellcheck шагом набора проверок. Замер: shell-скриптов в гейте один, три соседних шага — Python в плагинах, линтер к ним неприменим; цена шага — одна строка плюс подавление ложного SC1007 на идиому CDPATH= cd. Отказ всё равно осознанный, но цену называем настоящую, а не «их четыре»;
  • автоматическая правка разошедшихся мест — шаг набора проверок судит, а не чинит.

Decisions

Решение 1: версия 1.26, а число выбирает человек

Берём 1.26 — она стоит на машине разработки (go1.26.5), образ golang:1.26-alpine в реестре есть, последний релиз тоже go1.26.5, а CGO_ENABLED=0 go build ./... на ней уже проходит. Требование PocketBase v0.39.10 (go 1.25.0) она выполняет.

В go.mod пишем go 1.26.0, а не 1.26.5: требование модуля — это нижняя граница, и привязывать её к патчу значит без нужды отсекать сборку на более раннем патче той же минорной версии.

Число называет человек, и нормой это не записано намеренно. go mod tidy поднимает требование модуля сам, следуя за зависимостью, и подъём, никем не назначенный, дал сегодняшнее расхождение. Но «выбрал человек» ненаблюдаемо: директива, поднятая инструментом, и директива, назначенная решением, выглядят одинаково, а норма, которую нечем уронить, расходится с кодом молча. Поэтому здесь мотив, а в спеке — то, что проверяется: сборка и тесты на объявленном числе до мерджа.

Отвергнуто: остаться на 1.25 и завести только сверку. Сверка — половина задачи, и она бы прижилась; но тогда сегодняшнее число остаётся тем, которое никто не назначал, и первый же go mod tidy следующей зависимости повторит подъём вслепую. Задача собрана из двух половин именно поэтому.

Отвергнуто: toolchain в go.mod вместо подъёма go. Директива toolchain заставила бы Go скачивать нужный тулчейн сам, и расхождение с образом перестало бы ломать сборку. Но она же превращает сборку образа в сетевую операцию, а сборочный слой качает тулчейн при каждой сборке. Дороже и менее предсказуемо, чем строка сравнения.

Решение 2: новая capability toolchain

Дельта-спека ложится в новую capability toolchain — «каким инструментом и какой его версии собирается сервис, и что об этом проверяется до выкладки».

Отвергнуто: дописать в pipeline. pipeline нормирует прогон воркера и захват задачи — поведение работающего сервиса. Версия сборщика с ним не меняется вместе, а правило гранулярности в openspec/config.yaml именно про это: «дробить, когда в одной спеке смешиваются разные заботы».

Отвергнуто: обойтись без дельта-спеки. Изменение вводит проверяемое требование — «расхождение роняет набор проверок», — и требование без дома проверяется только памятью того, кто его завёл. Обещание «образ собирается» уже один раз жило в трёх документах и во всех трёх было неверным.

Отвергнуто имя build. Первая редакция называла capability build, и на разметке выяснилось, что читать её нельзя: настройка среды разработчика запрещает чтение любого каталога с этим именем. Спека, недоступная проходам ревью, не проверяется ни одним из них, а после архивации осталась бы слепым пятном насовсем. Имя toolchain точнее и по существу: предмет здесь — инструмент сборки и его версия, а не сборка как процесс.

Признаём натяжение: три существующие capability описывают поведение сервиса для его потребителей, а toolchain описывает поведение инструмента разработки. Потребитель у него другой — тот, кто собирает сервис. Правило config.yaml говорит «поведение или домен системы»; инструмент сборки — домен, и именно как домен он здесь и назван. Если capability так и останется с одним требованием, дешевле будет переименовать её, чем расщепить (RENAMED Requirements).

Решение 3: шаг сверяет все четыре места, а не два

Минимум по критерию приёмки — go.mod против Dockerfile. Берём шире: плюс CLAUDE.md и README.md.

Причина прямо из дефекта 2026-08-12: три места из четырёх говорили одно и то же, и неверными были именно они. Пару go.modDockerfile парная сверка тогда поймала бы — та пара как раз разошлась. Чего она не ловит, так это документа, разошедшегося с согласованным кодом: сойдись тогда go.mod с образом на 1.24, и памятка с README продолжали бы врать молча, а гейт оставался бы зелёным. Сегодня проект ровно в этом состоянии наполовину: README.md не называет версию вовсе, а CLAUDE.md проверяется только тем, что кто-то её прочтёт.

Цена: строку о версии придётся держать в форме, которую находит машина. Это же и польза — документ, чья строка перестала находиться, краснеет вместо того, чтобы молча протухнуть.

Отвергнуто: сверять только go.mod и Dockerfile. Дешевле на три строки скрипта и не ловит половину прошлого дефекта.

Отвергнуто: сверять ещё и config.dist.toml, docker/entrypoint.sh и Ansible-роль в pet-project-server. Версии Go там нет; чужой репозиторий этому набору проверок недоступен.

Решение 4: отдельный скрипт в репозитории, а не строка в Taskfile.yml

Шаг живёт файлом scripts/check-go-version.sh, а Taskfile.yml его зовёт.

Сверка четырёх мест — это четыре разных способа достать число (директива модуля, тег образа, проза памятки, проза README), сравнение и внятное сообщение со всеми четырьмя. В Taskfile.yml это легло бы двадцатью строками shell внутри YAML, где их не читает ни редактор, ни shellcheck, а кавычки экранируются дважды. Соседние шаги (docs, tasks, openspec) уже зовут скрипты, и эта форма для набора проверок родная.

Скрипт лежит в репозитории, а не в плагине: он про этот проект, а не про метод работы.

Оговорка о выигрыше: shellcheck шагом набора проверок этим изменением не заводится, и обоснование выше стоит на том, что файл хотя бы можно проверить и прочитать глазами, а не на том, что его кто-то проверяет машиной. Заводить линтер оболочки — отдельная работа, и она уезжает урожаем.

Отвергнуто: строка shell прямо в Taskfile.yml. Дешевле на один файл, дороже при первом же изменении: правка регулярного выражения в YAML-скаляре ошибается молча.

Отвергнуто: написать проверку на Go отдельной командой. Тогда она попадает в go build ./... и go vet ./..., а вместе с ней — разбор Dockerfile в коде сервиса. Проверка о проекте не должна ехать в бинарник сервиса.

Решение 5: форма строки, которую ищет машина

Место Что ищем Правило множественности
go.mod единственная строка, начинающаяся с go — первые два числа директива toolchain запрещена: она пятое место
Dockerfile тег golang:<мажор>.<минор>[.<патч>][-<база>], берём первые два числа все вхождения FROM golang: обязаны давать одно число
CLAUDE.md образец Go <мажор>.<минор> в разделе ## Стек ровно одно вхождение в разделе; вне раздела число не читается
README.md образец Go <мажор>.<минор> в разделе ## Технологии ровно одно вхождение в разделе; вне раздела число не читается

Патч и база образа из тега отбрасываются: спека объявила их свободными, и образец обязан это допускать — иначе golang:1.26.5-alpine уронил бы набор проверок на дереве, которое та же спека называет верным.

Правило множественности заведено не впрок: реализация, молча берущая первое совпадение, судила бы по обновлённой строке и не видела протухшей соседней.

Рамка у него — раздел, а не файл, и это правка по находке ревью. Первая редакция считала вхождения по всему файлу, и на памятке это давало гарантированный ложный красный: она по устройству ведёт историю закрытых долгов («Два прежних долга закрыты и здесь названы»), и первая же правдивая строка о прошлой версии уронила бы шаг — сообщением, которое толкает чинить не шаг, а исторический документ. Тем же ловился бы любой пример команды в README. Решение человека на чекпоинте: считать по разделу стека, за его пределами число не читать.

Граница раздела при этом определена явно — следующий заголовок того же или более высокого уровня, и заголовок внутри блока кода за заголовок не считается. Неопределённая граница давала бы ложное зелёное: пример в чужом разделе открывал бы раздел стека на пустом месте, и число доставалось бы оттуда, откуда его читать не велено.

Число не нашлось — это отказ с названным местом, а не «нечего сравнивать»: пропавшая строка иначе выглядела бы как совпадение.

Решение 6: шаг судит по репозиторию, а не по машине

Число берётся чтением файлов. go шаг не зовёт вовсе — ни go mod edit -json, ни go list -m, ни go env.

Способ это не самый удобный: разбор директивы через go mod edit -json короче и надёжнее регулярного выражения. Он же и опасный: вызов go тянет за собой GOTOOLCHAIN, $PATH и установленный тулчейн, а при непустом GOTOOLCHAIN go вправе полезть в сеть за нужной версией — то есть требование «без сети» перестало бы выполняться. Хуже того, исход шага стал бы зависеть от машины, а не от коммита, — ровно та подмена, которая держала дефект 2026-08-12 невидимым: go build ./... шёл на хостовом Go и потому был зелёным, пока образ не собирался.

Отсюда же и словарь кодов выхода: 0 сошлось, 1 расхождение, 2 ошибка употребления, 3 окружение. Свой словарь заводить нельзя — раздел «Гейт» в CLAUDE.md объявляет его общим для проверочных шагов, и четвёртый шаг с собственной семантикой сделал бы это утверждение неверным.

Оболочка — POSIX sh, без GNU-only флагов (grep -P, sed -E с расширениями, mapfile). Пути шаг строит от корня репозитория, а не от текущего каталога: иначе его исход зависел бы от того, откуда он запущен.

Risks / Trade-offs

  • Скрипт ищет число прозой документа, и переписанная строка сломает шаг → сообщение отказа называет место, где число не нашлось, поэтому чинится однозначно и сразу. Ложное зелёное здесь невозможно по построению: не нашлось — отказ.
  • Четыре места вместо двух — четыре места, которые надо править при подъёме версии → это цена решения 3, и она осознанная: молчаливо врущий документ дороже одной лишней правки.
  • 1.26 может оказаться несовместимой с зависимостью, которую мы ещё не трогали → проверяется до мерджа: task image собирает образ на объявленной версии, а go test ./... идёт на хостовом go1.26.5. Обе проверки в критериях приёмки.
  • Шаг проверяет согласованность чисел, но не то, что образ собирается → осознанный остаток. Собранный образ по-прежнему видит только тот, кто позвал task image руками; сверка ловит класс расхождений, а не все отказы сборки.