All checks were successful
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m8s
CI / openapi-codegen-check (pull_request) Successful in 2m3s
CI / backend-tests (pull_request) Successful in 17m40s
Две дыры из deep-ревью PR #3506 — обе про «отказ выглядит как успех». M1. `stale=$(docker inspect … | while …)` под `set -eu` без `pipefail` (в POSIX-sh его нет) отдаёт статус `while`, то есть всегда 0. Провал `docker inspect` или пустой вывод давали пустой список → ветка «всё доехало» → `caddy reload` → зелёная джоба с надписью «окна недоступности нет» при прокси, работающем по СТАРОМУ конфигу. Ровно тот беззвучный отказ, ради которого написан скрипт. Теперь список читается отдельной командой, провал и пустой вывод считаются расхождением (fail-safe в прежнее поведение), число сверенных файлов печатается — «сверили пять» и «сверили ноль» в логе больше не выглядят одинаково. Ноль пофайловых маунтов (например, если Caddyfile переведут на именованный том) — тоже расхождение, а не тавтологически успешная сверка. M2. В фильтре `backend` (ci.yml) не было `ops/**`, а все содержательные регрессии живут в самом ops/caddy-apply.sh: гейт его ИСПОЛНЯЕТ. PR, правящий только скрипт, давал backend=false — джоба пропускается, гейт не исполняется, «пересоздавать всегда» уезжает в main зелёным. Тот же класс, что уже осуждён комментариями рядом (#2950/#3448/#3467). Мелочи оттуда же: * `[ -d "$src" ] && continue` вместо `[ -f "$src" ] || continue` — пропуск по `-f` склеивал «это каталог» (пропустить верно) и «файла на хосте нет», для которого в контейнере как раз живёт старый инод; * сообщение об отказе `caddy validate` больше не называет причиной битый конфиг, когда упасть мог и сам запуск проверочного контейнера; * в комментарии к проверке записана её граница: в полном деплое общий `up -d $UP_SERVICES` (deploy.yml:959) поднимает и caddy за ~110 строк до вызова скрипта, поэтому правка, которая одновременно ломает Caddyfile и меняет блок caddy в compose, пересоздаст контейнер раньше проверки. Гейт дорос с 16 до 22 проверок: `docker inspect` не ответил → пересоздание, ноль пофайловых маунтов → пересоздание, исчезнувший файл на хосте → пересоздание, число сверенных маунтов печатается, `caddy reload`/вызов скрипта не проглочены `|| true`, ci.yml-фильтр покрывает ops/**. Все 11 мутантов (7 новых + 4 прежних) краснеют, контроль зелёный. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
576 lines
38 KiB
YAML
576 lines
38 KiB
YAML
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: подмена фронта МЕРЫ без окна недоступности (#3274)"
|
||
# Тем же шагом-соседом и по той же причине: секунды на PR, падение
|
||
# блокирует merge.
|
||
#
|
||
# ЗАЧЕМ. Публичный лендинг лежал 30–90 с на КАЖДОМ деплое МЕРЫ —
|
||
# не потому, что подмена контейнера медленная (0,5 с), а потому, что
|
||
# `up -d` со списком сервисов делает create всех (старые контейнеры
|
||
# УДАЛЯЮТСЯ) и только потом start, дождавшись зависимостей. Лечение —
|
||
# две половинки в разных файлах: `frontend` вынесен из общей пачки в
|
||
# deploy-tradein.yml + ретрай подключения в caddy/sites/apps.caddy.
|
||
# Обе обратимы молча и незаметно (дописать frontend обратно в SERVICES
|
||
# «за компанию»; скопировать новый публичный путь с блока без импорта),
|
||
# а отказ виден только непрерывной пробой во время деплоя — то есть
|
||
# никогда, если её никто не запустил.
|
||
run: |
|
||
python3 scripts/check-frontend-swap-window.py --selftest
|
||
python3 scripts/check-frontend-swap-window.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'
|
||
# #3448: тот же класс, ещё раз. Гейт про исключающие `!`-шаблоны
|
||
# в paths-filter проверяет ВСЕ воркфлоу, а paths-filter живёт и
|
||
# здесь — без этой строки правка ci-tradein.yml с таким шаблоном
|
||
# не запустила бы backend-tests, то есть гейт не побежал бы ровно
|
||
# на той правке, от которой стережёт.
|
||
- '.forgejo/workflows/ci-tradein.yml'
|
||
# #3467/#3475: гейт backend/tests/ops/test_3467_prometheus_reload.py
|
||
# читает оба файла ниже. Без них правка, трогающая ТОЛЬКО
|
||
# deploy-metrics.yml (скажем, дописывающая `|| true` к шагу
|
||
# перезагрузки Prometheus), даёт backend=false — джоба
|
||
# backend-tests пропускается, гейт не исполняется, регрессия
|
||
# уезжает в main зелёной. Ровно то, что осуждает комментарий выше.
|
||
- '.forgejo/workflows/deploy-metrics.yml'
|
||
- 'docker-compose.metrics.yml'
|
||
# #3443: тот же класс, третий раз. Гейт
|
||
# backend/tests/ops/test_3443_caddy_reload_not_recreate.py не читает
|
||
# ops/caddy-apply.sh, а ИСПОЛНЯЕТ его с подставным `docker` — то есть
|
||
# все содержательные регрессии живут в самом скрипте, а не в
|
||
# deploy.yml. PR, правящий только ops/**, без этой строки давал бы
|
||
# backend=false: джоба пропускается, гейт не исполняется, и
|
||
# «пересоздавать всегда» (окно 67 с на всех доменах) или
|
||
# «не пересоздавать никогда» (правка конфига беззвучно не доезжает)
|
||
# уезжает в main зелёным.
|
||
- 'ops/**'
|
||
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-схемы."
|