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:
av
2026-08-12 10:57:50 +03:00
parent aa20b229f9
commit 09228f23d8
20 changed files with 1963 additions and 11 deletions
+243
View File
@@ -0,0 +1,243 @@
#!/bin/sh
# Сверяет объявленную версию Go во всех местах, где она названа: требование
# модуля, сборочный образ, памятка и README. Сравниваются мажор и минор; патч и
# база сборочного образа свободны.
#
# Шаг судит по содержимому репозитория и только по нему: команда `go` не
# зовётся вовсе. Иначе исход зависел бы от установленного тулчейна и от
# GOTOOLCHAIN, а при непустом GOTOOLCHAIN `go` вправе уйти в сеть за нужной
# версией — то есть шаг перестал бы быть функцией коммита. Ровно эта подмена
# держала дефект 2026-08-12 невидимым: `go build ./...` шёл на хостовом Go и был
# зелёным, пока образ не собирался.
#
# Коды выхода — общий словарь проверочных шагов проекта (CLAUDE.md, «Гейт»):
# 0 сошлось, 1 расхождение, 2 ошибка употребления, 3 окружение.
set -eu
me=check-go-version
if [ "$#" -ne 0 ]; then
cat >&2 <<'EOF'
Использование: check-go-version.sh
Сверяет объявленную версию Go в go.mod, Dockerfile, CLAUDE.md и README.md.
Аргументов не принимает. Коды выхода: 0 сошлось, 1 расхождение,
2 ошибка употребления, 3 окружение.
EOF
exit 2
fi
# Корень считается от самого скрипта, а не от текущего каталога: иначе исход
# зависел бы от того, откуда шаг запустили.
#
# `CDPATH= ` — очистка переменной перед `cd`, а не забытый пробел в присваивании:
# непустой CDPATH увёл бы `cd` в чужой каталог. shellcheck читает это как SC1007,
# и подавление стоит здесь, чтобы заведение линтера потом стоило ровно одной
# строки шага.
# shellcheck disable=SC1007
root=$(CDPATH= cd -- "$(dirname -- "$0")/.." 2>/dev/null && pwd) || {
echo "$me: не удалось определить корень репозитория" >&2
exit 3
}
# Перечень мест закрыт и лежит здесь одним списком. Пятое место, о котором никто
# не знает, — это и есть тот дефект, против которого написан шаг, поэтому
# перечень не расползается по коду.
gomod=$root/go.mod
dockerfile=$root/Dockerfile
claudemd=$root/CLAUDE.md
readmemd=$root/README.md
# Нечитаемое место проверяется здесь, а не при разборе, и вот почему. Значения
# добываются подстановкой команд в аргументе, а она теряет код возврата; в
# `read_doc` вдобавок конвейер, чей статус берётся от последней команды. Отказ
# чтения дал бы пустой вход, и шаг сказал бы «README.md не называет версию» —
# то есть отправил бы чинить документ, в котором строка на месте, а сломаны
# права. Отказ окружения обязан звучать как отказ окружения.
for f in "$gomod" "$dockerfile" "$claudemd" "$readmemd"; do
if [ ! -f "$f" ]; then
echo "$me: нет файла ${f#"$root"/}" >&2
exit 3
fi
if [ ! -r "$f" ]; then
echo "$me: файл ${f#"$root"/} нечитаем" >&2
exit 3
fi
done
# Число непустых строк на входе.
count_lines() {
awk 'NF { n = n + 1 } END { print n + 0 }'
}
# Различные непустые значения на входе.
distinct() {
awk 'NF' | sort -u
}
# Директива `go` в go.mod: первые два числа.
read_gomod() {
sed -n 's/^go[[:space:]]\{1,\}\([0-9][0-9]*\.[0-9][0-9]*\).*$/\1/p' "$gomod"
}
# Все теги golang в Dockerfile: golang:<мажор>.<минор>[.<патч>][-<база>].
# Патч и база отбрасываются — спека объявила их свободными.
read_dockerfile() {
sed -n \
's/^[Ff][Rr][Oo][Mm][[:space:]].*golang:\([0-9][0-9]*\.[0-9][0-9]*\).*$/\1/p' \
"$dockerfile"
}
# Тело раздела документа. Раздел кончается следующим заголовком того же или более
# высокого уровня; заголовок внутри блока кода заголовком не считается, иначе
# пример в чужом разделе открыл бы «раздел стека» на пустом месте. Хвостовые
# пробелы в заголовке markdown не рендерит, поэтому и здесь они не значат ничего.
section() {
awk -v want="$2" '
/^```/ { fence = !fence; if (inside) print; next }
!fence && (/^# / || /^## /) {
line = $0
sub(/[[:space:]]+$/, "", line)
inside = (line == want)
next
}
inside { print }
' "$1"
}
# Есть ли в документе раздел с таким заголовком. Правила те же, что у section:
# иначе «раздел есть» и «раздел читается» разошлись бы на первом же примере.
has_section() {
awk -v want="$2" '
/^```/ { fence = !fence; next }
!fence && (/^# / || /^## /) {
line = $0
sub(/[[:space:]]+$/, "", line)
if (line == want) { found = 1 }
}
END { exit(found ? 0 : 1) }
' "$1"
}
# Все вхождения образца `Go <мажор>.<минор>` в разделе стека документа.
#
# Читается именно раздел, а не файл целиком. `CLAUDE.md` по устройству ведёт
# историю закрытых долгов, и правдивая строка о прошлой версии уронила бы шаг
# сообщением «называет версию больше одного раза» — то есть послала бы чинить
# исторический документ вместо проверки. Тем же ловился бы любой пример команды
# в README.
#
# Первый sed ставит каждое вхождение на свою строку: два числа в одной строке
# иначе слились бы в одно, и второе, протухшее, осталось бы невидимым.
read_doc() {
section "$1" "$2" | sed -e 's/Go [0-9][0-9]*\.[0-9][0-9]*/\
&\
/g' | sed -n 's/^Go \([0-9][0-9]*\.[0-9][0-9]*\)$/\1/p'
}
failed=0
report=''
collected=''
fail() {
echo "$me: $1" >&2
failed=1
}
add_report() {
line=$(printf ' %-11s %s' "$1" "$2")
report="$report$line
"
}
# Добывает число из места и кладёт его в `collected`; пусто — значит не добыто.
# Третий аргумент: 1 — место обязано называть версию ровно один раз.
#
# Функция не зовётся в подстановке команд намеренно: подоболочка потеряла бы и
# флаг отказа, и отчёт.
collect() {
place=$1
values=$2
strict=$3
collected=''
total=$(printf '%s\n' "$values" | count_lines)
uniq=$(printf '%s\n' "$values" | distinct)
uniq_total=$(printf '%s\n' "$uniq" | count_lines)
if [ "$total" -eq 0 ]; then
add_report "$place" 'версия не названа'
fail "$place не называет версию Go"
return 0
fi
if [ "$uniq_total" -gt 1 ]; then
add_report "$place" "$(printf '%s' "$uniq" | tr '\n' '/') — разные числа"
fail "$place называет несколько разных версий"
return 0
fi
if [ "$strict" -eq 1 ] && [ "$total" -gt 1 ]; then
add_report "$place" "$uniq — названа $total раз(а)"
fail "$place называет версию больше одного раза"
return 0
fi
collected=$uniq
add_report "$place" "$uniq"
}
# Директива toolchain — пятое место, которого перечень не знает: четыре числа
# сойдутся, а собирать будет пятое.
if grep '^toolchain[[:space:]]' "$gomod" >/dev/null 2>&1; then
fail 'go.mod содержит директиву toolchain — она называет версию пятым местом'
fi
# Раздел, в котором документ называет версию. За его пределами число не читается.
claude_section='## Стек'
readme_section='## Технологии'
# Пропавший раздел — отдельный исход: он говорит «искать негде», а не «версия не
# названа», и чинится другим движением.
collect_doc() {
place=$1
path=$2
heading=$3
collected=''
if ! has_section "$path" "$heading"; then
add_report "$place" "нет раздела «$heading»"
fail "в $place нет раздела «$heading», где называется версия"
return 0
fi
collect "$place" "$(read_doc "$path" "$heading")" 1
}
# Документы называют версию прозой, и второе вхождение в разделе стека — не
# дубликат, а второе утверждение: обновят одно, второе протухнет молча. У
# Dockerfile иначе: каждый сборочный слой — настоящий вход сборки, и от них
# требуется совпадение, а не единственность.
collect go.mod "$(read_gomod)" 1
v_gomod=$collected
collect Dockerfile "$(read_dockerfile)" 0
v_docker=$collected
collect_doc CLAUDE.md "$claudemd" "$claude_section"
v_claude=$collected
collect_doc README.md "$readmemd" "$readme_section"
v_readme=$collected
if [ "$failed" -eq 0 ]; then
found=$(printf '%s\n%s\n%s\n%s\n' \
"$v_gomod" "$v_docker" "$v_claude" "$v_readme" | distinct | count_lines)
if [ "$found" -ne 1 ]; then
fail 'объявленные версии Go разошлись'
fi
fi
if [ "$failed" -ne 0 ]; then
printf 'Объявленная версия Go по местам:\n%s' "$report" >&2
exit 1
fi
exit 0