Две дыры из 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>
Гейт backend/tests/ops/test_3467_prometheus_reload.py (едет в PR #3475) читает
.forgejo/workflows/deploy-metrics.yml и docker-compose.metrics.yml. Пока этих
путей нет в фильтре `backend`, правка, трогающая ТОЛЬКО deploy-metrics.yml —
например дописывающая `|| true` к шагу перезагрузки Prometheus, — даёт
backend=false: джоба backend-tests пропускается, гейт не исполняется, регрессия
уезжает в main зелёной. Это ровно тот класс, который осуждает комментарий
двумя абзацами выше в этом же файле: гейт, который не запускается на той самой
правке, от которой стережёт, — украшение.
Список правится одной веткой намеренно: параллельный PR #3475 его не трогает,
иначе две ветки подрались бы за один фильтр.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Мутационный прогон нашёл пять зелёных мутаций — то есть мест, где логику можно
сломать, а гейт этого не заметит. Закрыты фикстурами, каждая краснеет ровно на
своей мутации:
* CADDY_RE → `^(Caddyfile|caddy)`: тогда `caddy-extra/**` и `Caddyfile.bak`
дают caddy_only=true — тихий пропуск полного деплоя, против которого весь PR;
* снятие проверки «файлов больше нуля»: пустой дифф формально удовлетворяет
«ни один файл не лежит вне caddy» и отключает сборку;
* выпадение `data/sql/**` из backend: миграции едут в backend-образе;
* подмена базы на `HEAD^..HEAD`: на ОДНОМ мерж-коммите даёт верный ответ и
выглядит рабочей, а на push'е из нескольких коммитов теряет первый — фикстура
«бэкенд-коммит + caddy-коммит» это ловит;
* потеря `core.quotePath=false`: кириллический путь под backend/ выпадает из
классификации.
Плюс прод-сторож из deploy-caddy: его кусок (от PROD_HEAD до `git reset --hard`)
извлекается из ssh-скрипта и ИСПОЛНЯЕТСЯ на временном репозитории, где прод-дерево
отстаёт от origin/main — отдельно законный случай (отстал только конфиг прокси)
и отказной (отстал бэкенд). Проверяется и порядок: сторож обязан стоять ДО
`git reset`. Команда ищется регуляркой по началу строки, а не подстрокой:
`git reset --hard` упоминается выше в комментариях, и поиск по тексту находил
объяснение вместо кода.
Признак непустоты у проверки исключающих `!`-шаблонов: раньше она бы прошла при
нулевом охвате (переименуют действие, заведут .yaml) — теперь отдельно
утверждается, что хотя бы один шаг paths-filter найден, как это сделано в ci.yml
для shell-гейта. Маска расширена до *.y*ml, параметризация — по найденным шагам.
ci.yml: в фильтр `backend` добавлен `.forgejo/workflows/ci-tradein.yml` — там
тоже живёт paths-filter, и без этой строки правка с `!`-шаблоном не запустила бы
backend-tests, то есть гейт не побежал бы ровно на той правке, от которой стережёт.
Докстринг фикстуры с мержем переписан: он утверждал, что «дифф последнего
коммита» на мерж-коммите даёт пустой список (это верно для `git show`, а не для
`git diff HEAD^ HEAD`) — то есть обещал защиту, которой у этой фикстуры нет.
Теперь там сказано, что подмену базы стережёт отдельная проверка.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Только scripts/ + ci.yml: ни deploy.yml, ни deploy-tradein.yml по этим путям
не триггерятся. Мержить ПОСЛЕ частей 1 и 2a — гейт проверяет обе половины
и на main без них покраснеет.
Публичный лендинг отдавался БЕЗ витрины: без ленты сделок, без строк сверки
«прогноз против факта», без подписи разброса. Страница про точность — без
единого доказательства. Отдавалось 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 — все четыре ссылки.
Node 20 вышел из поддержки 30.04.2026: security-патчи для него больше не
выпускаются, а образ node:20-alpine продолжает собираться и молча уносить это
в прод. Node 24 — текущая Active LTS.
Меняется ровно major рантайма, больше ничего:
frontend/Dockerfile node:20-alpine → node:24-alpine (deps/builder/runner)
tradein-mvp/frontend/Dockerfile то же, три стадии
.forgejo/workflows/ci.yml node-version "20" → "24" (два джоба)
.forgejo/workflows/ci-tradein.yml то же (один джоб)
Версия в CI намеренно держится равной major'у из Dockerfile — так было и
раньше, комментарии рядом обновлены вместе с числом, чтобы не разошлись.
Ни `engines`, ни `.nvmrc` в проекте нет — других мест, где закреплён major,
не осталось (проверено grep'ом по Dockerfile/yml/md).
Совместимость: next 15.5.24 поддерживает node 20/22/24; sharp 0.35.4 — node
^18.17 || ^20.3 || >=22, prebuild linuxmusl-x64 есть.
Приёмка — этот самый CI: джобы фронтов теперь выполняются на node 24, так что
зелёный прогон PR и есть доказательство. Локально проверить нечем — на машине
node 26, это не тот major.
Follow-up к #3103. Прод лёг на ~30 минут потому, что PR завёл
`import ../metrics-*.caddy.snippet` в caddy/sites/infra.caddy, но не добавил
bind-монты этих файлов в docker-compose.prod.yml.
Соседний гард `caddy validate` эту дыру не ловит принципиально: он копирует
каталог caddy/ целиком (`docker cp caddy ...`), а на проде смонтированы только
отдельные файлы плюс два каталога. Расхождение между «что лежит в репозитории»
и «что реально видит контейнер» видно только если сверять с маунтами.
check-caddy-snippet-mounts.py разбирает bind-монты сервиса caddy:, резолвит
каждый `import` в Caddyfile / caddy/sites/*.caddy / caddy/*.caddy.snippet
относительно КОНТЕЙНЕРНОГО пути импортирующего файла и падает, если цель не
покрыта ни одним маунтом. Именованные сниппеты `(name) { }` пропускаются,
для glob/placeholder-импортов (`caddy/sites/{$CADDY_SITES:*}.caddy`)
проверяется каталог. --selftest воспроизводит ровно баг #3102.
Первая версия шага смонтировала `$PWD` внутрь caddy-контейнера и упала на
`open /etc/caddy/Caddyfile: no such file or directory`.
Причина: job сам исполняется внутри контейнера, а `docker run` создаёт
КОНТЕЙНЕР-БРАТ на том же демоне. Путь в `-v` резолвится на ХОСТЕ, тогда как
`$PWD` — путь внутри job-контейнера, которого на хосте нет. Классическая
ловушка docker-in-docker, и она не зависит от содержимого конфига — смонтируй
так что угодно, монтирования просто не произойдёт.
Заменено на `docker create -w /work` + `docker cp` + `docker start -a`: копия
не зависит от того, как смонтирован workspace. Копируется и каталог `caddy/` —
Caddyfile делает `import caddy/users.caddy.snippet`, без него validate падает
на импорте.
Проверено локально обе стороны: валидный конфиг → `Valid configuration`,
exit 0; конфиг с незакрытой скобкой → `unexpected EOF`, exit 1. Без второй
проверки гейт мог бы оказаться вечно-зелёным.
Ревью четырьмя независимыми линзами (периметр, семантика Caddy, политика ПДн
против кода, фронт) + по два проверяющих на каждую находку. Ниже — то, что
пережило проверку и воспроизведено на живом коде, а не выведено из чтения.
## Caddy: открытый редирект и потерянные ссылки
Захват хвоста регекспом (`^/trade-in/mera-public/(.+)$` → `redir /{re…1}`) —
открытый редирект. Захват берётся из РАСКОДИРОВАННОГО пути, поэтому
`/trade-in/mera-public/%5Cevil.example/pay` даёт цель `/\evil.example/pay`, а
браузеры трактуют `/\` как `//` — Location уводит на чужой хост. Готовая
фишинговая заготовка с домена, который напечатан внутри оферты и уходит
модератору эквайера. Заменено поимённым списком путей: такой адрес просто не
матчится.
Адреса со слэшем на конце (`/oferta/`, и длинные `…/oferta/`) отдавали 404 —
ровно те ссылки, ради сохранности которых редирект и делался. Добавлена
нормализация, цепочка замкнута (проверено: 2 перехода → 200).
Query-строка терялась: размещённые ссылки с UTM приходили бы в аналитику как
прямой заход. `uri strip_prefix` + `{uri}` переносит её. Обёртка `route`
обязательна — без неё `redir` выполняется раньше `uri` и Location равен
исходному адресу (бесконечный цикл, поймано на стенде).
`/v3` — черновое превью с маркетинговыми плейсхолдерами — было открыто на
боевом домене молча. Теперь названо вслух и запинено тестом.
## Гейты, которых не было
`caddy validate` не звал НИ ОДИН workflow, а deploy применяет конфиг не через
`reload` (тот отказался бы принять битый), а через `up -d --force-recreate` —
опечатка уводит контейнер в crash-loop и роняет ВСЕ домены. Добавлен гейт в
ci.yml, тем же образом caddy:2, что и на проде.
Проверка «роут ↔ Caddy» была односторонней и пропускала обратную ошибку —
путь, открытый наружу, о котором приложение не знает. Так и уехал `/v3`.
Теперь двусторонняя, плюс проверка, что для каждой страницы есть 301.
## Бюджет внешнего геокодера
Per-IP окна ограничивают одного клиента, но не сумму: 40/мин с адреса — это
57 600 в сутки при бесплатном тире DaData в 10 000, ОБЩЕМ с закрытым контуром.
Подтверждено на проде: достаточно упомянуть не-екатеринбургский город, чтобы
локальный тир отключился и запрос гарантированно ушёл во внешний сервис. То
есть один скрипт оставлял без подсказок платящих пилотов.
Per-IP снижен до 20/мин, добавлен общий суточный потолок 2000 и потолок
одновременных подсказок (4): кадастровый тир уходит в FDW-скан чужой базы,
держит соединение около секунды, а пул общий с B2B — полтора десятка
параллельных публичных запросов клали бы закрытый контур.
## «Адрес нигде не сохраняется» — теперь правда целиком
Две утечки, обе воспроизведены:
1. ЖУРНАЛЫ. Геокодер печатает введённую строку открытым текстом на каждый
вызов, прод пишет stdout в persistent journald — адрес ложился на диск
рядом с IP того же запроса в access-логе Caddy. Закрыто фильтром логов на
время публичного запроса (contextvar, переживает await и to_thread).
Закрытый контур логи сохраняет: они нужны для разбора жалоб пилотов.
2. МОНИТОРИНГ. sentry_sdk кладёт в событие ПОЛНОЕ тело запроса — а тело
публичной ручки это ровно `{"q": "<адрес>"}`; `send_default_pii=False` тут
не гейт, он про куки. Плюс брэдкрамб httpx несёт адрес в query геокодера.
Закрыто `scrub_public_address`.
Текст п. 5.4 политики расширен до «ни в журналы веб-сервера, ни в технические
журналы, ни в мониторинг» — ровно то, что теперь обеспечено кодом.
## Фронт
- Отмена запроса подсказок откладывалась внутрь следующего debounce-такта и
не наступала вовсе, если человек переставал печатать: ответ по старой строке
долетал и ложился в список. Контроллер создаётся сразу, отменяется в cleanup.
- Список схлопывался на каждое нажатие — клик по намеченному пункту
промахивался. Старая выдача висит, пока не пришла новая.
- «Комнат» с лэндинга — свободный текст: «студия» не совпадала ни с одним
option, селект показывал пустоту, parseInt давал NaN, на сервер уходил
rooms: null → 422 с текстом «сломалось на нашей стороне». Нормализация
вынесена чистой функцией и покрыта тестами.
- У пробы покрытия не было ни таймаута, ни отмены: оборванное соединение
оставляло кнопку в «Смотрим данные…» навсегда. 15 с + понятный текст.
- Ошибка подсказок глушилась в пустой список — тупик без объяснения.
- Комбобокс: Tab проваливался в кнопки подсказок, список не закрывался по
уходу фокуса и перекрывал поля, Escape оставлял висячий aria-activedescendant.
## Проверено
Локальный стенд (реальный site-блок Caddy + заглушка): 18 маршрутов, включая
`%5C`, `//`, `%2F` — все три теперь 404. vitest 55 passed, backend 17 passed по
публичному API, tsc, lint, build, isolation guard 41 файл, caddy validate.
Мутации: снять редакцию логов → падает тест журналов; не вырезать тело запроса
→ падает тест мониторинга; убрать /estimate из Caddy → падает тест маршрутов.
Диск был занят на 76% (110 из 145 ГБ). Разбор: 201 том-сирота на 12.6 ГБ —
125 анонимных (каталоги данных PostgreSQL от тестовых прогонов CI) и 76
окружений задач Forgejo Actions. Прод-данных среди них нет ни одного.
Корневая причина: ci.yml и ci-tradein.yml поднимают свой postgres и снимают
его через `docker rm -f` БЕЗ `-v`. Контейнер уходит, анонимный том с данными
остаётся сиротой — по одному на каждый прогон CI.
- ci.yml / ci-tradein.yml: `docker rm -f "$CI_PG"` → `docker rm -fv` (4 места).
В deploy-*.yml тот же вызов применяется к БОЕВЫМ контейнерам — туда -v
добавлять нельзя, снесло бы тома с данными прода. Не тронуто.
- ops/docker-prune.sh — страховка на то, что runner не убрал за собой.
Удаляет: остановленные контейнеры старше 24ч, висячие образы старше 7 суток
и тома-сироты ТОЛЬКО двух известных форм (64-символьный hex и
FORGEJO-ACTIONS-TASK-*). Именованные тома не трогаются никогда — голый
`docker volume prune` такой разницы не делает, поэтому здесь не используется.
Порядок важен: контейнеры → образы → тома, иначе освободившиеся после
контейнеров тома останутся до следующего запуска (на этом я и споткнулся
при ручной чистке — после prune осталось ещё 78 сирот).
Проверено на проде: DRY_RUN, затем боевой прогон, затем повторный — no-op.
Тома 220 → 15, все используются, освобождать нечего. Диск 76% → 67%.
Cron поставлен: вс 04:00 UTC (свободный слот рядом с бэкапами).
Две связанные вещи, обе — по решению владельца продукта.
1. auth/** в paths-фильтры обоих CI (ci.yml, ci-tradein.yml).
auth/roles.yaml — общий RBAC-конфиг двух стеков, но лежит в корне репы и не
попадал НИ В ОДИН фильтр: правка ролей/пользователей не запускала ни backend-,
ни tradein-сьют. Так 2026-07-30 в main уехал красный test_get_role_known_users
(user2 переведён в expired, тест ждал pilot) — обнаружен только вручную и
починен в PR #2587. Теперь правка roles.yaml гоняет оба гейта.
2. «Поиск домов» (/trade-in/sale-share) — ТЕСТОВЫЙ продукт, доступ только у
админа. Раньше он был закрыт от клиентских ролей (employee/manager/pilot), но
оставался открыт внутренней роли analyst. «Только у админа» включает и
внутренние роли → analyst добавлен в deny по sale-share.
Асимметрия с «Кэшем» намеренная и запиннена тестом: Кэш — не продукт, а
диагностика кэшей/скраперов, т.е. ровно тот инструмент, ради которого роль
analyst заведена; ему он оставлен.
Замеры после правки (реальный is_path_allowed поверх roles.yaml):
роль | Поиск домов | Кэш | ядро продукта
admin | True | True | True
analyst | False | True | True
pilot | False | False| True
Тест test_yaml_roles_deliberately_outside_client_deny переписан: пиннит ОБЕ
стороны асимметрии, а не только «analyst видит всё». Набор внутренних путей
разрезан на _SALE_SHARE_PATHS / _CACHE_TOOL_PATHS с assert'ом, что разрез
покрывает исходный набор целиком — иначе новый путь добавят и забудут отнести
к продукту, оставив analyst непроверенным.
Заодно поправлены устаревшие комментарии «Доступ: pilot + admin» в Caddyfile
(vanity-редирект gendsgn.ru/sale-share) и в докстринге самой страницы.
Тесты: 77 passed (tradein rbac/auth_session/auth_api) + 24 passed (site-finder).
tsc --noEmit + next build — зелёные. YAML обоих workflow провалидирован.
openapi-codegen-check был RED на каждом PR: CI шаг гонял bare `npx prettier`
(резолвится в плавающий latest), а committed api-types.ts был отформатирован
старым prettier (≤3.6.2, union-типы multi-line). 3.9.0 переносит union-wrapping
на single-line → CI-регенерация всегда давала diff → gate падал.
Чиним детерминизмом одной версии на обоих путях:
- frontend/package.json: добавлен prettier@3.9.0 (exact) в devDependencies
+ обновлён package-lock.json.
- CI openapi-codegen-check: bare `npx prettier` → ./node_modules/.bin/prettier
(project-local pinned, не плавает).
- .pre-commit-config.yaml: prettier hook additional_dependencies=["prettier@3.9.0"]
— тот же движок, что в CI (alpha.8-обёртка байт-в-байт == prettier 3.9.0).
- api-types.ts перегенерирован prettier 3.9.0 (union-типы single-line).
Backend OpenAPI-схема не менялась — только формат. CI-регенерация теперь
байт-в-байт совпадает с committed-файлом и с выводом pre-commit hook.
backend-tests и openapi-codegen-check тратят ~90% времени не на работу
(дамп схемы + pytest), а на пересборку окружения с нуля каждый прогон.
Доминанта — 'uv sync --frozen' тянет весь geo/WeasyPrint-стек заново, т.к.
~/.cache/uv не кэшировался между прогонами (npm уже кэширован setup-node).
Изменения:
- Cache uv (~/.cache/uv) через actions/cache в обоих job, ключ по backend/uv.lock,
restore-keys для частичного хита. continue-on-error → сбой cache-бэкенда
раннера не ломает gate.
- openapi-codegen-check: 'uv sync --frozen --no-dev' — job только импортирует
app.main для дампа OpenAPI, dev-группа (pytest/ruff/coverage) не нужна.
Dockerfile тоже --no-dev, поэтому импорт гарантирован.
Refs #1709
Каждый коммит в ветку с открытым PR триггерил ДВА прогона ci.yml на один
SHA: push-событие (github.ref=refs/heads/<branch>) и pull_request-событие
(github.ref=refs/pull/<N>/merge). Разный github.ref → разные concurrency-
группы → прогоны не отменяли друг друга → 2x нагрузка на и так дефицитные
2 раннера (CI-шторм 2026-06-17, деплой tradein #1699 простоял 17 мин).
В bot-пайплайне каждый коммит идёт через PR, поэтому pull_request гейтит
его полностью; push-прогон был чистым дублем. Оставляю только pull_request:
теперь github.ref стабилен на весь PR и concurrency cancel-in-progress
корректно отменяет superseded-прогоны при новом пуше.
Refs #1709
Run the merged frontend vitest suite on every frontend-touching PR so the
tests don't rot. Mirrors the #1032 backend gate: per-job dorny/paths-filter
(new `frontend` output, `frontend/**`), node:20 + npm cache on the lockfile,
`npm ci --legacy-peer-deps --no-audit --no-fund` (exact frontend/Dockerfile
flags), then `npm run test`. Additive — backend-tests job unchanged; no
top-level paths filter so frontend-only PRs already trigger the workflow.
Making frontend-tests a required check is a follow-up in the Forgejo UI.
Refs #999.
Forgejo runs only .forgejo/workflows/* — the .github pytest gate never
executed here, so backend changes merged + deployed untested (live bug
#994 district 500 shipped uncaught). Add a real gate: ruff check + the full
backend pytest suite (1687 passed) on PRs to main and feature-branch pushes,
scoped to backend/** + data/sql/** via paths-filter. Mock-only lane (no
postgres service): the one real-DB test self-skips via a connectivity probe;
WeasyPrint PDF tests RUN thanks to installed libpango. uv via official
installer (not setup-uv, PEP 668), uv sync --frozen against the committed lock,
TESTING=1 to activate the test-mode RBAC bypass.
NOTE: making this a HARD required check + having the auto-merge bot consult
check status needs Forgejo branch-protection config (human action) — until
then the gate is visible but advisory.
Refs #944.