# Build layer and package ## Цель документа Зафиксировать локальный слой сборки и формат итогового install package. ## Базовое решение В baseline builder остаётся **shell-first** для packaging-слоя. То есть: - основной packaging pipeline — **sh/bash** - оркестратор при этом пишется на **Bun + TypeScript** - builder локально компилирует оркестратор в готовый install-артефакт - target machine не должна сама собирать или доустанавливать JS/TS toolchain Причина простая: packaging можно держать простым, а оркестратор — typed и модульным. ## Где работает builder Production builder работает на отдельном build host: - Debian 13 - amd64 / x86_64 - bash - доступ к интернету для apt и скачивания toolchain В текущей production-модели сборка выполняется **на Debian 13 amd64**, а не на Windows/macOS dev-машине. Builder не является частью target install flow: на target server приезжает уже готовый install package, без JS/TS/Go build step. ## Что хранится в репозитории проекта Минимум: - исходники оркестратора на **Bun + TypeScript** - shell packaging scripts - шаблоны конфигов - systemd unit templates - docs - исходный код **HY2XS admin** - шаблоны для `post-install.env` - package metadata ## Контракт версий: `versions.env` Корневой `versions.env` — **единственный источник истины** для контракта «продукт / платформа / toolchain». Что в нём есть: ```bash HY2XS_VERSION=1.0.0 HY2XS_RELEASE_LINE=1 HY2XS_CONFIG_SCHEMA_VERSION=2 HY2XS_BUILD_OS=debian HY2XS_BUILD_OS_VERSION=13 HY2XS_BUILD_ARCH=amd64 HY2XS_TARGET_OS=debian HY2XS_TARGET_OS_VERSION=13 HY2XS_TARGET_ARCH=amd64 GO_VERSION=1.26.7 GO_LINUX_AMD64_SHA256= BUN_VERSION=1.3.13 BUN_LINUX_X64_SHA256= BUN_LINUX_X64_BASELINE_SHA256= NODE_VERSION=24.20.0 NODE_LINUX_X64_SHA256= PNPM_VERSION=9.15.9 HYSTERIA_CHANNEL=stable GOVULNCHECK_VERSION=v1.7.0 PNPM_AUDIT_LEVEL=high ``` Чего в нём **нет** и быть не должно: 1. **Прикладных зависимостей** (Vue, Gin, GORM, npm/Go модули). У них уже есть канонические lock-механизмы: `apps/frontend/pnpm-lock.yaml`, `orchestrator/bun.lock`, `apps/go.sum`. Второй слой неизбежно разъедется с настоящим графом зависимостей. 2. **Конкретной версии Hysteria.** Здесь живёт только *политика* выбора (`HYSTERIA_CHANNEL`); результат резолва замораживается в `tools/build/hysteria-lock.env`. Пин версии здесь вернул бы ручное обновление, от которого мы ушли. Два Bun-артефакта зафиксированы отдельно намеренно: `select_bun_artifact()` выбирает `bun-linux-x64` или `bun-linux-x64-baseline` по наличию AVX2, поэтому одной контрольной суммы архитектурно недостаточно. ### Почему версии toolchain — это вопрос безопасности, а не удобства Go здесь не просто сборщик: им компилируется `hy2xs-admin`, и его stdlib целиком попадает в production-бинарь. Поэтому версия выбирается по политике поддержки Go (major поддерживается, пока не вышли две более новые), а не по тому, на чём собиралось раньше. Цифры, ради которых это записано. На `GO_VERSION=1.21.13` — линия, давно вне поддержки — `govulncheck ./...` находил **21 вызываемую уязвимость**, из них 17 в одной только stdlib. После перехода на 1.26.7 и обновления графа зависимостей — **ноль**. Node живёт только на build-хосте и в артефакт не попадает, но 20.x достигла EOL, то есть перестала получать security-обновления, а собирает она код, который уезжает в production. Отсюда LTS-линия 24. Bun обновляется отдельно от остальных: оркестратор собирается через `bun build --compile`, то есть Bun runtime физически входит в исполняемый файл. Смена его minor-версии — это смена рантайма внутри артефакта, и она требует полного прохода `bun test → tsc → compile → приёмка на Debian`, а не строки в общем патче. ### Проверка типов frontend — обязательный шаг релиза ```text pnpm run typecheck → vue-tsc --noEmit → ОБЯЗАН пройти pnpm run build:prod → vite build pnpm run verify → typecheck, затем build ``` `bundle_ui()` запускает `typecheck` **до** сборки bundle: собирать production bundle из кода, который не проходит проверку типов, незачем. Порядок и сам факт наличия шага проверяются приёмкой. До v1 этой гарантии не было. `build:prod` означал `vite build && vue-tsc --noEmit`, но `vue-tsc` был версии `0.35.0` (2022 год) и шаблоны Vue практически не типизировал: проверка проходила зелёной, не давая гарантии, которую обещает. Хуже того — на Vue 3.5 она ломается сама, потому что не знает `vue/jsx-runtime`, то есть пережить обновление Vue всё равно не могла. Современный `vue-tsc 3.3` на том же коде дал **142 ошибки**: 141 × `TS18048` («possibly undefined» при обращении к необязательным секциям конфига Hysteria в шаблоне) и одна `TS2322`, всё в двух файлах представления Hysteria. Ожидавшегося класса «`DefaultRow` несовместим с `PeerVo`» на Element Plus 2.3 не было вовсе — он появился позже, вместе с обновлением Element Plus до 2.14, где слоты таблицы типизированы строже. Закрыто это не подавлением, а границей: `api/config/hysteriaViewModel.ts` превращает ответ сервера в модель, где присутствие каждой секции — свойство типа. Подробности — в [docs/04](04-admin-panel.md). Контракт теперь читается так: > проверка типов SFC-шаблонов проходит, и это доказывает сборка, а не намерение. ### Проверка зависимостей на уязвимости `tools/build/lib/security.sh` — обязательный шаг сборки между тестами админки и записью metadata: | Проверка | Что покрывает | Порог | | --- | --- | --- | | `govulncheck ./...` | Go-граф **и stdlib**, с анализом достижимости: уязвимость считается только при наличии пути вызова из нашего кода | любая вызываемая | | `pnpm audit` | **весь** lock-граф frontend, включая build tooling, без анализа достижимости | `PNPM_AUDIT_LEVEL` | Про «весь граф» отдельно, потому что здесь стояло `--prod` с обоснованием «devDependencies в артефакт не попадают». Для frontend build tooling это обоснование неверно по существу. `vite` и `rollup` действительно не копируются на production-сервер как `node_modules`. Но они **исполняются на build-машине, читают наши исходники и порождают тот самый production-бандл**, который уезжает в артефакт. Уязвимость в них — это уязвимость в том, что мы выпускаем. Это не гипотеза: DOM clobbering в Rollup затрагивал именно генерируемый бандл, а проверка по одному production-подграфу его не показывала. По всему графу тот же прогон дал 33 предупреждения против нуля. Версия `govulncheck` пиньтся в `versions.env`, а база уязвимостей подтягивается на каждом запуске: пин инструмента не должен превращаться в пин знаний о мире. Аварийного выхода у шага **нет**, и это отличает его от `ALLOW_DIRTY_BUILD`. Результат уезжает в `metadata/package.env` полем `dependency_security_gate`, которое принимает единственное значение `true`: по готовому tarball видно, что он проверялся, потому что непроверенного tarball не бывает. Две переменные обхода здесь существовали и были описаны как способ выпустить релиз, зная об уязвимости. Способом они не были: финальная приёмка архива требует буквально `dependency_security_gate=true`, поэтому сборка с любой из них доходила до конца — компиляция, бандл, тесты, метаданные, tar — и падала на последнем шаге. Продукт документировал операцию, которую сам же запрещал. Противоречие закрыто в пользу строгой политики; отсутствие обходов проверяется приёмкой, а не только описано здесь. Контракт читается однозначно: > релизный артефакт HY2XS невозможно собрать с непройденной проверкой > зависимостей. Новое advisory чинится обновлением графа (`apps/go.sum`, `apps/frontend/pnpm-lock.yaml`) или версии toolchain в `versions.env`. Для локальной работы обходить нечего: `go test ./...`, `govulncheck ./...` и `pnpm audit` запускаются напрямую и tarball не создают. ### Тесты и типы Та же политика и по той же причине. Аварийного выхода у этого шага **нет**: переменной, отключающей тесты, не существует. Проверяется на трёх участках: | Шаг сборки | Что запускается | | --- | --- | | `run_orchestrator_tests` | `bun x tsc --noEmit`, `bun test` | | `bundle_ui` | `pnpm run typecheck` (`vue-tsc --noEmit`) до сборки bundle | | `run_admin_tests` | `go vet ./...`, `go test ./...` | Готовый пакет объявляет об этом полем `tests_gate=true` в `metadata/package.env` — так же, как `dependency_security_gate` и `hysteria_compat_gate`. Значение у поля ровно одно, потому что не бывает пакета, собранного с пропущенными тестами: обе функции прогона выставляют свой флаг **после** успешного завершения, а `write_metadata` отказывается писать метаданные, если хотя бы один из них не выставлен. То есть поле остаётся утверждением о результате, а не переключателем. Здесь существовала переменная, описанная как «аварийное отключение тестов; для release-сборок недопустимо». Недопустимость держалась исключительно на этой фразе: ни metadata, ни финальная приёмка архива не проверяли, что тесты запускались, поэтому сборка с ней доходила до конца и выдавала внешне неотличимый production-tarball. Глушила она при этом не только тесты, но и `tsc --noEmit` с `go vet` — то есть проверку типов и статический анализ того самого кода, который уезжает в production. История — в `CHANGELOG.md`. ### Проверка, а не генерация `profile.ts`, `package/config/hy2xs.env` и `packageManager` в двух `package.json` остаются обычными файлами. Сборка их **не генерирует**, а сверяет шагом `verify_versions_contract`. Причина: генерируемые исходники ломают чистый чекаут — `bun test`, `tsc` и `go test` должны работать до запуска сборки. Проверка даёт тот же инвариант дешевле. `verify_versions_contract` сверяет: | Что | С чем | | --- | --- | | `PACKAGE_VERSION` | `HY2XS_VERSION` | | `orchestrator/package.json` → `packageManager` | `bun@$BUN_VERSION` | | `apps/frontend/package.json` → `packageManager` | `pnpm@$PNPM_VERSION` | | `package/config/hy2xs.env` → схема | `HY2XS_CONFIG_SCHEMA_VERSION` | | константы, **скомпилированные** в оркестратор | схема, release line, целевая платформа, API namespace | | `constant.AdminAPIBase` / `constant.HysteriaMachineAuthPath` (Go) | константы оркестратора | | `API_BASE` фронтенда | `constant.AdminAPIBase` | | шаблоны Hysteria и `post-install.env` | `HYSTERIA_MACHINE_AUTH_PATH` | | `apps/go.mod` → директива `go` | `GO_VERSION` | | `metadata/package.env` | версия, release line, схема, target | | `hy2xs-admin version` (готовый бинарь) | `v$HY2XS_VERSION` | Контракт оркестратора сверяется не grep'ом по исходникам, а выводом `orchestrator/tools/print-contract.ts`: это доказывает, что в бинарь попало то же значение. API namespace попал в этот список не для красоты. Путь machine-auth записывается в `/etc/hysteria/config.yaml` и в `post-install.env`, то есть по нему Hysteria обращается к админке. Пока строка была продублирована в шаблонах, smoke, тестах, приёмке и e2e, расхождение обнаруживалось только на живом сервере. ### Версии в `package.json` — не версия продукта `orchestrator/package.json` объявляет `version: 0.1.0`, а `apps/frontend/package.json` — `version: 0.0.0`. Это placeholder'ы приватных пакетов, которые никуда не публикуются; единственная версия продукта живёт в `versions.env` (`HY2XS_VERSION`) и оттуда доезжает до `metadata/package.env`, install-state и бинарника админки. Ни одно из этих двух чисел не участвует в контракте версий и не должно восприниматься как release version. Версия админки приходит в бинарь через ldflags: ```bash -ldflags "-s -w -X 'hy2xs-admin/model/constant.Version=v${HY2XS_VERSION}'" ``` Собственной константы версии в Go-коде больше нет: она уже успела разъехаться с версией пакета. ## Что делает builder 1. Проверяет структуру проекта. 2. Загружает и проверяет контракт `versions.env`. 3. Прогоняет тесты и типы оркестратора. 4. Разрешает upstream-версию Hysteria и проходит compatibility gate. 5. Компилирует оркестратор из Bun/TypeScript в install-артефакт. 6. Собирает / подготавливает HY2XS admin и сверяет его версию с контрактом. 7. Прогоняет тесты HY2XS admin (после сборки frontend: `go:embed all:dist` требует готовых ассетов). 8. Копирует артефакты UI в package staging directory. 9. Кладёт entrypoint, templates, docs и service files. 10. Формирует итоговый install package. 11. Считает manifest/checksum. 12. Проверяет архив и прогоняет acceptance-проверки. 13. Выдаёт один переносимый результат для target machine. ## Что builder не делает - не ставит Hysteria2 на локальной машине «для продакшена» - не превращается в CI/CD платформу - не генерирует update pipeline - не делает uninstall manifests - не готовит миграции между старыми инсталляциями ## Рекомендуемая структура ```text project/ ├── tools/ │ └── build/ │ ├── build.sh │ ├── README.md │ └── lib/ ├── orchestrator/ │ ├── package.json │ ├── bun.lock │ ├── tsconfig.json │ └── src/ ├── package/ │ ├── install.sh │ ├── orchestrator/ │ ├── templates/ │ └── systemd/ ├── ui/ │ └── hy2xs-admin/ ├── docs/ └── dist/ ``` ## Формат итогового пакета Итоговый пакет должен содержать: - install-only orchestrator artifact - bundled HY2XS admin - unit templates - config templates - docs / examples - manifest версии проекта Итоговый пакет **не должен** содержать: - builder scripts - исходную локальную build-среду - временные каталоги сборки - мусор CI - target-side dependency install step для оркестратора ## Production builder bootstrap `tools/build/build.sh` должен быть самодостаточным для Debian 13 amd64: 1. Проверяет ОС и архитектуру по `versions.env` (`HY2XS_BUILD_*`). 2. Проверяет структуру репозитория и lock-файлы. 3. Доставляет отсутствующие системные build-зависимости через `apt-get`. 4. Проверяет версии Go, Bun, Node.js и pnpm по `versions.env`. 5. При несовпадении версий скачивает управляемый локальный toolchain в `.toolchain/` и **сверяет каждый архив с контрольной суммой из `versions.env`**. 6. Собирает только Linux amd64 артефакты. 7. Записывает версии toolchain в metadata пакета. Собственных значений по умолчанию у `tools/build/lib/deps.sh` больше нет: без загруженного контракта сборка падает сразу, а не собирает пакет на неизвестном toolchain. ## Отношение к Hysteria2 Сам бинарь Hysteria2 **не вендорится** в install package как baseline-правило. Причина: - ядро Hysteria рассматривается как stable upstream component; - целевая установка скачивает его с official upstream, но **строго по замороженным координатам**. ### Разрешение версии на сборке ```text SOURCE │ ▼ resolve latest stable (HyNetworks/hysteria, только теги app/vX.Y.Z) │ ▼ resolve exact release asset (hysteria-linux-amd64 + hashes.txt) │ ▼ download hashes.txt → ожидаемый SHA-256 от upstream │ ▼ download artifact + сверка с ожидаемым SHA-256 │ ▼ compatibility gate (реальный бинарник принимает канонический конфиг HY2XS) │ ▼ PACKAGE METADATA version = vX.Y.Z exact_url = sha256 = <...> resolution = latest-stable | pinned | override │ ▼ TARGET SERVER скачивает уже конкретный неизменяемый артефакт ``` Так одновременно выполняются оба требования: «по умолчанию брать последнюю стабильную» и «production-установка должна быть детерминированной и проверяемой». Переменные builder: | Переменная | Значение по умолчанию | Назначение | | --- | --- | --- | | `HYSTERIA_CHANNEL` | `stable` | `stable` — разрешить последнюю стабильную через upstream API; `pinned` — взять `tools/build/hysteria-lock.env` без сети | | `HYSTERIA_VERSION_OVERRIDE` | пусто | Закрепить конкретную версию `vX.Y.Z` | | `HYSTERIA_COMPAT_GATE` | `true` | Compatibility gate; для release-сборок обязателен | | `HYSTERIA_WRITE_LOCK` | `false` | Записать разрешённые значения обратно в `tools/build/hysteria-lock.env` | | `HYSTERIA_VERIFY_UPSTREAM_HASHES` | `true` | Сверять артефакт с upstream `hashes.txt`; отключение — только break-glass | | `GITHUB_TOKEN` | пусто | Опционально, чтобы не упереться в anonymous rate limit | ### Проверка происхождения артефакта Раньше SHA-256 считался локально от уже скачанного файла. Это защищает target от последующей подмены, но не доказывает, что builder скачал именно ожидаемый upstream artifact: сумма фиксирует то, что пришло, каким бы оно ни было (trust-on-first-use). Upstream публикует контрольные суммы релиза отдельным ассетом `hashes.txt`: ```text 6493dfff…f94 build/hysteria-linux-amd64 f24f63be…189 build/hysteria-linux-amd64-avx ``` Поэтому порядок теперь такой: ```text download hysteria-linux-amd64 download hashes.txt ↓ ожидаемый SHA-256 из upstream ↓ сверка скачанного бинарника ↓ и только после этого — запись SHA-256 в HY2XS lock и metadata ``` Сопоставление идёт по базовому имени и строго на равенство: `build/` — часть пути, а `hysteria-linux-amd64-avx` — другой артефакт, который не должен совпасть по префиксу. Разбор вынесен в `parseUpstreamHashes` и покрыт тестами. Источник ожидаемой суммы фиксируется в `metadata/package.env` (`hysteria_sha_source=upstream-hashes | hy2xs-lock | local-download`). Дополнительно: - версия, URL и SHA256 фиксируются в metadata install package (`metadata/hysteria.version`, `metadata/hysteria.url`, `metadata/hysteria.sha256`); - способ выбора версии фиксируется в `metadata/hysteria.resolution` и `metadata/package.env`; - runtime `reconfigure` не обновляет и не откатывает бинарник Hysteria2; - install flow валидирует SHA256 и фактическую версию установленного бинарника; - install-time код не обращается к upstream API и не использует moving `latest` — это проверяется тестами и acceptance-шагом сборки. ### Compatibility gate Gate защищает от ситуации, когда upstream меняет схему конфигурации, а builder молча собирает неработающий HY2XS. Порядок: 1. скачать артефакт и сверить SHA-256 с upstream `hashes.txt`; 2. сверить `hysteria version` с разрешённой версией; 3. отрендерить канонический конфиг HY2XS тем же кодом, что работает на target (`orchestrator/tools/render-canonical-config.ts`); 4. запустить реальный бинарник Hysteria с этим конфигом — для Gecko и для Salamander; 5. только после этого собирать release package. При несовместимости ломается сборка: ```text BUILD FAILED: unsupported Hysteria stable v2.13.0 ``` Это осознанно: ошибка должна проявиться на build machine, а не на сервере оператора. ## Инварианты Система считается правильной, если: 1. builder запускается на Debian 13 amd64 build host, не как target-side build step 2. пакет можно перенести на чистый Debian 13 3. на сервере нет отдельного build step 4. bundled UI уже находится внутри пакета 5. оркестратор authored as Bun/TypeScript, но на target приходит как готовый install-артефакт 6. Hysteria2 подтягивается install layer'ом с upstream по замороженным координатам, а не собирается на target из исходников 7. выход новой версии Hysteria после сборки не меняет содержимое уже собранного пакета 8. несовместимый upstream ломает сборку, а не установку у пользователя 9. контрольная сумма Hysteria подтверждена upstream-ассетом `hashes.txt`, а не только локальным пересчётом 10. версии продукта, платформы и toolchain объявлены в одном месте, а рассинхрон роняет сборку до создания tarball