Инструменты разработчика8 мин чтения
Тесты для того, что не исполняется: CI, инфраструктура и метаданные сторов
Workflow, docker-compose, тексты о приватности и метаданные App Store не исполняются в тестах, но каждый может сорвать релиз. Как ID Caller стережёт их check-скриптами и сюитами, которые ломают файлы нарочно.
Некоторые из самых важных файлов ID Caller никогда не запускаются в тестах. Workflow GitHub Actions, docker-compose, правило алерта Prometheus, политика конфиденциальности на сайте, описание в App Store — ничто из этого не компилируется и не роняет юнит-тест, когда в нём ошибка. Ломается оно позже: на деплое, во время инцидента или на ревью App Store.
ID Caller — наше приложение для приватных звонков без номера телефона: клиент на Kotlin/Compose Multiplatform и бэкенд на Go. Конфигурацию и тексты вокруг него мы считаем кодом. У каждой такой поверхности есть скрипт check-*.sh, который проверяет её инварианты, а у каждого скрипта — сюита, которая копирует файлы в фикстуру, ломает одну вещь и требует, чтобы проверка упала.
Инвентарь
В репозитории восемь check-скриптов, по одному на поверхность, плюс check-all.sh, который локально запускает их подряд вместе с гейтами Go и KMP. Рядом, в scripts/tests/, лежат десять сюит: восемь для check-скриптов, одна для скриптов бэкапа, восстановления и ротации секрета и одна для генератора, который собирает iOS-файл Localizable.xcstrings из Compose-ресурсов. Когда мы прогнали их для этой статьи, набралось 300 зелёных кейсов.
Правило записано в инструкции проекта: новая неисполняемая поверхность — это новый check-скрипт по образцу, сюита с фикстурой и damage-хелпером и шаг в check-all.sh.
Анатомия check-скрипта
Все восемь построены по одному шаблону. Вот check-store.sh, урезанный до каркаса, лимитов длины и одного правила App Review:
#!/usr/bin/env bash
# Использование: check-store.sh [ROOT] (по умолчанию — корень репозитория)
set -euo pipefail
ROOT="${1:-$(cd "$(dirname "$0")/.." && pwd)}"
fail=0
err() {
echo "FAIL: $*" >&2
fail=1
}
require_grep() { # file pattern message [grep-flags]
local file="$1" pattern="$2" message="$3" flags="${4:--q}"
[[ -f "$ROOT/$file" ]] || return 0 # отсутствие файла ловит require_file
grep $flags "$pattern" "$ROOT/$file" || err "$file: $message"
}
IOS_META=docs/store/metadata
AND_META=docs/store/metadata/android
# ... require_file для каждого файла метаданных
# 2. Лимиты длины полей сторов (в символах — python3, как в остальных чеках)
if command -v python3 >/dev/null 2>&1; then
check_len() { # file max
local file="$1" max="$2" n
[[ -f "$ROOT/$file" ]] || return 0
n="$(python3 -c 'import sys; print(len(open(sys.argv[1], encoding="utf-8").read().strip()))' "$ROOT/$file")"
[[ "$n" -le "$max" ]] || err "$file: $n символов (лимит $max)"
}
for locale in ru en-US; do
check_len "$IOS_META/$locale/name.txt" 30
check_len "$IOS_META/$locale/keywords.txt" 100
# ...
done
# ... то же для остальных локалей и Google Play
fi
# 3b. App Review 2.3.10: в метаданных App Store нет упоминаний сторонних
# платформ и их магазинов (режект 07.08.2026). Каталог android/ —
# метаданные Google Play, запрет на него не распространяется.
ios_only_texts=()
while IFS= read -r f; do
ios_only_texts+=("$f")
done < <(find "$ROOT/$IOS_META" -type f -name '*.txt' -not -path "$ROOT/$AND_META/*" 2>/dev/null)
if [[ "${#ios_only_texts[@]}" -gt 0 ]]; then
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/forbidden-platforms.sh"
for pattern in "${FORBIDDEN_PLATFORMS[@]}"; do
hits="$(grep -lE "$pattern" "${ios_only_texts[@]}" 2>/dev/null || true)"
if [[ -n "$hits" ]]; then
err "упоминание сторонней платформы (App Review 2.3.10), паттерн '$pattern': $hits"
fi
done
fi
# 6. Review Notes: VoIP-обоснование, seed-ID, видео-демо
rn=docs/store/review-notes.md
require_grep "$rn" 'voip' "нет VoIP-обоснования" -qi
require_grep "$rn" 'reportNewIncomingCall' "нет reportNewIncomingCall (инвариант 5 CLAUDE.md)"
# ...
if [[ "$fail" -ne 0 ]]; then
# ...
exit 1
fiШаблон держится на четырёх решениях:
- Корень — аргумент. По умолчанию скрипт проверяет репозиторий, но его можно направить на любой каталог, и именно эта строка делает проверку тестируемой.
errфиксирует провал и идёт дальше, поэтому один прогон выводит все сломанные инварианты, а не останавливается на первом.require_grepмолчит, если файла нет: об этом уже сообщилrequire_file. Одна ошибка — одно сообщение.- Необязательные инструменты отключаются мягко. YAML-валидация работает, только если есть
python3с PyYAML, а CI-джобы, которые на неё опираются, ставят PyYAML явно.
Списки паттернов, нужные нескольким проверкам, живут в scripts/lib/. Запрещённые формулировки «абсолютной анонимности» подключают и проверка сайта, и проверка сторов, так что паттерн, исправленный один раз, действует на обе поверхности.
Что стерегут восемь проверок
check-ci.sh: workflow-файлы на месте и парсятся; серверный пайплайн по-прежнему запускаетgolangci-lint,go testиgovulncheck; пайплайн приложения по-прежнему собирает iOS на macOS-раннере. Ловит «временно» удалённый скан уязвимостей и битыйrenovate.json.check-infra.sh: Terraform, compose, TURN, алертинг, бэкапы. Отвергает*.enc.yamlбез маркеров SOPS (секрет открытым текстом), вернувшийся шаг деплоя черезssh(деплоим только через API Dokploy) и/metrics, открытый наружу.check-e2e.sh: все семь Maestro-флоу на месте и запускают приложение, оркестратор исполняемый и вызывает каждый флоу, а nightly-workflow падает, если staging недоступен.check-privacy.sh: на сайте нет абсолютных обещаний анонимности; политика конфиденциальности на обоих языках перечисляет ровно то, что хранит сервер (ID, публичный ключ, push-токен, связи между ID), со сроком хранения 12 месяцев и оговоркой, что при P2P-звонке ваш IP виден собеседнику; iOS privacy manifest не декларирует трекинг и действительно подключён к таргету приложения.check-store.sh: файлы метаданных на месте и непустые, поля укладываются в лимиты сторов, Review Notes объясняют использование VoIP, а тексты App Store не называют другие платформы.check-release.sh: подпись и учётные данные приходят только из properties и переменных окружения, а fastlane, снесённый 19 августа, не возвращается, чтобы перезатереть метаданные, которые мы теперь ведём прямо в консолях сторов.check-deeplinks.sh: universal link для линковки согласован между бэкендом, Android-манифестом, iOS-entitlement, общей навигацией и association-файлами сайта. Разрыв в любом слое — мёртвая ссылка.check-bootstrap.sh: инварианты каркаса вроде зафиксированных версий Go и Java.
Часть правил — это шрамы. 7 августа App Store отклонил билд 1.0 (2) по Guideline 2.3.10 за карточку онбординга «Android & iOS». Теперь check-store.sh запрещает названия платформ в метаданных App Store, а Kotlin-тест делает то же для UI-строк. 20 августа алерт поднял ложный critical на восьми звонках, потому что неотвеченные звонки считались сбоями. Теперь проверка требует, чтобы правило вычитало неотвеченные звонки, не срабатывало на слишком малом объёме и использовало or vector(0): без него выкатка правила раньше бэкенда молча заглушила бы алерт. 23 сентября мы обнаружили панели Grafana, которые показывали данные staging, и теперь каждый запрос дашборда обязан смотреть на прод.
Тестируем проверки: фикстура плюс damage
Проверка, которая никогда не падает, хуже, чем никакой: она ставит зелёную галочку ни за что. Поэтому у каждого скрипта есть сюита, и, кроме самого первого каркаса, сюита всегда появлялась раньше скрипта: коммиты вида «failing tests for privacy-текстов» приходят с полностью красной сюитой, потому что скрипта ещё нет.
assert_ok() { # name cmd...
local name="$1"; shift
if "$@" >/dev/null 2>&1; then
echo "ok - $name"; pass=$((pass + 1))
else
echo "FAIL - $name (ожидался exit 0)"; failures=$((failures + 1))
fi
}
# ... assert_fail — зеркальный; STORE_FILES — список файлов store-поверхности
# Фикстура: минимальная копия store-поверхности во временной директории
make_fixture() {
local dir f
dir="$(mktemp -d)"
for f in "${STORE_FILES[@]}"; do
[[ -f "$REPO_ROOT/$f" ]] && { mkdir -p "$dir/$(dirname "$f")"; cp "$REPO_ROOT/$f" "$dir/$f"; }
done
echo "$dir"
}
damage() { # file pattern — удалить строки с pattern (no-op, если файла нет)
local file="$1" pattern="$2"
[[ -f "$file" ]] || return 0
grep -v -- "$pattern" "$file" > "$file.tmp" && mv "$file.tmp" "$file"
}
cleanup_dirs=()
trap 'rm -rf "${cleanup_dirs[@]:-}"' EXIT
assert_ok "реальный репозиторий проходит проверку" "$CHECK" "$REPO_ROOT"
fx="$(make_fixture)"; cleanup_dirs+=("$fx")
assert_ok "валидная фикстура проходит проверку" "$CHECK" "$fx"
for f in "${STORE_FILES[@]}"; do
fx="$(make_fixture)"; cleanup_dirs+=("$fx")
rm -f "$fx/$f"
assert_fail "отсутствие $f ловится" "$CHECK" "$fx"
done
fx="$(make_fixture)"; cleanup_dirs+=("$fx")
printf '\nWorks on Android too.\n' >> "$fx/$IOS_META/en-US/description.txt"
assert_fail "\"Android\" в en description (App Store) ловится" "$CHECK" "$fx"
fx="$(make_fixture)"; cleanup_dirs+=("$fx")
printf '\nЗвоните между Android и iOS.\n' \
>> "$fx/$AND_META/ru-RU/full_description.txt"
assert_ok "«Android» в метаданных Google Play разрешён" "$CHECK" "$fx"
fx="$(make_fixture)"; cleanup_dirs+=("$fx")
damage "$fx/docs/store/review-notes.md" 'reportNewIncomingCall'
assert_fail "review-notes без reportNewIncomingCall ловится" "$CHECK" "$fx"
# ...Форма у всех сюит одинаковая:
- Два базовых кейса: реальный репозиторий проходит, и свежая фикстура проходит. Второй доказывает, что фикстура — валидная отправная точка, поэтому дальнейший провал вызван повреждением, а не файлом, который мы забыли скопировать.
- Одно повреждение на фикстуру. Каждый кейс ломает ровно один инвариант в свежей копии и ждёт ненулевой код выхода: удалённые строки, дописанная запрещённая фраза, битый JSON или YAML, удалённый файл,
chmod -xна скрипте. - Явные разрешённые кейсы. «Android» в метаданных Google Play обязан проходить; placeholder-отпечатки в
assetlinks.jsonдают только предупреждение. Без таких кейсов слишком жадный паттерн падает на легитимном тексте, и люди привыкают игнорировать проверку.
По сути это написанное руками мутационное тестирование конфигурации: каждое повреждение — мутант, и проверка обязана его убить.
Когда damage-тест вскрыл слабый паттерн
Проверка бэкапа когда-то искала в backup.sh строку age -r, чтобы убедиться, что дампы шифруются. На код-ревью мы заметили, что собственное сообщение скрипта об отсутствующем получателе тоже содержит age -r, так что удаление настоящего шифрующего пайпа оставило бы проверку зелёной. Исправление — более точный паттерн | age -r и damage-кейс, который удаляет только строку с пайпом и оставляет сообщение. Grep-проверка хороша ровно настолько, насколько хорош её паттерн, и сюита — то место, где паттерны затачиваются.
Один runner и шаг в CI, который сторожит сам себя
Runner занимает одиннадцать строк:
#!/usr/bin/env bash
# scripts/tests/run-all.sh — единая точка запуска shell-тестов check-скриптов.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOT"
for test_script in scripts/tests/*.test.sh; do
echo "==> $test_script"
"$test_script"
doneОн запускается в contract-workflow на каждом pull request:
dto-sync:
runs-on: ubuntu-latest
steps:
# ... checkout, установка Go, golden-сверка
- name: CI config invariants
run: ./scripts/check-ci.sh
- name: Privacy texts invariants (T-041, R-08)
run: ./scripts/check-privacy.sh
- name: Shell validator test suites
run: ./scripts/tests/run-all.shПоскольку каждая сюита начинается с кейса «реальный репозиторий проходит», этот шаг заодно прогоняет все восемь проверок на настоящем дереве. Workflow деплоя и E2E дополнительно вызывают check-infra.sh и check-e2e.sh напрямую.
Runner появился в июне в правке по итогам код-ревью; до этого сюиты существовали, но CI их не запускал. Чтобы шаг не исчез снова, его требует check-ci.sh, а сюита этой проверки удаляет строку из фикстурной копии workflow и ждёт провала:
require_grep .github/workflows/contract.yml 'scripts/tests/run-all\.sh' \
"нет запуска shell-тестов check-скриптов"То же ревью исправило проблему потише. Nightly E2E и еженедельный нагрузочный тест раньше мягко пропускались, если staging был недоступен, и прогон мог выглядеть зелёным, не выполнив ни одного флоу. Теперь они падают, а проверки требуют guard с exit 1 в обоих.
Границы подхода
Эти проверки доказывают наличие, а не смысл: require_grep 'govulncheck' пройдёт, даже если слово осталось только в комментарии. Мы с этим согласны. Скрипты — растяжки для регрессий, которые люди совершают на практике (удалённый шаг, откаченная настройка, запрещённая фраза), а настоящее исполнение остаётся за terraform validate, валидатором OpenAPI и ночным прогоном Maestro. Изменить решение — значит поменять проверку и её тест в одном коммите, и это трение задумано.
Как начать у себя
- Выберите одну поверхность, которая уже делала вам больно: workflow, compose-файл, тексты для сторов.
- Запишите её инварианты обычными предложениями и превратите каждое в
require_fileилиrequire_grep. - Пусть скрипт принимает корневой каталог аргументом и собирает все провалы перед выходом.
- Сначала напишите сюиту: фикстуру, которая копирует только то, что читает проверка, базовые кейсы для реального репозитория и свежей фикстуры, затем по одному повреждению на инвариант.
- Добавьте разрешённые кейсы везде, где паттерн может сработать на легитимном тексте.
- Держите общие списки паттернов в одном подключаемом файле.
- Гоняйте все сюиты через один runner в CI и заставьте одну из проверок требовать наличие этого шага.
- Когда инцидент или режект учит вас новому правилу, добавляйте инвариант и его damage-тест в том же изменении.
ONLYAPPS Team
Нови-Сад, Сербия · onlyapps.org