gendesign/.forgejo/workflows/ci-tradein.yml
bot-backend 4ef8bc818b
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 59s
CI Trade-In / frontend-checks (pull_request) Successful in 1m5s
CI Trade-In / backend-tests (pull_request) Successful in 4m53s
ci(tradein): гейт невалидных индексов — деплой ловил их уже на проде
Оборванный CREATE INDEX CONCURRENTLY оставляет индекс с indisvalid=false:
планировщик им не пользуется, ошибки нет, а re-run миграции с IF NOT EXISTS
видит его как существующий и молча пропускает. До сих пор это ловил только
шаг 3b деплоя — то есть после раскатки на прод, ценой красного деплоя и
ручного DROP INDEX CONCURRENTLY в окне.

Тот же запрос теперь стоит в CI сразу после сборки схемы из 252 миграций.
Проверка в деплое ОСТАЁТСЯ: CI собирает схему с нуля и по построению не
может воспроизвести оборванный CIC на живой базе — это разные детекторы,
не дубликаты. CI ловит миграцию, которая рождает невалидный индекс из
чистой схемы; деплой ловит след аварии на проде.

Оба пути проверены на реальной базе (prod tradein-postgres): штатный
предикат → invalid=0, гейт зелёный; инвертированный → 358, ветка падения
срабатывает и печатает список idx/tbl.

Refs #2990
2026-08-27 18:47:09 +03:00

394 lines
27 KiB
YAML
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.

name: CI Trade-In
# Forgejo Actions pre-merge gate for the SUBPROJECT tradein-mvp/.
# WHY THIS FILE EXISTS (#2208): основной .forgejo/workflows/ci.yml гейтит
# ТОЛЬКО backend/** + frontend/** главного стека — tradein-PR проходили на
# пусто-зелёных чеках (paths-filter no-op), а pytest tradein жил лишь в
# post-merge deploy-tradein.yml. Итог: сломанный tradein-код мержился в main
# и обнаруживался только на деплое. Этот workflow добавляет РЕАЛЬНЫЙ pre-merge
# gate: tradein-backend pytest + tradein-frontend type-check/lint ДО мержа.
on:
# ТОЛЬКО pull_request — НЕТ push-триггера на feature-ветки (CI-шторм #1709,
# см. подробное обоснование в ci.yml). Кратко: раньше push+pull_request на один
# SHA давали разный github.ref → разные concurrency-группы → 2× прогон на
# дефицитных раннерах. В bot-пайплайне каждый коммит идёт через PR, так что
# pull_request гейтит его полностью; push-прогон был чистым дублем.
pull_request:
branches: [main]
concurrency:
# github.ref стабилен на весь PR (refs/pull/<N>/merge) → новый push в ветку PR
# отменяет предыдущий незавершённый прогон ЭТОГО PR вместо накопления.
group: ci-tradein-${{ github.ref }}
cancel-in-progress: true
jobs:
# Paths-filter: гейт бежит ТОЛЬКО когда поменялся tradein-код.
# PR не трогающий tradein-mvp/ → оба job'а no-op'ятся → дёшево.
changes:
runs-on: ubuntu-latest
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
browser: ${{ steps.filter.outputs.browser }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
backend:
- 'tradein-mvp/backend/**'
- 'tradein-mvp/packages/**'
# workspace root: lock-only bump (uv lock --upgrade) или правка
# [tool.uv.workspace] меняют реальные зависимости → гейт обязан бежать.
- 'tradein-mvp/uv.lock'
- 'tradein-mvp/pyproject.toml'
# auth/roles.yaml — общий RBAC-конфиг обоих стеков, лежит В КОРНЕ
# репы и монтируется в tradein-backend (/app/auth/roles.yaml).
# tests/test_rbac.py читает именно его, поэтому правка ролей обязана
# гонять и этот гейт. Без строки правка roles.yaml не запускала НИ
# ОДИН сьют (та же дыра закрыта симметрично в ci.yml) — так на main
# уехал красный test_get_role_known_users (2026-07-30 → PR #2587).
- 'auth/**'
# Реестр городов — фронтовый файл, но его читает БЭКЕНДОВЫЙ тест
# (tests/test_public_mera_api.py сверяет то, что мы предлагаем
# выбрать, с тем, на что умеет отвечать проба покрытия). Без этой
# строки правка одного лишь дропдауна не гоняла бы сверку — а
# разошлись списки ровно так: город добавили на фронте, в пороги
# покрытия не внесли, и житель Серова получал «вы вне области».
- 'tradein-mvp/frontend/src/lib/city-registry.ts'
- '.forgejo/workflows/ci-tradein.yml'
frontend:
- 'tradein-mvp/frontend/**'
# Caddyfile — по той же причине, что auth/** у бэкенда: он лежит в
# КОРНЕ репы, но его читает фронтовый тест
# (mera-public/__tests__/public-perimeter.test.ts) — тот сверяет,
# что каждый маршрут публичного сайта действительно раздаётся на
# meraocenka.ru. Без этой строки правка одного лишь Caddyfile не
# запускала бы НИ ОДИН гейт, и удаление короткого адреса из
# allowlist уехало бы на main зелёным — а на сайте кнопка «Проверить»
# стала бы ссылкой в 404.
- 'Caddyfile'
- '.forgejo/workflows/ci-tradein.yml'
browser:
# Сайдкар — сервис ВНЕ uv-воркспейса (tradein-mvp/pyproject.toml
# members = backend + packages/*), со своим Dockerfile и без pyproject,
# поэтому и фильтр отдельный: backend-гейт его тестов не видел вовсе.
- 'tradein-mvp/browser/**'
- '.forgejo/workflows/ci-tradein.yml'
backend-tests:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.backend == 'true'
# Postgres-сервис (#2745). ДО него лэйн был mock-only: DATABASE_URL указывал на
# заведомо мёртвый `localhost:5432/test`, и девять тестов с `_live_session()`
# self-skip'ались — в CI они не бежали НИ РАЗУ. Так и разъехался со схемой
# test_house_dedup_merge (#2740: houses.url стал NOT NULL), а
# test_gar_flats_loader вообще падал до первого утверждения (#2744).
#
# Замер перед включением: полный сьют на mock-лэйне 122с / 3858 passed / 10 skipped,
# тот же сьют против живой БД — 106с / 3867 passed / 1 skipped. Живая БД не
# медленнее, поэтому НЕ добавляем второй job, а чиним этот: один прогон, на
# девять реальных проверок больше. Накладные — только подъём контейнера и
# bootstrap схемы (219 файлов, ~20с).
defaults:
run:
working-directory: ./tradein-mvp/backend
env:
# Имя контейнера уникально на прогон: параллельные PR не дерутся за него.
CI_PG: ci-pg-tradein-${{ github.run_id }}
steps:
- uses: actions/checkout@v4
with:
# ПОЛНАЯ история, а не дефолтный depth=1 (#2683).
# tests/test_migration_numbering.py сверяет номер новой миграции с
# origin/main и с точкой ветвления. Ровно этот флаг их и даёт: при
# depth=0 checkout идёт refspec'ом `+refs/heads/*:refs/remotes/origin/*`
# (видно в логе прогона), при depth=1 — только `+<sha>:refs/remotes/
# pull/N/head`, то есть ни ветки main, ни общего предка в клоне нет.
# Дотянуть main отдельным `git fetch` НЕЛЬЗЯ: из job-контейнера
# git.gendsgn.ru:443 недостижим (проверено, run 6977 — connection
# refused), сеть есть только у самого checkout.
#
# Гейт при отсутствии эталона краснеет, а не пропускается: молча
# пропущенная проверка и есть тот зелёный, который ничего не проверяет.
# Пак репозитория ~33 MiB — полный fetch дешевле разбора коллизии на проде.
fetch-depth: 0
- name: Поднять Postgres и собрать схему tradein
working-directory: .
# ПОЧЕМУ НЕ `services:` И ПОЧЕМУ БЕЗ ПУБЛИКАЦИИ ПОРТА.
# Раннер запускает и job, и сервис-контейнеры с `--network host` (видно в
# логе прогона: `docker create image=... network="host"`), а на 5432 того
# же хоста слушает ПРОДОВЫЙ Postgres. Попытка через `services:` +
# `ports: 5432:5432` кончилась тем, что сервис-контейнер не смог занять
# порт, а psql из job'а ушёл В ПРОД и получил
# `password authentication failed for user "tradein"`. То есть
# `localhost:5432` из job'а на этом раннере — боевая база, а не тестовая.
# Поэтому контейнер поднимаем сами, в bridge-сети, БЕЗ публикации порта,
# и ходим по его собственному IP: прод недостижим в принципе, параллельные
# прогоны не конфликтуют, psql берём из самого контейнера.
#
# ОДИН шаг, а не два: между шагами контейнер успевал исчезнуть, и
# bootstrap падал на `container is not running`.
#
# `pg_isready -h 127.0.0.1`, а НЕ через unix-сокет: на время initdb образ
# поднимает ВРЕМЕННЫЙ сервер с listen_addresses='' — по сокету он уже
# отвечает «accepting connections», хотя снаружи БД ещё не существует, а
# впереди рестарт. Проба по TCP зеленеет только на настоящем сервере —
# том самом, к которому пойдут тесты.
#
# postgis, не plain postgres: tests/tasks/test_cadastral_geo_match.py
# проверяет KNN по geometry (PostGIS_Version() в connectivity-probe).
# Имя БД ОБЯЗАНО отличаться от `test`: `_live_session()` считает DSN с
# `localhost:5432/test` заглушкой и вернул бы None — контейнер поднялся
# бы, а тесты всё равно скипались.
run: |
set -u
docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
docker run -d --name "$CI_PG" \
-e POSTGRES_DB=tradein -e POSTGRES_USER=tradein -e POSTGRES_PASSWORD=tradein \
postgis/postgis:16-3.4
ready=""
for _ in $(seq 1 45); do
if docker exec "$CI_PG" pg_isready -h 127.0.0.1 -U tradein -q 2>/dev/null; then
ready=1; break
fi
[ "$(docker inspect -f '{{.State.Status}}' "$CI_PG" 2>/dev/null)" = "running" ] || break
sleep 2
done
if [ -z "$ready" ]; then
echo "::error::Postgres не поднялся; статус=$(docker inspect -f '{{.State.Status}} exit={{.State.ExitCode}}' "$CI_PG" 2>&1)"
docker logs --tail 50 "$CI_PG" 2>&1 || true
exit 1
fi
ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$CI_PG")
[ -n "$ip" ] || { echo "::error::не удалось узнать IP контейнера $CI_PG"; exit 1; }
echo "DATABASE_URL=postgresql+psycopg://tradein:tradein@${ip}:5432/tradein" >> "$GITHUB_ENV"
echo "✓ Postgres на ${ip}:5432 (контейнер $CI_PG)"
# Тот же порядок и тот же строгий режим, что в deploy-tradein.yml:
# `ls | sort` + ON_ERROR_STOP=on, падение любой миграции → job RED.
# Никаких «применилось как получилось»: схема в CI либо та же, что на
# проде, либо гейта нет.
docker exec -i "$CI_PG" psql -U tradein -d tradein -v ON_ERROR_STOP=on -q -c \
"CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE ROLE gendesign_reader;"
# Исключений НЕТ (#2990). Раньше здесь пропускалась 077 — единственная
# миграция, читающая foreign table через postgres_fdw, которой в CI нет.
# Пропуск означал, что гейт не проверял ровно тот файл, который потом
# ронял чистый старт на реальном железе. Теперь 077 сама выходит раньше
# обращения к FDW, если мигрировать нечего, и в CI проходит честно.
for sql_file in $(ls -1 tradein-mvp/backend/data/sql/*.sql | sort); do
fname=$(basename "$sql_file")
docker exec -i "$CI_PG" psql -U tradein -d tradein -v ON_ERROR_STOP=on -q < "$sql_file" \
|| { echo "::error::миграция $fname не применилась"; docker logs --tail 20 "$CI_PG" 2>&1 || true; exit 1; }
done
echo "✓ схема собрана: $(docker exec "$CI_PG" psql -U tradein -d tradein -tAc \
"SELECT count(*) FROM information_schema.tables WHERE table_schema='public'") таблиц"
# Гейт невалидных индексов (#2990). Оборванный CREATE INDEX CONCURRENTLY
# оставляет индекс с indisvalid=false: планировщик им не пользуется,
# ошибки нет, а re-run миграции с IF NOT EXISTS видит его как
# существующий и молча пропускает. Тот же запрос стоит в деплое
# (deploy-tradein.yml, шаг 3b) — там он ловит битые индексы на живом
# проде; здесь он ловит миграцию, которая рождает невалидный индекс
# прямо из чистой схемы, до раскатки.
invalid=$(docker exec "$CI_PG" psql -U tradein -d tradein -tAc "SELECT count(*)
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE NOT i.indisvalid
AND n.nspname NOT IN ('pg_catalog', 'information_schema')") \
|| { echo "::error::не удалось прочитать pg_index"; exit 1; }
if [ "${invalid:-0}" != "0" ]; then
echo "::error::после применения миграций невалидных индексов: $invalid"
docker exec "$CI_PG" psql -U tradein -d tradein -c "SELECT i.indexrelid::regclass AS idx, i.indrelid::regclass AS tbl
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE NOT i.indisvalid
AND n.nspname NOT IN ('pg_catalog', 'information_schema')" 2>&1 || true
exit 1
fi
echo "✓ невалидных индексов нет"
- name: Install uv
# Официальный standalone-инсталлер. НЕ astral-sh/setup-uv — он ломается
# на Forgejo-runner с PEP 668 externally-managed-environment (#666 CI).
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Cache uv packages
# Кросс-прогонный кэш скачанных/собранных wheel'ов (~/.cache/uv).
# continue-on-error чтобы сбой cache-бэкенда раннера НИКОГДА не ронял gate.
# Ключ по workspace-локу tradein-mvp/uv.lock (tracked с воркспейса #2137).
uses: actions/cache@v4
continue-on-error: true
with:
path: ~/.cache/uv
key: uv-tradein-${{ runner.os }}-${{ hashFiles('tradein-mvp/uv.lock') }}
restore-keys: |
uv-tradein-${{ runner.os }}-
- name: Sync deps (incl. dev group — pytest, ruff)
# Workspace-лок tradein-mvp/uv.lock TRACKED (с воркспейса #2137; gitignored
# только старый backend/uv.lock) → --frozen детерминирован и зеркалит
# Dockerfile (uv sync --frozen --no-dev там). uv находит workspace root
# вверх от cwd.
run: uv sync --frozen
- name: Lint (ruff check)
# Правила выбраны в tradein-mvp/backend/pyproject.toml ([tool.ruff.lint]
# select = E F I B UP N RUF), но до этого шага их никто не гонял в CI —
# "дерево чистое" было непроверенным утверждением, а не гарантией.
# Версия ruff — та же, что в tradein-mvp/uv.lock (--frozen из шага выше),
# т.е. ровно то, что видит `uv sync --frozen` в Dockerfile.
# Blocking: любое нарушение → job RED (не декоративно).
run: uv run ruff check .
- name: Run pytest (tradein-mvp/backend)
# БЕЗ deselect'ов — сьют гоняется целиком (#2722).
#
# Здесь два года жил `--deselect tests/test_search_api.py::test_search_cache_hit`
# с объяснением «падает ТОЛЬКО в whole-suite ordering, в изоляции проходит —
# global-state leak из другого модуля». Объяснение было неверным в обеих
# половинах: тест падал и в изоляции тоже (401 vs 200), потому что он —
# единственный HTTP-тест в своём файле — ходил в /api/v1/search БЕЗ заголовка
# X-Authenticated-User, а RBAC-гард отвечает на такое 401 (ровно то, что
# фиксирует tests/test_estimate_idor.py). Причина была в тесте, а не в порядке;
# заголовок добавлен, deselect снят, полный прогон зелёный.
#
# Не добавлять сюда новые deselect'ы: молча выключенный тест — это тот же
# класс дефекта, что каталог вне пайплайна (#2722). Тест либо чинится, либо
# помечается xfail с причиной В КОДЕ, где её видно рядом с самим тестом.
#
# NB: в deploy-tradein.yml (post-merge test-job) свой экземпляр этого
# deselect'а — он остаётся до #2680, который правит тот файл. Расхождение
# безвредно: pre-merge гейт тест гоняет, post-merge просто пропустит зелёный.
#
# `-rs` (#2745) — КАЖДЫЙ пропуск печатает свою причину в лог job'а. Без него
# `-q` рисует пропуск точкой `s`, неотличимой на глаз от прогона: ровно так
# девять DB-тестов «шли зелёными», ничего не проверяя. Пропуск, который не
# называет себя вслух, со временем перестаёт быть верным.
run: uv run pytest -q -rs
- name: Снести тестовый Postgres
# if: always() — контейнер уходит и когда сьют красный, и когда прогон
# отменён concurrency-группой. Иначе на раннере копятся мёртвые контейнеры.
if: always()
working-directory: .
run: docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
# Тесты браузерного сайдкара (#2722). До этого job'а они не бежали НИГДЕ:
# ci-tradein гейтил только backend/frontend, deploy-tradein — тоже, а каталог
# вне uv-воркспейса, так что и `uv run pytest` из backend их не собирал. Итог:
# 4 теста лежали красными на main (с 2026-06-20 и 2026-07-02), файл при этом
# правился, и никто не узнал. Починка — PR #2724, этот job закрывает причину.
#
# Почему НЕ переиспользуем backend-job:
# 1. сайдкар не член воркспейса → `uv sync --frozen` его не ставит;
# 2. aiohttp (единственная не-stdlib зависимость сьюта) нет в tradein-mvp/uv.lock;
# 3. разный scope paths-filter: правка browser/ не должна гонять backend-сьют.
browser-tests:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.browser == 'true'
# Сьют идёт ~15с. Лимит — страховка от зависшего теста: у сайдкара нет своего
# pyproject, а значит и pytest-timeout'а backend'а (timeout=120). Дешевле
# взять нативный job-таймаут, чем тащить плагин ради одного каталога.
timeout-minutes: 10
defaults:
run:
working-directory: ./tradein-mvp/browser
steps:
- uses: actions/checkout@v4
- name: Set up Python
# 3.12 — как в browser/Dockerfile (FROM python:3.12-slim).
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install test deps
# ВЕСЬ список: pytest + aiohttp. Ни playwright, ни camoufox, ни закачки
# Firefox — camoufox импортируется ЛЕНИВО внутри _launch_browser
# (server.py, `from camoufox.async_api import AsyncCamoufox`), а сами тесты
# мокают _ensure_browser/_do_fetch и грузят server.py по пути через importlib.
# pytest-asyncio тоже НЕ нужен: ни одного `async def test_` — каждый тест сам
# крутит asyncio.run(). Проверено локально на venv ровно из этих двух пакетов.
#
# aiohttp без пина — ровно как в browser/Dockerfile (`pip install ... aiohttp`),
# то есть гейт видит ту же версию, что уедет в образ. Пин здесь означал бы
# проверку версии, которой в проде нет.
run: pip install pytest aiohttp
- name: Run pytest (tradein-mvp/browser)
# Каталог без pyproject/pytest.ini → дефолтная конфигурация, ничего
# не deselect'ится. Ожидание: 108 passed, 0 failed, 0 skipped.
# `-rs`: если однажды появится пропуск, он назовёт причину в логе, а не
# растворится в строке точек.
run: pytest -q -rs
frontend-checks:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.frontend == 'true'
defaults:
run:
working-directory: ./tradein-mvp/frontend
steps:
- uses: actions/checkout@v4
- name: Set up Node
# Node 20 — major из tradein-mvp/frontend/Dockerfile (node:20-alpine).
# cache: npm включён с #2770 — package-lock.json теперь tracked.
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: tradein-mvp/frontend/package-lock.json
- name: Install deps (npm ci)
# ТОЧНЫЕ флаги из tradein-mvp/frontend/Dockerfile (deps stage), чтобы гейт
# видел то же дерево, что уедет в образ. `ci`, а не `install` (#2770): до
# него лока не было вовсе (лежал мёртвый pnpm-lock.yaml, из которого никто
# не ставил), и версии в CI и в прод-образе выбирались независимо по дате
# сборки — гейт проверял не тот код, который деплоится.
#
# Правишь package.json — регенерируй лок в том же PR: `npm ci` требует
# точного match и иначе роняет и этот job, и build образа.
run: npm ci --legacy-peer-deps --no-audit --no-fund
- name: Type-check (tsc --noEmit)
# Blocking: любая TS-ошибка → job RED.
run: npm run type-check
- name: Run tests (vitest)
# Blocking (#2766). До этого шага у tradein-фронта не бежало НИ ОДНОЙ
# проверки поведения: лэйн гейтил только типы и статический анализ, а оба
# молчат про то, что видит пользователь — пустое поле, погашенное число,
# отказ по частоте. Инфраструктура не изобретена, а взята у соседнего
# frontend/ (vitest + jsdom + testing-library), где сьют живёт давно.
#
# Пропусков в сьюте нет и быть не должно: сторож пропусков
# (tests/skip_allowlist.txt) — pytest-only, у vitest такого нет, поэтому
# пропуск здесь стал бы ровно тем незаметным «зелёным», который #2722
# запретил на бэкенде. Тест либо чинится, либо помечается `.fails`
# с причиной В КОДЕ.
run: npm test
- name: Lint (next lint)
# Blocking: любая ESLint-ошибка → job RED.
run: npm run lint
- name: Mera-public isolation guard (#2631)
# Blocking: статический import-graph публичного лэндинга не должен
# достигать закрытого контура (useMe/lib/api/sessionId/isPathAllowed/
# GuardedRoute вне next/dynamic). Инвариант этапа 1 #2545.
run: npm run check:mera-public-isolation