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

231 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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.mod``Dockerfile` парная сверка
тогда поймала бы — та пара как раз разошлась. Чего она не ловит, так это
документа, разошедшегося с **согласованным** кодом: сойдись тогда `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` руками; сверка ловит класс расхождений, а не все отказы сборки.