gendesign/.forgejo/workflows/ci-tradein.yml
bot-backend 73c4c487ed
All checks were successful
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 / frontend-checks (pull_request) Successful in 1m6s
CI Trade-In / backend-tests (pull_request) Successful in 4m50s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Successful in 48s
fix(mera/b2c): житель Серова получал «вы вне области», а подсказки игнорировали выбранный город
Два дефекта, найденных прогоном сценария глазами посетителя на живом домене.

## 1. Город предлагали выбрать, но отвечать по нему не умели

Дропдаун на сайте (`OBLAST_CITIES`, city-registry.ts) и списки покрытия
(`COVERAGE_GREEN/YELLOW_CITIES`, trade_in.py) — одно множество, записанное в
двух местах. Они разошлись в обе стороны:

  предлагали, но не отвечали:  Серов
  отвечали, но не предлагали:  Берёзовский, Среднеуральск, Ревда

Житель Серова выбирал СВОЙ город из НАШЕГО дропдауна и получал:

  «Этот адрес вне области, по которой мы собираем данные.
   Сейчас это Свердловская область: Екатеринбург целиком и ещё несколько
   городов вокруг.»

Про город в той же самой области. Серов при этом покрыт данными: 363 активных
объявления в радиусе 15 км, все свежие (замер по проде). Поэтому добавлен в
жёлтый тир, а не убран из дропдаунa; три недостающих города добавлены на фронт.

Шапка city-registry.ts этот риск прямо предсказывала — «перед добавлением
7-го города сверить оба списка вручную, теста на это пока нет». Теперь тест
есть: бэкендовый сьют читает TS-реестр и требует РАВЕНСТВА множеств. Плюс
проверка, что у каждого города с порогом есть центроид, — иначе порог мёртвый,
город по координатам не резолвится.

## 2. Подсказки не слушались выбранного города

`city_hint` доезжает до геокодера, но на выдачу не влияет: его смотрит только
екатеринбургский кадастровый тир (как признак «речь не про ЕКБ, тир
пропускаем»), а DaData-тир ограничен регионом целиком и хинта не принимает.

Замер: выбран Серов, введено «Ленина 1» → первой подсказкой «Невьянский р-н,
пгт Верх-Нейвинский». Человек выбирает верхний вариант и считает чужой дом —
ровно баг #2576, ради которого город и спрашивают.

Публичная ручка теперь подставляет город в саму строку запроса. Проверено на
проде: «Серов Ленина 1» даёт серовскую выдачу целиком. Для Екатеринбурга
подстановка безвредна — три разных адреса дали тот же результат с префиксом и
без, поэтому правило одно на все города, без исключения для основного трафика.

Чинится в публичной ручке, а не в геокодере: там от `city_hint` зависит
поведение закрытого контура (`target_city_ambiguous`).

## Фикстура теста

`_FAR_AWAY_CITY` стояла в 21 км от центра Серова и работала как «далеко от
всех» лишь потому, что Серов не был поддержан. Переехала в Тавду — 271 км до
ближайшего центроида.

## Мутации

  убрать Серов из покрытия (состояние прода) → падает сверка списков
  не подставлять город в строку             → падает проверка ручки
  откат                                     → 21 passed

Плюс backend 75 passed, vitest 56 passed, tsc, lint, build, isolation guard.

`city-registry.ts` добавлен в paths-фильтр БЭКЕНДОВОГО лэйна: сверку списков
делает бэкендовый тест, и без этой строки правка одного лишь дропдауна её бы
не запускала — то есть ровно тот путь, которым списки и разошлись.
2026-08-16 13:03:19 +03:00

372 lines
25 KiB
YAML
Raw Permalink 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;"
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