#!/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