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//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 — только `+: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;" for sql_file in $(ls -1 tradein-mvp/backend/data/sql/*.sql | sort); do fname=$(basename "$sql_file") # ЕДИНСТВЕННОЕ исключение, и оно названо вслух: 077 — не DDL, а # backfill, читающий foreign table gendesign_rosreestr_deals из БД # ДРУГОГО стека через postgres_fdw. В CI второй БД нет, USER MAPPING # создать не из чего. На пустых таблицах backfill всё равно no-op. if [ "$fname" = "077_dedup_hash_plain_key_backfill.sql" ]; then echo "⚠ пропускаю $fname — postgres_fdw к БД gendesign, которой в CI нет" continue fi 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'") таблиц" - 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