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:
Executable
+243
@@ -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
|
||||
Reference in New Issue
Block a user