- шаг 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 и перестал собираться, а восемь шагов гейта и шесть проходов ревью были зелёными
244 lines
11 KiB
Bash
Executable File
244 lines
11 KiB
Bash
Executable File
#!/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
|