name: CI # Forgejo Actions pytest gate for the MAIN backend (backend/). # WHY THIS FILE EXISTS: Forgejo runs ONLY .forgejo/workflows/* — the # .github/workflows/ci.yml pytest gate does NOT execute on git.gendsgn.ru # (proven: Forgejo Actions runs показывают только deploy/build jobs). Без этого # backend-изменения мержились + деплоились БЕЗ автотестов → live-баг #994 # (district 500) уехал в прод необнаруженным. Этот workflow добавляет реальный # gate: backend-сьют GREEN (1687 passed / 0 failed) после CI-rehab 1+2. # # Lane = MOCK-ONLY (day 1): нет postgres service-контейнера — сьют мокает БД, # единственный real-Postgres тест (tests/sql/ mv_layout) self-skip'ается через # connectivity-probe. PDF-тесты (WeasyPrint) РЕАЛЬНО ИДУТ здесь (libpango # установлен ниже), тогда как на macOS-dev они runtime-skip'аются. # # FUTURE: захочется добавить сюда живой postgis и гонять mv_layout — ⚠️ НЕ через # `services:` с публикацией порта (#2757). Раннер запускает и job, и сервис- # контейнеры с `--network host`, а на 5432 этого же хоста слушает БОЕВОЙ # Postgres: контейнер порт не займёт, а `localhost:5432` из job'а — это прод. # В #2745 так и вышло, спасло только несовпадение пароля. Образец правильного # способа (docker run в bridge-сети БЕЗ публикации, готовность по TCP, коннект # по IP контейнера) — в .forgejo/workflows/ci-tradein.yml, шаг «Поднять Postgres # и собрать схему tradein». В .github/workflows/ci.yml лежит ровно анти-пример # (`ports: 5432:5432`) — он безвреден только потому, что GitHub Actions у нас не # исполняется; копировать оттуда нельзя. Гейт ниже (Guard: host-port collisions) # уронит сборку, если такая публикация всё же появится. on: # ТОЛЬКО pull_request — НЕТ push-триггера на feature-ветки (CI-шторм #1709). # WHY: раньше был и push: [feat/**,fix/**,...]. Каждый коммит в ветку с открытым # PR триггерил ДВА прогона на ОДИН SHA: push-событие (github.ref=refs/heads/) # и pull_request-событие (github.ref=refs/pull//merge). Разный github.ref → # разные concurrency-группы (см. ниже) → прогоны НЕ отменяют друг друга → 2× job # при и так дефицитных раннерах. В bot-пайплайне каждый коммит идёт через PR, так # что pull_request гейтит его полностью; push-прогон был чистым дублем. # Trade-off: push в feature-ветку БЕЗ открытого PR не получит CI до открытия PR # (бот открывает PR сразу после первого push) — приемлемо. pull_request: branches: [main] concurrency: # Теперь, когда остался только pull_request, github.ref стабилен на весь PR # (refs/pull//merge) → новый push в ветку PR отменяет предыдущий незавершённый # прогон ЭТОГО PR (cancel-in-progress) вместо накопления параллельных. group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: # Paths-filter: gate бежит ТОЛЬКО когда поменялся backend-код или SQL, # которые этот backend читает (mirror deploy.yml changes-job). На чисто # frontend/docs PR job no-op'ится → дёшево. changes: runs-on: ubuntu-latest outputs: backend: ${{ steps.filter.outputs.backend }} frontend: ${{ steps.filter.outputs.frontend }} steps: - uses: actions/checkout@v4 - name: "Guard: host-port collisions in workflows (#2757)" # Шагом в changes-job, а не отдельным job'ом: этот job и так бежит на # КАЖДОМ PR и уже сделал checkout — гейт стоит ~1с и не занимает # дефицитный слот раннера. Падение = merge заблокирован. # python3 есть в образе раннера (catthehacker/ubuntu:act-latest, 3.12.3). run: | python3 scripts/check-workflow-ports.py --selftest python3 scripts/check-workflow-ports.py - name: "Guard: Caddy import покрыт volume-маунтом (#3102)" # Тем же шагом-соседом и по той же причине: дёшево, на каждом PR, # падение блокирует merge. # # ЗАЧЕМ. 2026-08-26 сюда доехал PR, который завёл `import # ../metrics-*.caddy.snippet` в caddy/sites/infra.caddy, но не добавил # bind-mount этих файлов в docker-compose.prod.yml. `caddy validate` # ниже эту дыру НЕ ловит: он копирует ВЕСЬ каталог caddy/ как есть # (`docker cp caddy ...`), а на проде смонтированы только отдельные # файлы и два каталога — расхождение между "что лежит в репозитории" и # "что реально видит контейнер" видно только на реальных маунтах. # Итог того PR: Caddy на проде не смог адаптировать конфиг, ушёл в # restart-loop и уронил ВСЕ сайты хоста на ~30 минут. run: | python3 scripts/check-caddy-snippet-mounts.py --selftest python3 scripts/check-caddy-snippet-mounts.py - name: "Guard: сервисы МЕРЫ не ссылаются на двоящееся имя" # ПТИЦА и МЕРА — разные compose-проекты, но оба назвали сервисы # `backend`/`postgres`/`frontend` и оба сидят в общей сети # gendesign_shared. Docker отдаёт на такое имя ДВА адреса, клиент # берёт любой. # # 30.08.2026 это уронило публичный лендинг: BACKEND_URL вёл в бэкенд # ПТИЦЫ, тот отвечал 401, и страница про точность рендерилась БЕЗ # ленты сделок, без строк сверки и без подписи разброса — то есть без # единого доказательства. Отказ тихий: fetch не бросает, приходит # валидный чужой ответ; а из-за двоения часть перегенераций попадала # в правильный адрес, и поломка выглядела случайной. run: | python3 scripts/check-compose-ambiguous-hosts.py --selftest python3 scripts/check-compose-ambiguous-hosts.py - name: "Guard: Caddyfile синтаксически валиден" # Тем же шагом-соседом и по той же причине, что два гейта рядом: бежит # на КАЖДОМ PR, стоит секунды, падение блокирует merge. # # ЗАЧЕМ. До 16.08.2026 конфиг прокси не проверял НИКТО — ни один # workflow не звал `caddy validate`/`adapt` (grep по .forgejo/). При # этом deploy.yml применяет его не через `reload` (тот отказался бы # принять битый конфиг и оставил бы старый работать), а через # `up -d --force-recreate caddy`: синтаксическая ошибка уводит контейнер # в crash-loop, и ложатся ВСЕ домены сразу — gendsgn.ru, meraocenka.ru, # obsidian, status. То есть цена опечатки в этом файле — полный # даунтайм, а гейта на неё не было. # # `docker cp`, а НЕ `-v "$PWD:/etc/caddy"`. Job сам исполняется внутри # контейнера, и `docker run` создаёт КОНТЕЙНЕР-БРАТ на том же демоне: # путь в `-v` резолвится на ХОСТЕ, а `$PWD` — это путь внутри job- # контейнера, которого на хосте нет. Первая версия этого шага так и # упала: `open /etc/caddy/Caddyfile: no such file or directory`. # Копирование не зависит от того, как смонтирован workspace. # # Образ тот же `caddy:2`, что в docker-compose.prod.yml — проверяем ровно # тем парсером, который будет читать конфиг на проде. # # Копируем и `caddy/` — Caddyfile делает `import caddy/users.caddy.snippet`, # и без него validate упадёт на импорте (файл в репозитории есть). # # Плейсхолдеры окружения ({env.*}) при validate резолвятся в пустую # строку — это нормально, синтаксис от их значений не зависит. run: | set -euo pipefail cid=$(docker create -w /work caddy:2 \ caddy validate --config /work/Caddyfile --adapter caddyfile) docker cp Caddyfile "$cid:/work/Caddyfile" docker cp caddy "$cid:/work/caddy" rc=0 docker start -a "$cid" || rc=$? docker rm -f "$cid" >/dev/null exit "$rc" - name: "Guard: блокирующий DDL без lock_timeout (#2752)" # Тем же шагом-соседом и по той же причине: гейт бежит на КАЖДОМ PR, # включая tradein-only (у ci.yml нет paths-фильтра на уровне workflow — # фильтруется только job backend-tests). Это важно: миграции лежат в ДВУХ # каталогах, и гейт, видимый лишь одному лэйну, пропускал бы половину. run: | python3 scripts/check-migration-lock-timeout.py --selftest python3 scripts/check-migration-lock-timeout.py - name: "Guard: shell-скрипты синтаксически валидны (#2917)" # Соседям по этому job'у (caddy validate, lock_timeout) — тот же довод: # дёшево, на каждом PR, ловит опечатку до прода. # # ЗАЧЕМ ИМЕННО ЭТО. scripts/smoke-mera-perimeter.sh — единственная # проверка, которая видит публичный периметр МЕРЫ целиком, и до этого # PR она запускалась только ночным cron'ом. Опечатка в ней обнаружилась # бы следующим утром — и выглядела бы как регресс периметра, а не как # сломанный скрипт. Ни один линтер шелла в репозитории не стоит # (shellcheck нет), поэтому берём то, что есть в каждом образе: `bash -n` # разбирает файл, не исполняя его. # # ГРАНИЦА: `bash -n` ловит СИНТАКСИС, а не смысл — неверный URL или # перепутанный ожидаемый код он не увидит. Это не замена прогона, # а защита от того, что скрипт вообще не запустится. run: | set -euo pipefail found=0 for f in $(git ls-files 'scripts/*.sh' 'ops/*.sh' 'ops/**/*.sh'); do found=$((found + 1)) bash -n "$f" || { echo "::error file=$f::синтаксическая ошибка в shell-скрипте"; exit 1; } done # Ноль файлов означал бы, что гейт молча ничего не проверяет — # ровно тот случай, когда зелёный шаг не значит ничего (#2871). if [ "$found" -eq 0 ]; then echo "::error::не найдено ни одного .sh — гейт бы прошёл впустую, проверь маску" exit 1 fi echo "✓ синтаксис проверен у $found shell-скриптов" - uses: dorny/paths-filter@v3 id: filter with: filters: | backend: - 'backend/**' - 'data/sql/**' # auth/roles.yaml — общий RBAC-конфиг ОБОИХ стеков (bind-mount в # backend и в tradein-backend). Правка ролей/пользователей меняет # поведение backend/tests/test_rbac.py, но сам файл лежит вне # 'backend/**' → без этой строки сьют no-op'ился, и правка уезжала # в main без единого прогона. Так и случилось 2026-07-30: user2 # переведён в expired, test_get_role_known_users стал красным и # доехал до main незамеченным (починен в PR #2587). - 'auth/**' # Тот же класс, что и с auth/** выше (#2950). В backend/tests/ops/ # лежат гейты на сами workflow-файлы — например «оба прод-деплоя # обязаны быть в одной группе concurrency». Правка, разводящая # группы обратно, не трогает 'backend/**' → без этих строк # backend-tests пропускался бы, гейт не исполнялся, и регрессия # уезжала в main зелёной. Гейт, который не запускается на той самой # правке, от которой стережёт, — украшение. - '.forgejo/workflows/deploy.yml' - '.forgejo/workflows/deploy-tradein.yml' - '.forgejo/workflows/ci.yml' frontend: - 'frontend/**' - '.forgejo/workflows/ci.yml' backend-tests: runs-on: ubuntu-latest needs: changes if: needs.changes.outputs.backend == 'true' # Postgres-сервис (#2745). Раньше DATABASE_URL указывал на заведомо мёртвый # хост, и весь tests/sql/ (10 тестов: #17 velocity-alerts, #99 ДДУ-индикатор, # #295 weighted AVG) self-skip'ался connectivity-probe'ом — в CI эти проверки # не бежали ни разу с момента написания. # # plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные # таблицы (CREATE TEMP TABLE) и не трогают ни geometry, ни реальную схему — # проверено локально, 16 passed за 1.3с. Поэтому и bootstrap схемы здесь не # нужен, в отличие от tradein-лэйна. # # TEST_DATABASE_URL НАМЕРЕННО НЕ задаётся: на него завязан tests/integration/ # (phantom-column gate), которому нужна КОПИЯ ПРОДОВОЙ схемы через pg_dump по # SSH-туннелю. Пустой контейнер дал бы там красноту на пустом месте, поэтому # integration остаётся честно пропущенным — с причиной в логе (`-rs`). defaults: run: working-directory: backend env: # TESTING=1 активирует RBAC-bypass (app/main.py rbac_guard пропускает # запросы при settings.testing=True) — иначе 401 на всём /api/v1. TESTING: "1" REDIS_URL: redis://localhost:6379/0 # Имя контейнера уникально на прогон: параллельные PR не дерутся за него. CI_PG: ci-pg-backend-${{ github.run_id }} steps: - uses: actions/checkout@v4 - name: Поднять Postgres для тестов working-directory: . # ПОЧЕМУ НЕ `services:` И ПОЧЕМУ БЕЗ ПУБЛИКАЦИИ ПОРТА — подробный разбор в # ci-tradein.yml (тот же раннер). Кратко: job и сервис-контейнеры идут с # `--network host`, а на 5432 этого хоста слушает ПРОДОВЫЙ Postgres, то # есть `localhost:5432` из job'а — боевая база. Поднимаем контейнер сами, # в bridge-сети, без публикации порта, ходим по его IP. # # `pg_isready -h 127.0.0.1`, а не через unix-сокет: по сокету отвечает # ВРЕМЕННЫЙ сервер фазы initdb (listen_addresses=''), после которой БД # ещё перезапускается. Проба по TCP зеленеет только на настоящем сервере. # # plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные # таблицы и не трогают ни geometry, ни реальную схему — bootstrap схемы # здесь не нужен вовсе, в отличие от tradein-лэйна. run: | set -u docker rm -fv "$CI_PG" >/dev/null 2>&1 || true docker run -d --name "$CI_PG" \ -e POSTGRES_DB=gendesign_ci -e POSTGRES_USER=gendesign -e POSTGRES_PASSWORD=gendesign \ postgres:16 ready="" for _ in $(seq 1 45); do if docker exec "$CI_PG" pg_isready -h 127.0.0.1 -U gendesign -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://gendesign:gendesign@${ip}:5432/gendesign_ci" >> "$GITHUB_ENV" echo "✓ Postgres на ${ip}:5432 (контейнер $CI_PG)" - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.12" - 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 по умолч.). # `uv sync --frozen` без него каждый прогон тянет весь geo-стек заново — # доминирующая часть времени job (#1709). Ключ по uv.lock; continue-on-error # чтобы сбой cache-бэкенда раннера НИКОГДА не ронял gate. uses: actions/cache@v4 continue-on-error: true with: path: ~/.cache/uv key: uv-${{ runner.os }}-${{ hashFiles('backend/uv.lock') }} restore-keys: | uv-${{ runner.os }}- - name: Install system deps for geo + WeasyPrint # libpq/gdal/proj/geos — geo-стек (geopandas/shapely/pyproj). # libcairo2/libpango* — нативные либы WeasyPrint: с ними PDF-тесты # (tests/test_layout_tz_pdf.py) РЕАЛЬНО ИДУТ на CI (на macOS-dev они # module-skip'аются probe'ом native-libs). Mirror .github ci.yml:55-57. run: | sudo apt-get update sudo apt-get install -y libpq-dev libgdal-dev libproj-dev libgeos-dev \ libcairo2 libpango-1.0-0 libpangoft2-1.0-0 - name: Install Python deps (incl. dev group — ruff, pytest) # backend/uv.lock закоммичен → --frozen (детерминированно, fail при # дрейфе lock vs pyproject). dev-группа ставится по умолчанию (ruff/pytest). run: uv sync --frozen - name: Lint (ruff check) # Дешёвый fail-fast. NB: `ruff format --check` НАМЕРЕННО НЕ в gate — # на момент создания 17 файлов repo-wide дали бы day-1 red. Отдельный # format-pass = future enhancement. `ruff check .` сейчас green. run: uv run ruff check . - name: Test (pytest + coverage gate) # --ignore=tests/smoke: prod-smoke бьёт по live https://gendsgn.ru # (помечены @pytest.mark.prod_smoke; pyproject addopts уже их deselect'ит, # но --ignore — belt-and-suspenders на случай сбора фикстур). # tests/integration self-skip'ается через requires_test_db (skipif на # TEST_DATABASE_URL, который тут не задан) → НЕ игнорим, оно чисто skip'ается. # tests/sql/ теперь РЕАЛЬНО ИДУТ — postgres-контейнер выше (#2745). # PDF-тесты ИДУТ (libpango выше). Target: 0 failed, skips OK. # # `-rs` (#2745): каждый оставшийся пропуск печатает причину. Под `-q` без # него пропуск неотличим от прогона — именно так проверка тихо перестаёт # исполняться и об этом узнают, когда на неё надо опереться (#2722/#2729/#2740). # # Coverage-gate (#68): --cov=app меряет покрытие пакета app/. # --cov-fail-under=65 → job RED если покрытие упало ниже baseline # (измерено 2026-06: mock-lane сьют ~71%, см. [tool.coverage] в pyproject; # 65 = floor с запасом, не flaky). На CI PDF-тесты РЕАЛЬНО идут (libpango), # поэтому реальное CI-покрытие ≥ локально-измеренного floor. # coverage.xml — артефакт для будущего Codecov/Coveralls upload (#68 badge). # term-missing → видно непокрытые строки прямо в job-логе. run: | # #2871: код возврата печатаем ЯВНО. Сводка pytest («4647 passed») уходит # в лог ДО выхода, поэтому зелёная сводка при ненулевом коде выглядит как # «job упал неизвестно где» — а падал именно этот шаг. Гейт сохраняется: # ниже `exit $rc`. rc=0 uv run pytest -q -rs --ignore=tests/smoke \ --cov=app \ --cov-report=term-missing:skip-covered \ --cov-report=xml:coverage.xml \ --cov-fail-under=65 || rc=$? echo "### pytest вернул код $rc" exit $rc - name: Coverage summary → job output # Дешёвый human-readable итог. Бежит даже если gate упал (if: always) — # чтобы было видно НАСКОЛЬКО просело покрытие, а не только "fail". # GITHUB_STEP_SUMMARY поддержан не во всех версиях Forgejo act_runner → # если переменная пустая/файла нет, печатаем в обычный лог (fallback). if: always() run: | echo "### шаг «Coverage summary» начался" [ -f coverage.xml ] || { echo "coverage.xml отсутствует — пропускаю summary"; exit 0; } # NB (#2871): `coverage report` уважает fail_under из pyproject и выходит с # кодом 2, когда порог не набран, а `run:` идёт под `bash -eo pipefail` — # то есть падение ЭТОГО шага гасит зелёный pytest и выглядит как «job упал # неизвестно где». Разделяем вычисление и вывод, чтобы код возврата был виден. # `|| cov_rc=$?`, а не отдельная строка: под `set -e` присваивание после # упавшей команды просто не выполнится, и код возврата снова потеряется. cov_rc=0 uv run coverage report --skip-covered --sort=cover > /tmp/cov_report.txt || cov_rc=$? echo "### coverage report вернул код $cov_rc" report="$(tail -40 /tmp/cov_report.txt)" if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then { echo '```'; echo "$report"; echo '```'; } >> "$GITHUB_STEP_SUMMARY" else echo "$report" fi echo "### шаг «Coverage summary» закончился успешно" - name: Снести тестовый Postgres # if: always() — контейнер уходит и когда сьют красный, и когда прогон # отменён concurrency-группой. Иначе на раннере копятся мёртвые контейнеры. if: always() working-directory: . run: | echo "### шаг «Снести тестовый Postgres» начался (CI_PG=${CI_PG:-<пусто>})" docker rm -fv "$CI_PG" >/dev/null 2>&1 || true echo "### шаг «Снести тестовый Postgres» закончился успешно" frontend-tests: runs-on: ubuntu-latest needs: changes if: needs.changes.outputs.frontend == 'true' defaults: run: working-directory: frontend steps: - uses: actions/checkout@v4 - name: Set up Node # Node 24 — совпадает с major из frontend/Dockerfile (node:24-alpine). # cache=npm + cache-dependency-path на lockfile → переиспользуем ~/.npm # между прогонами (mirror Dockerfile's `--mount=type=cache,target=/root/.npm`). uses: actions/setup-node@v4 with: node-version: "24" cache: npm cache-dependency-path: frontend/package-lock.json - name: Install deps (npm ci, frozen lockfile) # ТОЧНЫЕ флаги из frontend/Dockerfile (deps stage): # --legacy-peer-deps — Tailwind 4 alpha + React 19 peer-dep mismatches; # --no-audit --no-fund — тише и быстрее в CI. `ci` (не `install`) = # детерминированно из package-lock.json, fail при дрейфе lock vs package.json. run: npm ci --legacy-peer-deps --no-audit --no-fund - name: Test (vitest) # `npm run test` = `vitest run` (single-shot, не watch). Только тесты — # `next build` НАМЕРЕННО не здесь (тяжёлый, verified в deploy.yml build). run: npm run test # OpenAPI → TS codegen drift-gate (#69). Защищает фронт от молчаливого # рассинхрона типов: если backend изменил OpenAPI-схему, а # frontend/src/lib/api-types.ts не перегенерён (`npm run codegen`) — job RED. # # Бежит когда поменялся backend ИЛИ frontend (backend-change может застейлить # типы даже без правок во frontend/). Отдельный job (не внутри frontend-tests), # чтобы vitest и codegen-gate скейлились независимо. # # WHY no running server: `npm run codegen` бьёт по http://localhost:8000/openapi.json, # но схема = app.openapi() — её можно сдампить из python БЕЗ uvicorn/DB # (app.main импортируется под TESTING=1 со stub-DSN, как в backend-tests). # Это убирает flaky port-wait. openapi-typescript v7 принимает локальный файл. # # WHY prettier: committed api-types.ts форматируется prettier'ом через # pre-commit hook (mirrors-prettier, no config → defaults, 2-space). Raw # openapi-typescript отдаёт 4-space → diff-шум. Прогоняем prettier (defaults) # на regen, чтобы сравнивать ТОЛЬКО контент, не форматирование. openapi-codegen-check: runs-on: ubuntu-latest needs: changes if: | needs.changes.outputs.backend == 'true' || needs.changes.outputs.frontend == 'true' env: # Те же stub-переменные, что backend-tests: psycopg требует parseable URL # на импорте; реального коннекта нет (схему дампим, не обслуживаем запросы). TESTING: "1" DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test REDIS_URL: redis://localhost:6379/0 steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.12" - name: Set up Node uses: actions/setup-node@v4 with: node-version: "24" cache: npm cache-dependency-path: frontend/package-lock.json - name: Install uv run: | curl -LsSf https://astral.sh/uv/install.sh | sh echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Cache uv packages # См. backend-tests: кросс-прогонный кэш ~/.cache/uv, тот же ключ по uv.lock. uses: actions/cache@v4 continue-on-error: true with: path: ~/.cache/uv key: uv-${{ runner.os }}-${{ hashFiles('backend/uv.lock') }} restore-keys: | uv-${{ runner.os }}- - name: Install system deps for geo + WeasyPrint # app.main транзитивно тянет geo/PDF-модули. На macOS-dev импорт схемы # проходит и без этих либ, но на ubuntu ставим как backend-tests # (belt-and-suspenders, чтобы import app.main точно не падал). run: | sudo apt-get update sudo apt-get install -y libpq-dev libgdal-dev libproj-dev libgeos-dev \ libcairo2 libpango-1.0-0 libpangoft2-1.0-0 - name: Install backend deps (uv sync --frozen --no-dev) working-directory: backend # --no-dev: этот job только дампит app.openapi() (нужен runtime app.main). # pytest/ruff/coverage не используются → не ставим dev-группу (быстрее). # Dockerfile тоже собирает с --no-dev → импорт app.main гарантированно ок. run: uv sync --frozen --no-dev - name: Install frontend deps (npm ci) working-directory: frontend run: npm ci --legacy-peer-deps --no-audit --no-fund - name: Dump OpenAPI schema from app (no server) working-directory: backend run: uv run python -c "import json; from app.main import app; print(json.dumps(app.openapi()))" > /tmp/openapi.json - name: Regenerate api-types.ts + format (project-local pinned prettier) working-directory: frontend # 1) openapi-typescript из дампнутого файла (эквивалент `npm run codegen`, # который читает ту же схему по URL). 2) ./node_modules/.bin/prettier — # PROJECT-LOCAL, pinned (prettier 3.9.0 в devDependencies). НЕ `npx # prettier` (тот резолвится в плавающий latest и расходится с pre-commit, # ломая этот gate). Pre-commit hook гоняет тот же локальный prettier 3.9.0 # → байт-в-байт идентичный формат. См. .pre-commit-config.yaml. run: | npx openapi-typescript /tmp/openapi.json -o src/lib/api-types.ts ./node_modules/.bin/prettier --write src/lib/api-types.ts - name: Assert api-types.ts is up-to-date working-directory: frontend # git diff --exit-code: 0 если файл не изменился (типы актуальны), # 1 если regen дал другой результат (типы устарели → fail с подсказкой). run: | if ! git diff --exit-code -- src/lib/api-types.ts; then echo "::error::frontend/src/lib/api-types.ts устарел относительно backend OpenAPI." \ "Запусти: cd frontend && npm run codegen (с backend на :8000), затем закоммить." \ "Pre-commit prettier отформатирует автоматически." exit 1 fi echo "✓ api-types.ts актуален относительно backend OpenAPI-схемы."