Files
transcriber/scripts/check-go-version.sh
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

244 lines
11 KiB
Bash
Executable File
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.
#!/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