#!/usr/bin/env bash set -euo pipefail # Загрузка и проверка контракта versions.env. # # versions.env отвечает на один вопрос: «какую версию продукта, на какой # платформе и каким toolchain мы воспроизводимо собираем». Всё остальное # (прикладные зависимости, конкретная версия Hysteria) живёт в собственных # lock-механизмах. # # Подход — проверка, а не генерация. Генерируемые исходники ломают чистый # чекаут: `bun test`, `tsc` и `go test` должны работать до запуска сборки. # Поэтому profile.ts / package.json / hy2xs.env остаются обычными файлами, # а verify_versions_contract роняет сборку при рассинхроне. VERSIONS_ENV_FILE="${VERSIONS_ENV_FILE:-$ROOT_DIR/versions.env}" REQUIRED_CONTRACT_VARS=( HY2XS_VERSION HY2XS_RELEASE_LINE HY2XS_CONFIG_SCHEMA_VERSION HY2XS_BUILD_OS HY2XS_BUILD_OS_VERSION HY2XS_BUILD_ARCH HY2XS_TARGET_OS HY2XS_TARGET_OS_VERSION HY2XS_TARGET_ARCH GO_VERSION GO_LINUX_AMD64_SHA256 BUN_VERSION BUN_LINUX_X64_SHA256 BUN_LINUX_X64_BASELINE_SHA256 NODE_VERSION NODE_LINUX_X64_SHA256 PNPM_VERSION HYSTERIA_CHANNEL GOVULNCHECK_VERSION PNPM_AUDIT_LEVEL ) validate_sha256_value() { local name="$1" local value="$2" printf '%s' "$value" | grep -Eq '^[a-f0-9]{64}$' \ || fail "versions.env: $name must be a 64-char lowercase hex SHA256, got: $value" } load_versions_contract() { [ -f "$VERSIONS_ENV_FILE" ] || fail "missing versions contract: $VERSIONS_ENV_FILE" # HYSTERIA_CHANNEL допускает override из окружения: офлайн-сборка из # закоммиченного lock не должна требовать правки versions.env. local preset_channel="${HYSTERIA_CHANNEL:-}" # shellcheck disable=SC1090 . "$VERSIONS_ENV_FILE" if [ -n "$preset_channel" ]; then HYSTERIA_CHANNEL="$preset_channel" fi local var value for var in "${REQUIRED_CONTRACT_VARS[@]}"; do value="${!var:-}" [ -n "$value" ] || fail "versions.env: missing required $var" done validate_sha256_value GO_LINUX_AMD64_SHA256 "$GO_LINUX_AMD64_SHA256" validate_sha256_value BUN_LINUX_X64_SHA256 "$BUN_LINUX_X64_SHA256" validate_sha256_value BUN_LINUX_X64_BASELINE_SHA256 "$BUN_LINUX_X64_BASELINE_SHA256" validate_sha256_value NODE_LINUX_X64_SHA256 "$NODE_LINUX_X64_SHA256" printf '%s' "$HY2XS_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ || fail "versions.env: HY2XS_VERSION must be X.Y.Z, got: $HY2XS_VERSION" printf '%s' "$HY2XS_RELEASE_LINE" | grep -Eq '^[0-9]+$' \ || fail "versions.env: HY2XS_RELEASE_LINE must be an integer, got: $HY2XS_RELEASE_LINE" printf '%s' "$HY2XS_CONFIG_SCHEMA_VERSION" | grep -Eq '^[0-9]+$' \ || fail "versions.env: HY2XS_CONFIG_SCHEMA_VERSION must be an integer" # HY2XS_TARGET_OS — это дистрибутив (debian), а GOOS/`target_os` в metadata # оперируют ядром. Производная величина, а не второй источник истины. case "$HY2XS_TARGET_OS" in debian) HY2XS_TARGET_GOOS=linux ;; *) fail "versions.env: unsupported HY2XS_TARGET_OS: $HY2XS_TARGET_OS" ;; esac export HY2XS_TARGET_GOOS # Мост к deps.sh: там больше нет собственных значений по умолчанию. GO_REQUIRED="$GO_VERSION" BUN_REQUIRED="$BUN_VERSION" NODE_REQUIRED="$NODE_VERSION" PNPM_REQUIRED="$PNPM_VERSION" GO_ARCHIVE_SHA256="$GO_LINUX_AMD64_SHA256" NODE_ARCHIVE_SHA256="$NODE_LINUX_X64_SHA256" export HY2XS_VERSION HY2XS_RELEASE_LINE HY2XS_CONFIG_SCHEMA_VERSION export HY2XS_BUILD_OS HY2XS_BUILD_OS_VERSION HY2XS_BUILD_ARCH export HY2XS_TARGET_OS HY2XS_TARGET_OS_VERSION HY2XS_TARGET_ARCH export GO_VERSION GO_LINUX_AMD64_SHA256 export BUN_VERSION BUN_LINUX_X64_SHA256 BUN_LINUX_X64_BASELINE_SHA256 export NODE_VERSION NODE_LINUX_X64_SHA256 PNPM_VERSION HYSTERIA_CHANNEL export GO_REQUIRED BUN_REQUIRED NODE_REQUIRED PNPM_REQUIRED export GO_ARCHIVE_SHA256 NODE_ARCHIVE_SHA256 export GOVULNCHECK_VERSION PNPM_AUDIT_LEVEL } # Ожидаемая контрольная сумма Bun зависит от выбранного артефакта: на CPU без # AVX2 скачивается bun-linux-x64-baseline, и одного digest'а не хватает. expected_bun_sha256() { case "${BUN_ARTIFACT:-}" in bun-linux-x64) printf '%s' "$BUN_LINUX_X64_SHA256" ;; bun-linux-x64-baseline) printf '%s' "$BUN_LINUX_X64_BASELINE_SHA256" ;; *) fail "expected_bun_sha256: unknown BUN_ARTIFACT: ${BUN_ARTIFACT:-}" ;; esac } read_json_string_field() { local file="$1" local field="$2" grep -Eo "\"${field}\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$file" \ | head -n1 \ | sed -E "s/.*:[[:space:]]*\"([^\"]*)\"/\1/" } read_env_field() { local file="$1" local key="$2" grep -E "^${key}=" "$file" | head -n1 | cut -d= -f2- } expect_equal() { local label="$1" local actual="$2" local expected="$3" [ "$actual" = "$expected" ] \ || fail "versions contract mismatch: $label is '$actual', versions.env declares '$expected'" } # Сравнение идёт с константами, СКОМПИЛИРОВАННЫМИ в оркестратор, а не с # текстом исходников: grep по файлу не доказывает, что в бинарь попало то же. verify_orchestrator_contract() { local output output="$(cd orchestrator && "$BUN_BIN" run tools/print-contract.ts)" \ || fail "versions contract: orchestrator contract dump failed" local line key value while IFS= read -r line; do [ -n "$line" ] || continue key="${line%%=*}" value="${line#*=}" case "$key" in HY2XS_CONFIG_SCHEMA_VERSION) expect_equal "orchestrator HY2XS_CONFIG_SCHEMA_VERSION" "$value" "$HY2XS_CONFIG_SCHEMA_VERSION" ;; HY2XS_RELEASE_LINE) expect_equal "orchestrator HY2XS_RELEASE_LINE" "$value" "$HY2XS_RELEASE_LINE" ;; HY2XS_TARGET_OS_VERSION) expect_equal "orchestrator target OS version" "$value" "$HY2XS_TARGET_OS_VERSION" ;; HY2XS_TARGET_ARCH) expect_equal "orchestrator target arch" "$value" "$HY2XS_TARGET_ARCH" ;; HY2XS_ADMIN_API_BASE) HY2XS_ADMIN_API_BASE="$value" ;; HY2XS_HYSTERIA_MACHINE_AUTH_PATH) HY2XS_HYSTERIA_MACHINE_AUTH_PATH="$value" ;; *) fail "versions contract: unexpected orchestrator contract line: $line" ;; esac done <<<"$output" [ -n "${HY2XS_ADMIN_API_BASE:-}" ] \ || fail "versions contract: orchestrator did not report HY2XS_ADMIN_API_BASE" [ -n "${HY2XS_HYSTERIA_MACHINE_AUTH_PATH:-}" ] \ || fail "versions contract: orchestrator did not report HY2XS_HYSTERIA_MACHINE_AUTH_PATH" export HY2XS_ADMIN_API_BASE HY2XS_HYSTERIA_MACHINE_AUTH_PATH } # API namespace — это runtime-контракт между тремя компонентами. # # Путь machine-auth уезжает в /etc/hysteria/config.yaml и в post-install.env, # то есть по нему Hysteria обращается к админке. Пока строка была размазана по # шаблонам, smoke, тестам и e2e, любое расхождение обнаруживалось только на # живом сервере. Здесь она сверяется во всех местах сразу — против константы, # скомпилированной в оркестратор. verify_api_namespace_contract() { local go_auth_path go_api_base go_auth_path="$(grep -Eo 'HysteriaMachineAuthPath[[:space:]]*=[[:space:]]*"[^"]*"' apps/model/constant/api.go \ | head -n1 | sed -E 's/.*"([^"]*)"/\1/')" go_api_base="$(grep -Eo 'AdminAPIBase[[:space:]]*=[[:space:]]*"[^"]*"' apps/model/constant/api.go \ | head -n1 | sed -E 's/.*"([^"]*)"/\1/')" expect_equal "apps constant.HysteriaMachineAuthPath" "$go_auth_path" "$HY2XS_HYSTERIA_MACHINE_AUTH_PATH" expect_equal "apps constant.AdminAPIBase" "$go_api_base" "$HY2XS_ADMIN_API_BASE" local frontend_base frontend_base="$(grep -Eo 'const API_BASE = "[^"]*"' apps/frontend/src/utils/request.ts \ | head -n1 | sed -E 's/.*"([^"]*)"/\1/')" expect_equal "frontend API_BASE" "$frontend_base" "$HY2XS_ADMIN_API_BASE" grep -qF "${HY2XS_HYSTERIA_MACHINE_AUTH_PATH}?access_token={{HYSTERIA_API_SECRET}}" \ package/templates/hysteria/config.yaml.tpl \ || fail "versions contract: hysteria template does not use ${HY2XS_HYSTERIA_MACHINE_AUTH_PATH}" grep -qF "${HY2XS_HYSTERIA_MACHINE_AUTH_PATH}?access_token={{HYSTERIA_API_SECRET}}" \ package/templates/env/post-install.env.tpl \ || fail "versions contract: post-install env template does not use ${HY2XS_HYSTERIA_MACHINE_AUTH_PATH}" # Старое пространство имён не имеет права вернуться ни в один компонент. # Историческое имя допустимо только в docs/14-legacy-cleanup.md и в # legacy-маркерах clean-host: там это имя чужого артефакта, а не наше. # # Сканируются ТОЛЬКО runtime production sources и то, что уезжает в пакет. # Файлы, задача которых — доказать отсутствие legacy-пространства, из скана # исключены: они обязаны называть его по имени, иначе проверять нечего. # # Регрессия приёмки. Раньше исключений не было, а в список каталогов входили # tools/build и orchestrator/test. Контракт ловил сам себя: grep возвращал # apps/router/router_test.go (регрессионный тест на отсутствие маршрута) и # этот самый файл, где строка '/hui' стоит в тексте проверки. Сборка падала # на шаге versions contract — то есть добавление теста, закрепляющего # очистку, гарантированно ломало сборку. # # Гарантия при этом не ослабла, а переехала на слой, где она сильнее: скан # исходников доказывает отсутствие СТРОКИ, а TestRouterHasNoLegacyNamespace # проверяет ТАБЛИЦУ МАРШРУТОВ собранного роутера. Поэтому существование # этого теста тоже входит в контракт и проверяется ниже. local legacy_hits legacy_hits="$(grep -rlF --exclude='*_test.go' '/hui' \ apps/model apps/router apps/controller apps/service apps/middleware \ apps/cmd apps/dao apps/util apps/frontend/src \ orchestrator/src package/templates tools/test 2>/dev/null || true)" [ -z "$legacy_hits" ] \ || fail "versions contract: legacy /hui namespace came back in: $legacy_hits" grep -q 'func TestRouterHasNoLegacyNamespace' apps/router/router_test.go \ || fail "versions contract: the legacy-namespace route test disappeared from apps/router/router_test.go" } verify_go_toolchain_contract() { local declared_go_line contract_minor go_minor declared_go_line="$(grep -E '^go[[:space:]]+[0-9]' apps/go.mod | head -n1 | awk '{print $2}')" [ -n "$declared_go_line" ] || fail "versions contract: apps/go.mod has no 'go' directive" contract_minor="$(printf '%s' "$GO_VERSION" | cut -d. -f2)" go_minor="$(printf '%s' "$declared_go_line" | cut -d. -f2)" [ "$(printf '%s' "$GO_VERSION" | cut -d. -f1)" = "$(printf '%s' "$declared_go_line" | cut -d. -f1)" ] \ || fail "versions contract: apps/go.mod major version $declared_go_line is incompatible with GO_VERSION $GO_VERSION" [ "$go_minor" -le "$contract_minor" ] \ || fail "versions contract: apps/go.mod requires go $declared_go_line, but GO_VERSION is $GO_VERSION" } verify_versions_contract() { log_step "Verifying versions contract (versions.env)" expect_equal "PACKAGE_VERSION" "${PACKAGE_VERSION:-}" "$HY2XS_VERSION" expect_equal \ "orchestrator/package.json packageManager" \ "$(read_json_string_field orchestrator/package.json packageManager)" \ "bun@$BUN_VERSION" expect_equal \ "apps/frontend/package.json packageManager" \ "$(read_json_string_field apps/frontend/package.json packageManager)" \ "pnpm@$PNPM_VERSION" expect_equal \ "package/config/hy2xs.env HY2XS_CONFIG_SCHEMA_VERSION" \ "$(read_env_field package/config/hy2xs.env HY2XS_CONFIG_SCHEMA_VERSION)" \ "$HY2XS_CONFIG_SCHEMA_VERSION" verify_orchestrator_contract verify_api_namespace_contract verify_go_toolchain_contract expect_equal "build host OS" "$HY2XS_BUILD_OS" "debian" expect_equal "build host arch" "$HY2XS_BUILD_ARCH" "amd64" expect_equal "target OS" "$HY2XS_TARGET_OS" "debian" expect_equal "target arch" "$HY2XS_TARGET_ARCH" "amd64" log_info "versions contract is consistent: HY2XS $HY2XS_VERSION (release line $HY2XS_RELEASE_LINE, config schema $HY2XS_CONFIG_SCHEMA_VERSION)" } # Проверяется уже собранный бинарь: это единственное доказательство, что # ldflags действительно доехали до артефакта. verify_admin_version_contract() { local binary="$1" local reported expected [ -x "$binary" ] || fail "versions contract: admin binary not found: $binary" expected="v$HY2XS_VERSION" reported="$("$binary" version 2>/dev/null | grep -Eo 'v[0-9]+\.[0-9]+\.[0-9]+' | head -n1 || true)" [ -n "$reported" ] || fail "versions contract: admin binary did not report a version" expect_equal "hy2xs-admin reported version" "$reported" "$expected" }