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 и перестал собираться, а восемь шагов гейта и шесть проходов ревью были зелёными
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
## 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` руками; сверка ловит класс расхождений, а не все отказы сборки.
|
||||
Reference in New Issue
Block a user