Andrey Epifancev 9bc2b26c1c scripts/regression: полная регрессия main всех репозиториев перед выкладкой (#179)
regress.sh — один вход: preflight (main = origin/main, чистые, нет чужих потребителей NATS), venv, lint+unit
всех репозиториев и docs make check, фронт в чистой копии (npm ci --dry-run как проверка lock, установка без
lock), интеграционные с циклом миграций, реальные данные парсера, образы, e2e ядра, изолированный стенд с нуля
(compose-проект arm-ka-regress на своих портах) со сценарием по API, UI-смоук Playwright, парсер по doc-bank.
Не останавливается на первой ошибке; итог — summary.md с SHA и статусом шагов.

Контрольные цифры — expected.json (qa-guide §7.1–7.3, прогоны 08.10/14.10, golden шахматки); снимки свода,
шахматки и форм сравниваются с прошлым прогоном. Ссылки из dev/checks.md и чек-листа синхронизации контура.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:09:15 +04:00

310 lines
16 KiB
Bash
Executable File
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
# Полная регрессия main всех репозиториев workspace перед выкладкой (arm-ka-docs#179). Описание — README.md рядом.
#
# scripts/regression/regress.sh [--out DIR] [--only st1,st2] [--skip st1,st2] [--keep-stand] [--allow-dirty]
# [--bank-baseline DIR] [--list]
#
# Этапы идут по порядку; упавший шаг не останавливает следующие (нужна вся картина), кроме preflight: без чистых
# main = origin/main прогон не имеет смысла (--allow-dirty — прогнать как есть, с пометкой в итоге).
# Итог — $OUT/summary.md (SHA репозиториев, статус и время шагов, счётчики pytest, пропуски), логи — $OUT/logs/.
# Код возврата: 0 — все шаги зелёные, 1 — есть красные, 2 — preflight не пройден или неверные аргументы.
#
# Workspace (каталог с arm-core/, tabular-parser/, … на main) — ARM_KA_WORKSPACE, по умолчанию родитель docs. Скрипты
# можно запускать из worktree docs: тогда задайте ARM_KA_WORKSPACE, проверяются всё равно основные checkout'ы.
set -uo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
WS="${ARM_KA_WORKSPACE:-$(cd "$HERE/../../.." && pwd)}"
COMPOSE_FILE="$WS/docs/dev/docker-compose.yml"
# Изолированный стенд: свой compose-проект и порты, общий стенд arm-ka (данные, тома) не трогается
REGRESS_PROJECT="${REGRESS_PROJECT:-arm-ka-regress}"
# Порты — только для команд этого проекта (dcr): глобальный export пересоздал бы контейнеры arm-ka на чужих портах
ARM_CORE_PORT=18000
REGRESS_PORTS="POSTGRES_PORT=15432 NATS_PORT=14222 NATS_MONITOR_PORT=18222 MINIO_API_PORT=19000 MINIO_CONSOLE_PORT=19001
ARM_CORE_PORT=$ARM_CORE_PORT TABULAR_PARSER_PORT=18001 SCAN_GATEWAY_PORT=18002"
ALL_STAGES="preflight venv static web integration fixtures images e2e stand ui bank"
# Репозитории под проверкой; pylib_ai_arm_statements (AURORA-1677) появится здесь после выпуска 0.1.2
REPOS="docs fixtures arm-statements arm-core tabular-parser scan-gateway"
OUT="" ONLY="" SKIP="" KEEP_STAND=0 ALLOW_DIRTY=0 BANK_BASELINE=""
while [ $# -gt 0 ]; do
case "$1" in
--out) OUT="$2"; shift 2 ;;
--only) ONLY="$2"; shift 2 ;;
--skip) SKIP="$2"; shift 2 ;;
--keep-stand) KEEP_STAND=1; shift ;;
--allow-dirty) ALLOW_DIRTY=1; shift ;;
--bank-baseline) BANK_BASELINE="$2"; shift 2 ;;
--list) echo "$ALL_STAGES"; exit 0 ;;
-h | --help) sed -n '2,13p' "$0"; exit 0 ;;
*) echo "неизвестный аргумент: $1" >&2; exit 2 ;;
esac
done
for s in ${ONLY//,/ } ${SKIP//,/ }; do
case " $ALL_STAGES " in *" $s "*) ;; *) echo "неизвестный этап: $s (есть: $ALL_STAGES)" >&2; exit 2 ;; esac
done
OUT="${OUT:-$WS/admin/regression/$(date +%Y-%m-%d_%H%M)}"
mkdir -p "$OUT/logs"
RESULTS="$OUT/results.tsv"
export ARM_KA_DOCS="$WS/docs" ARM_KA_FIXTURES="$WS/fixtures"
# ENV_FILE в окружении перебивает pytest.ini сервисов — не даём просочиться из оболочки
unset ENV_FILE
log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*"; }
# step <этап> <шаг> <команда…>: вывод — в logs/<этап>.<шаг>.log, строка итога — в results.tsv
step() {
local stage=$1 name=$2 t0 rc lf
shift 2
lf="$OUT/logs/$stage.$name.log"
t0=$(date +%s)
log "$stage/$name …"
("$@") >"$lf" 2>&1
rc=$?
printf '%s\t%s\t%s\t%s\t%s\n' "$stage" "$name" "$rc" "$(($(date +%s) - t0))" "$lf" >>"$RESULTS"
if [ $rc -eq 0 ]; then log "$stage/$name ok"; else log "$stage/$name КРАСНЫЙ (rc=$rc, $lf)"; fi
return $rc
}
want() {
local s=$1
[ -n "$ONLY" ] && { case ",$ONLY," in *",$s,"*) ;; *) return 1 ;; esac; }
[ -n "$SKIP" ] && { case ",$SKIP," in *",$s,"*) return 1 ;; esac; }
return 0
}
dc() { docker compose -f "$COMPOSE_FILE" "$@"; }
# shellcheck disable=SC2086 — REGRESS_PORTS намеренно раскладывается на слова
dcr() { env $REGRESS_PORTS docker compose -f "$COMPOSE_FILE" -p "$REGRESS_PROJECT" --profile services "$@"; }
mk() { make --no-print-directory -C "$WS/$1" "${@:2}"; }
# --- preflight -------------------------------------------------------------------------------------------------------
repo_state() {
local r=$1 dir="$WS/$1" br head origin dirty
[ -d "$dir/.git" ] || [ -f "$dir/.git" ] || { echo "$r: нет репозитория в $dir"; return 1; }
git -C "$dir" fetch -q origin || { echo "$r: fetch не удался"; return 1; }
br=$(git -C "$dir" rev-parse --abbrev-ref HEAD)
head=$(git -C "$dir" rev-parse HEAD)
origin=$(git -C "$dir" rev-parse origin/main)
dirty=$(git -C "$dir" status --porcelain --untracked-files=no | wc -l | tr -d ' ')
printf '%s\t%s\t%s\t%s\n' "$r" "$br" "$head" "$(git -C "$dir" log -1 --format='%cs %s' HEAD | cut -c1-100)" >>"$OUT/shas.tsv"
echo "$r: ветка $br, HEAD ${head:0:7}, origin/main ${origin:0:7}, изменённых файлов $dirty"
[ "$br" = main ] && [ "$head" = "$origin" ] && [ "$dirty" = 0 ]
}
st_preflight() {
: >"$OUT/shas.tsv"
local bad=0 r
for r in $REPOS; do step preflight "repo-$r" repo_state "$r" || bad=1; done
step preflight docker docker info --format '{{.ServerVersion}}' || bad=1
step preflight tools sh -c 'command -v uv && command -v npm && command -v openspec' || bad=1
# Чужие потребители общей NATS (arm-ka): контейнеры сервисов и локальное ядро на :8000 перехватывают сообщения
# queue group, интеграционные тесты и e2e тогда падают по таймауту (scan-gateway — 08.10, arm-core e2e — docstring)
step preflight shared-consumers sh -c '
running=$(docker ps --filter label=com.docker.compose.project=arm-ka --format "{{.Names}}" | grep -E "arm-core-1|tabular-parser|scan-gateway|scan-emulator" || true)
local8000=$(lsof -nP -iTCP:8000 -sTCP:LISTEN 2>/dev/null | tail -n +2 || true)
[ -z "$running" ] && [ -z "$local8000" ] && { echo "сервисов на общей NATS нет"; exit 0; }
echo "на общем стенде arm-ka работают потребители NATS — остановите их до integration/e2e:"; echo "$running"; echo "$local8000"; exit 1' || bad=1
if [ $bad -ne 0 ] && [ $ALLOW_DIRTY -eq 0 ]; then
log "preflight не пройден — прогон остановлен (--allow-dirty — прогнать как есть)"
return 2
fi
[ $bad -ne 0 ] && echo "allow-dirty" >"$OUT/allow-dirty"
return 0
}
# --- окружения -------------------------------------------------------------------------------------------------------
st_venv() {
local r
for r in docs arm-statements tabular-parser scan-gateway arm-core; do step venv "$r" mk "$r" venv; done
}
# --- lint + unit (без инфраструктуры) --------------------------------------------------------------------------------
st_static() {
step static docs-check mk docs check
step static docs-statements-check mk docs statements-check
step static arm-statements-lint mk arm-statements lint
step static arm-statements-test mk arm-statements test
local r
for r in tabular-parser scan-gateway arm-core; do
step static "$r-lint" mk "$r" lint
step static "$r-test" mk "$r" test
done
step static arm-core-dbml-check mk arm-core dbml-check
}
# --- фронт arm-core/web ----------------------------------------------------------------------------------------------
# В чистой копии web/ (без node_modules и dist) во временном каталоге, main не меняется (npm install в main переписывает
# package-lock.json, а node_modules main бывают собраны под другую ОС). Шаг npm-ci — воспроизводимость lock: красный,
# если lock расходится с package.json. Остальные шаги идут на установке без lock (npm-install): lock собран на linux, и
# по нему npm не ставит нативные пакеты другой ОС (biome, rspack, canvas — npm/cli#4828); так же ставит и контур, в
# снимке lock нет (dev/contour-transfer-guide.md §3.2).
WEB_TMP="" STAND_UP=0
PLAYWRIGHT="${PLAYWRIGHT:-playwright==1.55.0}"
web_copy() {
WEB_TMP=$(mktemp -d "${TMPDIR:-/tmp}/regress-web.XXXXXX")
rsync -a --exclude node_modules --exclude dist "$WS/arm-core/web/" "$WEB_TMP/"
echo "$WEB_TMP"
}
web_install() { rm -rf "$1/node_modules" "$1/package-lock.json" && npm --prefix "$1" install --no-audit --no-fund; }
st_web() {
web_copy >/dev/null
local w="$WEB_TMP"
echo "$w" >"$OUT/web-tmp"
step web npm-ci npm --prefix "$w" ci --no-audit --no-fund --dry-run
step web npm-install web_install "$w"
step web biome sh -c "cd '$w' && npx biome check src"
step web tsc sh -c "cd '$w' && npx tsc --noEmit"
step web eslint-prettier npm --prefix "$w" run lint
step web vitest npm --prefix "$w" test
step web build npm --prefix "$w" run build
}
# --- интеграционные (общая инфраструктура arm-ka: тесты создают свои БД, рабочую armka не трогают) ----------------------
st_integration() {
step integration infra-up mk scan-gateway up
step integration arm-core-check-infra mk arm-core check-infra
step integration tabular-parser mk tabular-parser test-integration
step integration scan-gateway mk scan-gateway test-integration
# включает цикл миграций upgrade → downgrade base → upgrade и сверку с create_all (tests/test_migrations.py)
step integration arm-core mk arm-core test-integration
}
# --- реальные данные -------------------------------------------------------------------------------------------------
st_fixtures() {
step fixtures tabular-parser-fixtures mk tabular-parser test-fixtures
step fixtures tabular-parser-golden mk tabular-parser golden-check
}
# --- образы ----------------------------------------------------------------------------------------------------------
# Сборка профиля services собирает arm-core:dev, tabular-parser:dev, scan-gateway:dev из main (Dockerfile в корне репо);
# эти же образы потом поднимают e2e и стенд. Эквивалент make docker-build трёх сервисов.
st_images() {
step images build dcr build
}
# --- e2e ядра с настоящим tabular-parser (общая NATS/MinIO из .env.test) ---------------------------------------------
e2e_run() {
local was rc
was=$(docker ps -q --filter name=arm-ka-tabular-parser-1)
dc -p arm-ka --profile services up -d --no-build --wait tabular-parser || return 1
mk arm-core e2e
rc=$?
[ -z "$was" ] && dc -p arm-ka --profile services stop tabular-parser
return $rc
}
st_e2e() {
step e2e arm-core-osv-ingest e2e_run
# пропуск («профиль services не поднят») даёт код 0 — его считаем красным
if grep -q "SKIPPED" "$OUT/logs/e2e.arm-core-osv-ingest.log"; then
printf 'e2e\tnot-skipped\t1\t0\t%s\n' "$OUT/logs/e2e.arm-core-osv-ingest.log" >>"$RESULTS"
log "e2e/not-skipped КРАСНЫЙ: e2e пропущен"
fi
}
# prev_run <подкаталог/файл>: каталог <подкаталог> последнего прошлого прогона рядом с $OUT, где файл есть (база сравнения)
prev_run() {
local f
f=$(find "$(dirname "$OUT")" -mindepth 3 -maxdepth 3 -path "*/$1" ! -path "$OUT/*" 2>/dev/null | sort | tail -1)
[ -n "$f" ] && dirname "$f"
}
# --- изолированный стенд с нуля: миграции, seed --dev, сценарий по API -----------------------------------------------
stand_down() { dcr down -v --remove-orphans; }
st_stand() {
local stand_base
stand_base=$(prev_run stand/report.json)
step stand down-before stand_down
step stand up dcr up -d --no-build --wait \
postgres nats minio arm-core tabular-parser scan-gateway scan-emulator
step stand scenario python3 -I "$HERE/stand_scenario.py" --api "http://127.0.0.1:$ARM_CORE_PORT" \
--fixtures "$WS/fixtures" --expected "$HERE/expected.json" --out "$OUT/stand" \
${stand_base:+--baseline "$stand_base"}
STAND_UP=1
}
# Стенд снимается после ui (экраны смотрят на его данные); --keep-stand — оставить для ручной проверки
stand_teardown() {
[ "$STAND_UP" = 1 ] || return 0
dcr logs --no-color >"$OUT/logs/stand.compose.log" 2>&1
if [ $KEEP_STAND -eq 1 ]; then
log "стенд оставлен: проект $REGRESS_PROJECT, ядро http://127.0.0.1:$ARM_CORE_PORT (снять: docker compose -p $REGRESS_PROJECT --profile services down -v)"
else
step stand down-after stand_down
fi
}
# --- UI-смоук: dev-сервер фронта (копия web/ из этапа web) против стенда, Playwright по основным экранам --------------
WEB_PORT=13000
st_ui() {
[ "$STAND_UP" = 1 ] && [ -f "$OUT/stand/report.json" ] || {
printf 'ui\tstand-required\t1\t0\t-\n' >>"$RESULTS"
log "ui: стенд не поднят в этом запуске (этап stand) — КРАСНЫЙ"
return 1
}
if [ -z "$WEB_TMP" ]; then
web_copy >/dev/null
step ui npm-install web_install "$WEB_TMP"
fi
step ui browser uv run --with "$PLAYWRIGHT" playwright install chromium
(cd "$WEB_TMP" && REACT_APP_ROOT_PATH=/ ARM_CORE_URL="http://127.0.0.1:$ARM_CORE_PORT" \
exec npx rsbuild dev --port $WEB_PORT) >"$OUT/logs/ui.web-server.log" 2>&1 &
step ui web-server sh -c "for i in \$(seq 1 60); do curl -sf -o /dev/null http://127.0.0.1:$WEB_PORT/ && exit 0; sleep 2; done;
echo 'dev-сервер фронта не поднялся за 120 с'; tail -20 '$OUT/logs/ui.web-server.log'; exit 1" || return 1
step ui smoke uv run --with "$PLAYWRIGHT" python "$HERE/ui_smoke.py" --web "http://127.0.0.1:$WEB_PORT" \
--api "http://127.0.0.1:$ARM_CORE_PORT" --stand-report "$OUT/stand/report.json" --out "$OUT/ui"
lsof -ti "tcp:$WEB_PORT" -sTCP:LISTEN | xargs kill 2>/dev/null
return 0
}
# --- парсер по банку первички (doc-bank) со сравнением с прошлым прогоном --------------------------------------------
st_bank() {
[ -d "$WS/doc-bank/files" ] || { log "bank: нет $WS/doc-bank — этап пропущен"; return 0; }
local base="${BANK_BASELINE:-$(prev_run bank/results.jsonl)}"
step bank parse "$WS/tabular-parser/api/.venv/bin/python" -I "$HERE/parser_bank.py" \
--parser-api "$WS/tabular-parser/api" --bank "$WS/doc-bank" --out "$OUT/bank" ${base:+--baseline "$base"}
}
# --- итог ------------------------------------------------------------------------------------------------------------
summary() { python3 -I "$HERE/summary.py" "$OUT" "$ALL_STAGES"; }
touch "$RESULTS"
# Повторный запуск этапов в тот же --out: строки этих этапов из прошлого запуска убираем, остальные остаются в итоге
for s in $ALL_STAGES; do
want "$s" || continue
awk -F'\t' -v s="$s" '$1 != s' "$RESULTS" >"$RESULTS.tmp" && mv "$RESULTS.tmp" "$RESULTS"
done
log "регрессия: workspace $WS, результат $OUT"
rc=0
for s in $ALL_STAGES; do
want "$s" || continue
"st_$s"
src=$?
if [ "$s" = preflight ] && [ $src -eq 2 ]; then rc=2; break; fi
done
stand_teardown
summary
[ -n "$WEB_TMP" ] && rm -rf "$WEB_TMP"
# awk завершается кодом 0, если есть красный шаг
if [ $rc -eq 0 ] && awk -F'\t' '$3 != 0 { bad = 1 } END { exit !bad }' "$RESULTS"; then rc=1; fi
log "итог: $OUT/summary.md (код $rc)"
exit $rc