gendesign/.forgejo/workflows/ci.yml
bot-backend f31cb56081
All checks were successful
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m12s
CI / backend-tests (pull_request) Successful in 17m35s
CI Trade-In / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Successful in 1m11s
прод: лендинг ходил в чужой бэкенд — имя сервиса двоится между продуктами
Публичный лендинг отдавался БЕЗ витрины: без ленты сделок, без строк сверки
«прогноз против факта», без подписи разброса. Страница про точность — без
единого доказательства. Отдавалось 106 КБ вместо 241 КБ.

Причина. ПТИЦА и МЕРА — разные compose-проекты, но оба назвали свой сервис
backend и оба подключены к общей сети gendesign_shared. Изнутри фронта:

    backend         → 172.18.0.6 (МЕРА) + 172.18.0.9 (ПТИЦА)
    tradein-backend → 172.18.0.6

BACKEND_URL=http://backend:8000 уводил серверный рендер в бэкенд ПТИЦЫ, тот
отвечал 401 no authenticated user, и страница рендерилась пустой.

Отказ тихий вдвойне. fetch не бросает — приходит валидный HTTP-ответ, просто
чужой. И имя двоится, поэтому часть перегенераций попадала в правильный адрес:
утром страница была с данными, к обеду без них, и это выглядело случайной
поломкой, а не ошибкой конфигурации.

DATABASE_URL болен тем же: @postgres:5432 мог уйти в базу ПТИЦЫ. Там спасало
лишь несовпадение кредов — отказ вместо тихого чтения не тех данных. Полагаться
на это нельзя: защита держится на том, что у чужой базы нет пользователя с
нашим паролем. Переведён на однозначное имя во всех трёх сервисах.

Гейт check-compose-ambiguous-hosts.py: пересечение имён сервисов обоих compose
и запрет ссылаться на них как на хост. Селфтест по конвенции соседних гейтов —
он провалился дважды на моих же фикстурах (в них не было общего имени, то есть
ловить было нечего), и это ровно то, ради чего селфтест и нужен.

Фальсификация на настоящем файле: возврат backend:8000 даёт точную строку 479,
возврат @postgres — все четыре ссылки.
2026-08-30 16:07:41 +05:00

534 lines
35 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
# 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/<branch>)
# и pull_request-событие (github.ref=refs/pull/<N>/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/<N>/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-схемы."