Files
HY2XS_flamy/tools/build/lib/acceptance.sh
T
founder 086b5d6624 build: закрепить новые инварианты приёмкой и документацией
verify_versions_contract получил сверку API namespace. Путь machine-auth
записывается в /etc/hysteria/config.yaml и в post-install.env, то есть по нему
Hysteria обращается к админке. Пока строка была продублирована в шаблонах,
smoke, тестах, приёмке и e2e, расхождение обнаруживалось только на живом
сервере. Теперь Go-константы, API_BASE фронтенда и оба шаблона сверяются
против значений, скомпилированных в оркестратор.

Приёмка проверяет, что:
  - fatal_pre_apply недостижим после записи install-state;
  - каждый ownership-флаг взводится раньше своего шага;
  - у read-only фазы нет универсального раннера, через который можно
    проскользнуть;
  - инвариант публичного endpoint живёт в preflight и не обращается к внешним
    сервисам определения IP;
  - purge-v0.sh и clean-host описывают одну границу;
  - секреты не попадают в персистентный файл экспорта;
  - импорт пиров валидируется так же строго, как их создание;
  - удалённые exportConfig/importConfig не вернулись.

Захардкоженная схема =2 в приёмке заменена на значение из versions.env: при
переходе на schema 3 пришлось бы помнить ещё и про эту строку.

Документация: контракт раннеров и ownership в 08, инвариант публичного
endpoint в 08/09/12/13 и README, сетевая идентичность панели и удалённые
export/import в 04, сценарии D1 (отказ сразу после PHASE 0) и D2 (устаревший
DNS после смены IPv4) в 11, версии package.json как не-версия продукта в 02.
2026-08-27 20:50:03 +05:00

349 lines
23 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
set -euo pipefail
run_fix20_acceptance_subset() {
local package_dir="$1"
[ -d "$package_dir" ] || fail "acceptance: package dir not found: $package_dir"
log_step "Acceptance: package layout sanity"
[ -x "$package_dir/install.sh" ] || fail "acceptance: install.sh is missing or not executable"
[ -x "$package_dir/orchestrator/hy2xs-orchestrator" ] || fail "acceptance: orchestrator artifact is missing"
log_step "Acceptance: project license is shipped with the package"
[ -f "$package_dir/LICENSE" ] || fail "acceptance: LICENSE is missing from the package"
grep -q 'GNU AFFERO GENERAL PUBLIC LICENSE' "$package_dir/LICENSE" \
|| fail "acceptance: packaged LICENSE must be AGPL-3.0-only"
grep -q '^license=AGPL-3.0-only$' "$package_dir/metadata/package.env" \
|| fail "acceptance: package metadata must declare license=AGPL-3.0-only"
log_step "Acceptance: orchestrator CLI help path"
"$package_dir/orchestrator/hy2xs-orchestrator" diagnostics collect --package-dir "$package_dir" >/dev/null 2>&1 || true
log_step "Acceptance: firewall mode defaults in config"
grep -q '^HY2XS_FIREWALL_MODE=' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_FIREWALL_MODE missing in runtime config"
log_step "Acceptance: baseline domain/public host/ssh defaults"
grep -q '^HY2XS_DOMAIN=fi.api.withen.pro$' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_DOMAIN must default to fi.api.withen.pro"
grep -q '^HY2XS_PUBLIC_HOST=fi.api.withen.pro$' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_PUBLIC_HOST must default to fi.api.withen.pro"
grep -q '^HY2XS_SSH_PORT=2323$' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_SSH_PORT must default to 2323"
log_step "Acceptance: force password change production default"
grep -q '^HY2XS_FORCE_PASSWORD_CHANGE=false$' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_FORCE_PASSWORD_CHANGE must default to false"
log_step "Acceptance: config schema version is declared"
# Значение берётся из versions.env, а не пишется числом: захардкоженная
# двойка означала бы, что при переходе на schema 3 нужно помнить ещё и про
# эту строку. Источник истины у схемы ровно один.
grep -q "^HY2XS_CONFIG_SCHEMA_VERSION=${HY2XS_CONFIG_SCHEMA_VERSION}\$" "$package_dir/config/hy2xs.env" \
|| fail "acceptance: packaged baseline must declare HY2XS_CONFIG_SCHEMA_VERSION=${HY2XS_CONFIG_SCHEMA_VERSION}"
log_step "Acceptance: public endpoint policy is declared and strict by default"
grep -q '^HY2XS_PUBLIC_ENDPOINT_POLICY=strict$' "$package_dir/config/hy2xs.env" \
|| fail "acceptance: packaged baseline must default to HY2XS_PUBLIC_ENDPOINT_POLICY=strict"
log_step "Acceptance: fresh install defaults to Gecko obfuscation"
grep -q '^HY2XS_HYSTERIA_OBFS_TYPE=gecko$' "$package_dir/config/hy2xs.env" \
|| fail "acceptance: fresh installations must default to HY2XS_HYSTERIA_OBFS_TYPE=gecko"
log_step "Acceptance: obfs type is not hardcoded in the Hysteria template"
grep -q '{{OBFS_BLOCK}}' "$package_dir/templates/hysteria/config.yaml.tpl" \
|| fail "acceptance: hysteria template must render the obfs block from the orchestrator"
! grep -Eq '^\s*type:\s*(gecko|salamander)\s*$' "$package_dir/templates/hysteria/config.yaml.tpl" \
|| fail "acceptance: hysteria template must not hardcode an obfs type"
log_step "Acceptance: modern server baseline is present in the template"
grep -q 'disableLossCompensation: {{DISABLE_LOSS_COMPENSATION}}' "$package_dir/templates/hysteria/config.yaml.tpl" \
|| fail "acceptance: bandwidth.disableLossCompensation missing from hysteria template"
grep -q '{{CONGESTION_BLOCK}}' "$package_dir/templates/hysteria/config.yaml.tpl" \
|| fail "acceptance: congestion block missing from hysteria template"
grep -q '{{QUIC_BLOCK}}' "$package_dir/templates/hysteria/config.yaml.tpl" \
|| fail "acceptance: quic block missing from hysteria template"
log_step "Acceptance: post-install env derives obfs type from resolved context"
grep -q '^HY2_OBFS_TYPE={{OBFS_TYPE}}$' "$package_dir/templates/env/post-install.env.tpl" \
|| fail "acceptance: post-install env must render the resolved obfs type, not a second set of defaults"
! grep -Eq '^HY2_OBFS_TYPE=(gecko|salamander)$' "$package_dir/templates/env/post-install.env.tpl" \
|| fail "acceptance: post-install env must not hardcode an obfs type"
log_step "Acceptance: production defaults are declared in exactly one module"
grep -q 'DEFAULT_HYSTERIA_OBFS_TYPE' orchestrator/src/config/profile.ts \
|| fail "acceptance: the default obfs type must be declared in orchestrator/src/config/profile.ts"
local default_declarations
default_declarations="$(grep -rl 'DEFAULT_HYSTERIA_OBFS_TYPE\s*[:=]' orchestrator/src \
| grep -v 'orchestrator/src/config/profile.ts' || true)"
[ -z "$default_declarations" ] \
|| fail "acceptance: the default obfs type must not be re-declared in: $default_declarations"
log_step "Acceptance: runtime env is derived from config, not literals"
! grep -Eq 'HY2XS_HYSTERIA_OBFS_TYPE=(gecko|salamander)' orchestrator/src/config/env.ts \
|| fail "acceptance: renderRuntimeEnv must not print a hardcoded obfs type"
grep -q 'HY2XS_HYSTERIA_OBFS_TYPE=\${config.hysteriaObfsType}' orchestrator/src/config/env.ts \
|| fail "acceptance: renderRuntimeEnv must derive the obfs type from the parsed config"
! grep -Eq '\|\|\s*"(gecko|salamander)"' orchestrator/src/config/env.ts \
|| fail "acceptance: env.ts must not carry its own obfs fallback default"
log_step "Acceptance: package metadata records how the Hysteria version was chosen"
grep -q '^hysteria_resolution=' "$package_dir/metadata/package.env" \
|| fail "acceptance: metadata must record hysteria_resolution"
grep -q '^hysteria_resolved_at=' "$package_dir/metadata/package.env" \
|| fail "acceptance: metadata must record hysteria_resolved_at"
grep -q '^hysteria_compat_gate=true$' "$package_dir/metadata/package.env" \
|| fail "acceptance: release packages must be built with the Hysteria compatibility gate enabled"
grep -Eq '^hysteria_artifact_url=https://github\.com/HyNetworks/hysteria/' "$package_dir/metadata/package.env" \
|| fail "acceptance: Hysteria artifact must come from the canonical HyNetworks upstream"
log_step "Acceptance: install-time never resolves a moving latest"
! grep -rq 'api.github.com' orchestrator/src/commands orchestrator/src/steps \
|| fail "acceptance: install-time code must not query the upstream release API"
! grep -rq 'download.hysteria.network' orchestrator/src \
|| fail "acceptance: install-time code must not use the moving latest download URL"
log_step "Acceptance: smoke verifies config semantics, not substrings"
grep -q 'assertHysteriaConfigMatchesProfile' orchestrator/src/steps/smoke.ts \
|| fail "acceptance: smoke must verify the effective config semantically"
log_step "Acceptance: admin export preserves unknown upstream fields and strips secrets"
grep -q 'ExportHysteria2ConfigYaml' apps/controller/config.go \
|| fail "acceptance: hysteria config export must go through the sanitizing raw-YAML path"
grep -q 'GetRawHysteria2Config' apps/service/hysteria2_export.go \
|| fail "acceptance: export must read the raw YAML instead of the typed model"
log_step "Acceptance: frontend ACME registry matches current upstream"
# Ищем именно предлагаемое значение, а не упоминание в комментарии.
! grep -q '"namedotcom"' apps/frontend/src/views/hysteria/list/index.vue \
|| fail "acceptance: namedotcom was removed upstream in Hysteria 2.11.0 and must not be offered"
local provider
for provider in cloudflare duckdns gandi godaddy namecheap njalla porkbun vultr; do
grep -q "\"${provider}\"" apps/frontend/src/views/hysteria/list/index.vue \
|| fail "acceptance: ACME DNS provider ${provider} is missing from the UI registry"
done
log_step "Acceptance: systemd unit production env"
grep -q '^Environment=GIN_MODE=release$' "$package_dir/systemd/hy2xs-admin.service" || fail "acceptance: GIN_MODE=release missing"
log_step "Acceptance: docs matrix markers"
grep -q 'Fix20 production matrix' docs/11-testing-and-acceptance.md || fail "acceptance: fix20 matrix section missing"
log_step "Acceptance: machine auth URL in templates"
grep -q '/internal/hysteria/auth?access_token={{HYSTERIA_API_SECRET}}' "$package_dir/templates/hysteria/config.yaml.tpl" || fail "acceptance: machine token missing in hysteria auth URL template"
grep -q '^HY2_AUTH_URL=http://127.0.0.1:{{UI_PORT}}/internal/hysteria/auth?access_token={{HYSTERIA_API_SECRET}}$' "$package_dir/templates/env/post-install.env.tpl" || fail "acceptance: machine token missing in post-install HY2_AUTH_URL"
log_step "Acceptance: smoke auth checks are tokenized"
grep -q 'unexpected auth status without machine token' orchestrator/src/steps/smoke.ts || fail "acceptance: missing 403 negative smoke for auth without machine token"
grep -q 'hysteria2/auth?access_token=\${context.config.hysteriaTrafficStatsSecret}' orchestrator/src/steps/smoke.ts || fail "acceptance: smoke auth URL is not tokenized"
log_step "Acceptance: bootstrap peer can pass auth smoke"
grep -q 'quota := int64(-1)' apps/dao/sqlite.go || fail "acceptance: bootstrap peer quota must be unlimited (-1), otherwise install auth smoke fails"
log_step "Acceptance: frontend i18n does not touch Pinia at module import"
! grep -q 'useAppStore' apps/frontend/src/lang/index.ts || fail "acceptance: lang/index.ts must not import/use Pinia store"
log_step "Acceptance: env rendering maps machine token and fails on unresolved placeholders"
grep -q 'HYSTERIA_API_SECRET: context.config.hysteriaTrafficStatsSecret' orchestrator/src/steps/env.ts || fail "acceptance: writePostInstallEnv must pass HYSTERIA_API_SECRET"
grep -q 'template render failed: unresolved placeholders' orchestrator/src/lib/fs.ts || fail "acceptance: renderTemplate must fail on unresolved placeholders"
run_clean_install_acceptance "$package_dir"
}
# Приёмка политики clean-install-only и связанных с ней инвариантов.
run_clean_install_acceptance() {
local package_dir="$1"
log_step "Acceptance: installer runs a read-only preflight before touching the host"
grep -q 'preflight-install' "$package_dir/install.sh" \
|| fail "acceptance: install.sh must run the read-only preflight before mutating the host"
grep -q 'PHASE 0' "$package_dir/install.sh" \
|| fail "acceptance: install.sh must document the read-only phase boundary"
grep -q 'preflightInstall' orchestrator/src/cli.ts \
|| fail "acceptance: orchestrator must expose the preflight-install command"
log_step "Acceptance: the read-only phase is enforced by a guard, not by convention"
grep -q 'enableReadOnlyGuard' orchestrator/src/commands/preflight-install.ts \
|| fail "acceptance: preflight-install must enable the read-only guard"
grep -q 'assertMutationAllowed' orchestrator/src/lib/fs.ts \
|| fail "acceptance: fs writes must be guarded during the read-only phase"
grep -q 'assertMutationAllowed' orchestrator/src/lib/process.ts \
|| fail "acceptance: mutating runners must be guarded during the read-only phase"
log_step "Acceptance: preflight passes before the first install-state write"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8");
const preflight = source.indexOf("await preflight(context");
const state = source.indexOf("await advanceInstallState(");
if (preflight < 0 || state < 0) {
throw new Error("could not locate preflight/advanceInstallState in install.ts");
}
if (preflight > state) {
throw new Error("install writes install-state before preflight");
}
' || fail "acceptance: install must not write install-state before a successful preflight"
log_step "Acceptance: rollback is ownership-aware, not message-driven"
grep -q 'classifyFailure(ownership' orchestrator/src/commands/install.ts \
|| fail "acceptance: failure classification must be driven by ownership, not by the error message"
grep -q 'systemd units were not deployed by this operation' orchestrator/src/commands/install.ts \
|| fail "acceptance: rollback must never stop services it did not deploy"
log_step "Acceptance: a written install-state already makes the failure post-apply"
# Регрессия: classifyFailure не учитывал stateWritten, поэтому падение
# apt-get объявлялось «на сервере ничего не изменено», rollback пропускался,
# а install-state.json оставался на хосте и ломал следующую установку.
grep -q 'ownership.stateWritten' orchestrator/src/commands/install.ts \
|| fail "acceptance: classifyFailure must account for a written install-state"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8");
const body = source.slice(source.indexOf("export function classifyFailure"));
const preApply = body.indexOf("return \"fatal_pre_apply\"");
const stateWritten = body.indexOf("ownership.stateWritten");
if (preApply < 0 || stateWritten < 0) {
throw new Error("could not locate classifyFailure branches");
}
if (stateWritten > preApply) {
throw new Error("stateWritten is checked after the fatal_pre_apply fallback");
}
' || fail "acceptance: fatal_pre_apply must be unreachable once install-state was written"
log_step "Acceptance: mutating ownership flags are raised before the step, not after"
# Флаг «шаг завершился» отвечает не на тот вопрос: apt-get умеет изменить
# систему и упасть. Каждый флаг обязан стоять ПЕРЕД своим await.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8");
const steps = [
["depsTouched", "await installDeps("],
["filesystemTouched", "await prepareFilesystem("],
["uiTouched", "await deployUi("],
["hysteriaTouched", "await installHysteria("],
["configTouched", "await generateConfig("],
["unitsTouched", "await deploySystemd("],
["firewallTouched", "await applyFirewall("],
["postInstallTouched", "await writePostInstallEnv("],
["bootstrapSecretTouched", "await ensureBootstrapAdminSecret("]
];
for (const [flag, call] of steps) {
const flagAt = source.indexOf("ownership." + flag + " = true");
const callAt = source.indexOf(call);
if (flagAt < 0) throw new Error("missing ownership flag: " + flag);
if (callAt < 0) throw new Error("missing step call: " + call);
if (flagAt > callAt) throw new Error(flag + " is raised after " + call);
}
' || fail "acceptance: ownership flags must be raised before the mutating step they cover"
log_step "Acceptance: the read-only phase has no universal runner to slip through"
# Пока существовал один `run`, под которым жили и `ss -ltn`, и `useradd`,
# guard держался на внимательности автора правки.
grep -q 'export async function runReadOnly' orchestrator/src/lib/process.ts \
|| fail "acceptance: process.ts must expose an explicit read-only runner"
grep -q 'export async function runMutating' orchestrator/src/lib/process.ts \
|| fail "acceptance: process.ts must expose an explicit mutating runner"
! grep -rEq '(^|[^A-Za-z0-9_])(run|runVisible|runHidden|runSecret|runRawVisible)`' orchestrator/src \
|| fail "acceptance: the pre-split runner names must not come back"
local unguarded_runner
unguarded_runner="$(grep -c 'assertMutationAllowed' orchestrator/src/lib/process.ts || true)"
[ "$unguarded_runner" -ge 4 ] \
|| fail "acceptance: every mutating runner must ask the read-only guard for permission"
log_step "Acceptance: the public endpoint invariant lives in preflight, not only in doctor"
grep -q 'assertPublicEndpoint' orchestrator/src/steps/preflight.ts \
|| fail "acceptance: preflight must verify that the public endpoint resolves to this server"
[ -f orchestrator/src/steps/networkEndpoint.ts ] \
|| fail "acceptance: the public endpoint invariant module is missing"
! grep -rqE 'ifconfig\.me|ipify|checkip\.amazonaws' orchestrator/src \
|| fail "acceptance: the server address must be resolved locally, not via an external service"
grep -q 'networkInterfaces' orchestrator/src/steps/networkEndpoint.ts \
|| fail "acceptance: local public IPv4 set must come from the host interfaces"
log_step "Acceptance: purge and clean-host describe the same boundary"
local purged_path
for purged_path in /var/lib/hysteria /usr/local/lib/hy2xs /usr/local/bin/hy2xs-orchestrator /var/log/hy2xs; do
grep -qF "$purged_path" tools/legacy/purge-v0.sh \
|| fail "acceptance: purge-v0.sh no longer removes $purged_path"
done
grep -qF '/var/lib/hysteria' orchestrator/src/steps/cleanHost.ts \
|| fail "acceptance: clean-host must treat leftover Hysteria runtime state as a legacy marker"
grep -qF '/usr/local/bin/hy2xs-orchestrator' orchestrator/src/steps/cleanHost.ts \
|| fail "acceptance: clean-host must treat a leftover orchestrator symlink as a legacy marker"
! grep -q 'keep-hysteria-binary' tools/legacy/purge-v0.sh \
|| fail "acceptance: --keep-hysteria-binary contradicts the installer clean-host contract"
log_step "Acceptance: admin secrets never reach a persistent export file"
# Экспорт формируется в памяти: os.Create в /var/lib/hy2xs-admin/export
# оставлял на диске JSON с расшифрованными секретами пиров.
[ ! -f apps/util/export.go ] \
|| fail "acceptance: the file-based export helper came back"
! grep -rq 'ExportPathDir' apps/cmd apps/controller apps/service apps/dao apps/model apps/util \
|| fail "acceptance: the persistent export directory came back"
grep -q 'c.Data(200, "application/octet-stream", payload)' apps/controller/peer.go \
|| fail "acceptance: peer export must be served from memory"
log_step "Acceptance: peer import is validated as strictly as peer creation"
grep -q 'ValidatePeerImportBatch' apps/service/peer.go \
|| fail "acceptance: peer import must validate the whole batch before writing"
grep -q 'DisallowUnknownFields' apps/controller/peer.go \
|| fail "acceptance: peer import must reject unknown fields instead of silently defaulting"
grep -q 'ReservedBootstrapPeerName' apps/service/peer_import.go \
|| fail "acceptance: peer import must refuse to overwrite the installer-owned bootstrap peer"
log_step "Acceptance: missing config schema marker is treated as legacy"
grep -q 'HY2XS_CONFIG_SCHEMA_VERSION отсутствует' orchestrator/src/config/env.ts \
|| fail "acceptance: a missing HY2XS_CONFIG_SCHEMA_VERSION must be rejected as legacy, not defaulted"
log_step "Acceptance: reconfigure/repair verify the installation generation"
grep -q 'assertCurrentGeneration' orchestrator/src/commands/reconfigure.ts \
|| fail "acceptance: reconfigure/repair must verify release line and config schema in install-state"
grep -q 'release_line' orchestrator/src/lib/installState.ts \
|| fail "acceptance: install-state must carry the product release line"
log_step "Acceptance: diagnostics redaction is structural"
grep -q 'Bun.YAML.parse' orchestrator/src/lib/redaction.ts \
|| fail "acceptance: YAML redaction must walk the document, not match lines"
# Регрессия: правило `.replace(/(auth:\s*).*/gi, ...)` подставляло маркер в
# заголовок mapping'а и оставляло вложенный auth.http.url с machine token.
! grep -qF 'replace(/(auth:' orchestrator/src/lib/redaction.ts \
|| fail "acceptance: the line-based auth redaction rule leaked nested auth.http.url"
log_step "Acceptance: dead updater/config-write routes stay removed"
# Ищется регистрация маршрута (имя в кавычках), а не любое упоминание:
# комментарий, объясняющий, почему маршрута нет, должен быть разрешён.
local dead_route
for dead_route in hysteria2ChangeVersion listRelease updateHysteria2Config importHysteria2Config restartServer uploadCertFile hysteria2AcmePath exportConfig importConfig; do
! grep -rqF "${dead_route}\"" apps/router apps/controller \
|| fail "acceptance: removed route ${dead_route} came back"
! grep -rqF "${dead_route}\"" apps/frontend/src/api \
|| fail "acceptance: frontend still calls the removed route ${dead_route}"
done
log_step "Acceptance: e2e uses the production share URI generator"
grep -q 'tools/share-uri' tools/test/e2e-hysteria.sh \
|| fail "acceptance: e2e must build the share URI with production code"
! grep -q 'build_share_uri' tools/test/e2e-hysteria.sh \
|| fail "acceptance: the bash share-URI implementation must not come back"
log_step "Acceptance: upstream checksums are verified before the lock is written"
grep -q 'resolve_expected_sha_from_upstream' tools/build/lib/hysteria.sh \
|| fail "acceptance: the Hysteria artifact must be verified against upstream hashes.txt"
log_step "Acceptance: toolchain checksums live in versions.env"
grep -q '^BUN_LINUX_X64_SHA256=' versions.env \
|| fail "acceptance: versions.env must pin the Bun x64 artifact"
grep -q '^BUN_LINUX_X64_BASELINE_SHA256=' versions.env \
|| fail "acceptance: versions.env must pin the Bun baseline artifact"
grep -q '^GO_LINUX_AMD64_SHA256=' versions.env \
|| fail "acceptance: versions.env must pin the Go toolchain archive"
grep -q '^NODE_LINUX_X64_SHA256=' versions.env \
|| fail "acceptance: versions.env must pin the Node.js archive"
! grep -q '^HYSTERIA_VERSION=' versions.env \
|| fail "acceptance: versions.env declares the Hysteria policy, not a pinned version"
log_step "Acceptance: admin version comes from the build contract"
grep -q 'var Version = "dev"' apps/model/constant/system.go \
|| fail "acceptance: admin version must be injected via ldflags, not hardcoded"
grep -q 'constant.Version=v' tools/build/lib/package.sh \
|| fail "acceptance: the build must inject the admin version from versions.env"
log_step "Acceptance: legacy cleanup is a separate, explicit helper"
[ -f tools/legacy/purge-v0.sh ] || fail "acceptance: legacy cleanup helper is missing"
[ -f docs/14-legacy-cleanup.md ] || fail "acceptance: legacy cleanup runbook is missing"
! grep -q 'purge-v0' "$package_dir/install.sh" \
|| fail "acceptance: the installer must never run destructive cleanup on its own"
}