From 091137a0c508c0bcf17a9602be20c38e95b36760 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 14:26:52 +0500 Subject: [PATCH 1/4] =?UTF-8?q?=D0=91=D1=8B=D1=81=D1=82=D1=80=D1=8B=D0=B9?= =?UTF-8?q?=20=D0=BF=D1=83=D1=82=D1=8C=20caddy=5Fonly:=20=D1=81=D1=87?= =?UTF-8?q?=D0=B8=D1=82=D0=B0=D0=B5=D0=BC=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=91=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B?= =?UTF-8?q?=20=D1=81=D0=B0=D0=BC=D0=B8,=20=D0=B1=D0=B5=D0=B7=20=D0=B8?= =?UTF-8?q?=D1=81=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D1=8E=D1=89=D0=B8=D1=85=20?= =?UTF-8?q?=D1=88=D0=B0=D0=B1=D0=BB=D0=BE=D0=BD=D0=BE=D0=B2=20(#3448)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Быстрый путь «правка ТОЛЬКО прокси» (#2916) не отработал ни разу: мерж 84920e6c, где в диффе один файл caddy/sites/apps.caddy, пересоздал весь стек ПТИЦЫ (gendesign-caddy-1 Created=2026-09-11T20:40:14, в логе Caddy "serving initial configuration" — холодный старт, а не reload). ПРИЧИНА НЕ ТА, ЧТО В ГИПОТЕЗЕ. Гипотеза #3448 — пустой github.event.before у мерж-коммита — опровергнута логом задачи 29244 (run 10881): Changes will be detected between 204e2e09de940f44bb6bbeb45e38b2b44d710168 and main git diff --no-renames --name-status -z 204e2e09..refs/remotes/origin/main M caddy/sites/apps.caddy Detected 1 changed files before валиден, коммит дотянут, дифф верный. Дальше в том же логе: ##[group]Filter non_caddy = true Matching files: caddy/sites/apps.caddy [modified] Исключённый файл сам себя и «исключил». dorny/paths-filter склеивает шаблоны одного фильтра через some, то есть ИЛИ (src/filter.ts: patterns.some(aPredicate), predicate-quantifier по умолчанию some), поэтому non_caddy: ['**', '!Caddyfile', '!caddy/**'] читается как «подходит под ** ИЛИ не Caddyfile ИЛИ не caddy/**» — а ** матчит всё. non_caddy был true ВСЕГДА, caddy_only — false всегда, deploy-caddy пропускался. Сигнала не было ни одного: пропущенную джобу Forgejo рисует зелёной, и «зелёный deploy-caddy» неотличим от невыполненного. ЧТО СДЕЛАНО. Job changes считает список файлов сам: git diff по явным границам (before → HEAD), флаги backend/frontend/infra/caddy_only выводятся из этого списка. Заплатки к фильтрам не годятся: predicate-quantifier: every действует на ВЕСЬ блок и сломал бы backend/frontend/infra, то есть фикс снова висел бы на незаметном умолчании. Шаг ПЕЧАТАЕТ и список файлов, и итоговые флаги — у правки должен быть наблюдаемый признак, иначе «сработало» и «просто не совпало» выглядят одинаково. База не разрешилась (ручной запуск, пустой/нулевой before, коммита нет в клоне) → изменённым считается весь репозиторий: лишний полный деплой безопаснее пропущенного. Фолбэка на HEAD^..HEAD намеренно нет — у push'а из нескольких коммитов он молча урезал бы список и включил быстрый путь там, где приехал бэкенд. Гейт backend/tests/ops/test_3448_caddy_only_detection.py ИСПОЛНЯЕТ этот шаг на временном репозитории с настоящим мерж-коммитом и проверяет значения флагов: только caddy → caddy_only=true; caddy+backend → false; база не разрешилась → полный деплой; решение видно в логе. Отдельная проверка ловит класс бага во всех воркфлоу — исключающие шаблоны '!' в любом paths-filter без predicate-quantifier: every. Приёмка на проде: следующий мерж с единственным файлом под caddy/** не меняет docker inspect gendesign-caddy-1 --format '{{.Created}}', а в логе Caddy — reload, а не "serving initial configuration". Closes #3448 Co-Authored-By: Claude Opus 5 --- .forgejo/workflows/deploy.yml | 117 +++++++-- .../ops/test_3448_caddy_only_detection.py | 240 ++++++++++++++++++ 2 files changed, 329 insertions(+), 28 deletions(-) create mode 100644 backend/tests/ops/test_3448_caddy_only_detection.py diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 9b2c1866..c751dbca 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -121,37 +121,98 @@ jobs: infra: ${{ steps.filter.outputs.infra }} # #2916: правка ТОЛЬКО конфига прокси. `infra` для этого не годится — он # включает и compose, и сам workflow, где полный деплой обязателен. - # `github.event_name == 'push'` первым множителем НАМЕРЕННО: на - # workflow_dispatch у paths-filter нет диффа, и любой его ответ не должен - # уметь отключить сборку — ручной прогон обязан оставаться полным. - caddy_only: ${{ github.event_name == 'push' && steps.filter.outputs.caddy == 'true' && steps.filter.outputs.non_caddy == 'false' }} + caddy_only: ${{ steps.filter.outputs.caddy_only }} steps: - uses: actions/checkout@v4 - - uses: dorny/paths-filter@v3 + + # ── #3448: список изменённых файлов считаем САМИ ───────────────────────── + # + # ЧТО БЫЛО. Быстрый путь «правка только прокси» (#2916) не отработал НИ + # РАЗУ. Причина — НЕ пустой `event.before`: эта гипотеза опровергнута + # логом задачи 29244 (run 10881, мерж 84920e6c) — `before` там валиден, + # 204e2e09…, и `git diff` вернул ровно один файл. Причина в семантике + # самого фильтра: dorny/paths-filter склеивает шаблоны ОДНОГО фильтра + # через `some`, то есть ИЛИ (src/filter.ts: `patterns.some(aPredicate)`, + # predicate-quantifier по умолчанию `some`). Список + # non_caddy: ['**', '!Caddyfile', '!caddy/**'] + # читается не как «всё, КРОМЕ caddy», а как «подходит под `**` ИЛИ не + # Caddyfile ИЛИ не caddy/**». `**` матчит всё, поэтому non_caddy был true + # ВСЕГДА и caddy_only — false всегда. В логе это видно дословно: + # ##[group]Filter non_caddy = true + # Matching files: + # caddy/sites/apps.caddy [modified] + # Исключённый файл сам себя и «исключил». deploy-caddy при этом + # пропускался, а Forgejo рисует пропущенную джобу зелёной — сигнала не + # было ни одного. + # + # ПОЧЕМУ ШЕЛЛ, А НЕ ЗАПЛАТКА К ФИЛЬТРАМ. Разность множеств тут нужна одна + # («все изменения лежат под caddy»), и выражать её действием, у которого + # ИЛИ по умолчанию, — значит снова повесить решение на незаметное + # умолчание: `predicate-quantifier: every` действует на ВЕСЬ блок и + # сломал бы backend/frontend/infra. Плюс два требования #3448: решение + # обязано быть ВИДНО в логе (иначе «сработало» и «просто не совпало» + # неотличимы), и оно не должно молча зависеть от того, что платформа + # кладёт в `before`. + # + # FAIL-SAFE. База не разрешилась (ручной запуск, пустой/нулевой `before`, + # коммита нет на сервере) → считаем изменённым ВЕСЬ репозиторий: лишний + # полный деплой безопаснее пропущенного. Фолбэка на `HEAD^..HEAD` тут + # намеренно нет: у мерж-коммита он дал бы верный ответ, а у push'а из + # нескольких коммитов — молча урезанный, и быстрый путь включился бы + # там, где приехал бэкенд. + - name: Определить изменённые файлы (#3448) id: filter - with: - filters: | - backend: - - 'backend/**' - - 'data/sql/**' - frontend: - - 'frontend/**' - infra: - - 'docker-compose.prod.yml' - - 'Caddyfile' - - 'caddy/**' - - '.forgejo/workflows/deploy.yml' - # Пара фильтров для «правка ТОЛЬКО прокси» (#2916). Одного `caddy` - # мало: он true и когда вместе с конфигом приехал бэкенд — тогда - # нужен обычный полный деплой. `non_caddy` матчит ВСЁ остальное, - # и быстрый путь включается лишь когда он false. - caddy: - - 'Caddyfile' - - 'caddy/**' - non_caddy: - - '**' - - '!Caddyfile' - - '!caddy/**' + env: + BEFORE: ${{ github.event.before }} + EVENT: ${{ github.event_name }} + run: | + set -eu + NULL_SHA=0000000000000000000000000000000000000000 + BASE="" + if [ "$EVENT" = "push" ] && [ -n "${BEFORE:-}" ] && [ "$BEFORE" != "$NULL_SHA" ]; then + git cat-file -e "${BEFORE}^{commit}" 2>/dev/null \ + || git fetch --depth=1 --no-tags origin "$BEFORE" >/dev/null 2>&1 \ + || true + if git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then + BASE="$BEFORE" + else + echo "::warning::коммит $BEFORE недоступен в клоне — деплой будет полным" + fi + fi + + if [ -n "$BASE" ]; then + FILES=$(git diff --no-renames --name-only "$BASE" HEAD) + N=$(printf '%s\n' "$FILES" | grep -c . || true) + echo "База: $BASE → $(git rev-parse HEAD); изменённых файлов: $N" + printf '%s\n' "$FILES" | sed 's/^/ /' + else + FILES=$(git ls-files) + N=$(printf '%s\n' "$FILES" | grep -c . || true) + echo "База не определена (event=$EVENT, before='${BEFORE:-}') — считаем изменённым весь репозиторий ($N файлов), деплой полный" + fi + + # Те же наборы путей, что были в фильтрах до #3448. + CADDY_RE='^(Caddyfile$|caddy/)' + has() { printf '%s\n' "$FILES" | grep -qE "$1"; } + + backend=false; frontend=false; infra=false; caddy_only=false + has '^(backend/|data/sql/)' && backend=true + has '^frontend/' && frontend=true + has '^(docker-compose\.prod\.yml$|Caddyfile$|caddy/|\.forgejo/workflows/deploy\.yml$)' && infra=true + # Быстрый путь: изменения ЕСТЬ и НИ ОДНО из них не лежит вне caddy. + # Проверка `N -gt 0` обязательна: пустой список иначе прошёл бы как + # «всё под caddy» и отключил бы сборку на ровном месте. + if [ "$N" -gt 0 ] && ! printf '%s\n' "$FILES" | grep -vE "$CADDY_RE" | grep -q .; then + caddy_only=true + fi + + echo "Флаги: backend=$backend frontend=$frontend infra=$infra caddy_only=$caddy_only" + { + echo "backend=$backend" + echo "frontend=$frontend" + echo "infra=$infra" + echo "caddy_only=$caddy_only" + } >> "$GITHUB_OUTPUT" build-backend: runs-on: ubuntu-latest diff --git a/backend/tests/ops/test_3448_caddy_only_detection.py b/backend/tests/ops/test_3448_caddy_only_detection.py new file mode 100644 index 00000000..9402210b --- /dev/null +++ b/backend/tests/ops/test_3448_caddy_only_detection.py @@ -0,0 +1,240 @@ +"""Гейт: быстрый путь «правка только прокси» действительно включается (#3448). + +ЧТО СЛУЧИЛОСЬ. Быстрый путь #2916 (`caddy_only` → джоба `deploy-caddy` с +`caddy reload` вместо пересоздания контейнеров) не отработал ни разу за всё +время жизни. Проверено на мерже 84920e6c, где в диффе ровно один файл +`caddy/sites/apps.caddy`: контейнеры пересозданы, в логе Caddy +`serving initial configuration` — холодный старт, а не reload. + +ПРИЧИНА — НЕ пустой `github.event.before` (рабочая гипотеза #3448 опровергнута +логом задачи 29244: `before` = 204e2e09…, `git diff` вернул ровно один файл). +Причина в том, что dorny/paths-filter склеивает шаблоны одного фильтра через +`some`, то есть ИЛИ (src/filter.ts: `patterns.some(aPredicate)`; параметр +`predicate-quantifier` по умолчанию `some`). Список + + non_caddy: ['**', '!Caddyfile', '!caddy/**'] + +значит «подходит под `**` ИЛИ не Caddyfile ИЛИ не caddy/**», а `**` матчит всё — +non_caddy был true ВСЕГДА. В логе это видно дословно: + + ##[group]Filter non_caddy = true + Matching files: + caddy/sites/apps.caddy [modified] + +исключённый файл сам себя и «исключил». + +ЗАЧЕМ ЭТОТ ФАЙЛ. У самой правки нет отрицательного признака: пропущенную джобу +Forgejo рисует зелёной, поэтому «зелёный deploy-caddy» одинаково выглядит и +когда быстрый путь сработал, и когда его вообще не было. Проверки ниже +ИСПОЛНЯЮТ шаг определения файлов из deploy.yml на настоящем временном +репозитории (включая мерж-коммит — ровно случай #3448) и смотрят на значения +флагов, а не на текст воркфлоу. Регресс к исключающим шаблонам paths-filter +ловит отдельная проверка в конце. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest +import yaml + +# backend/tests/ops/<этот файл> → корень репозитория +REPO_ROOT = Path(__file__).resolve().parents[3] +WORKFLOWS = REPO_ROOT / ".forgejo" / "workflows" +DEPLOY = WORKFLOWS / "deploy.yml" + +NULL_SHA = "0" * 40 + +# Файлы, которые лежат в тестовом репозитории до правки. Набор подобран так, +# чтобы фолбэк «база не определена» мог отличить полный деплой от пустого. +BASE_FILES = ( + "caddy/sites/apps.caddy", + "Caddyfile", + "backend/app/main.py", + "frontend/src/page.tsx", + "data/sql/001.sql", + "docker-compose.prod.yml", + "README.md", +) + +GIT_ENV = { + "GIT_AUTHOR_NAME": "t", + "GIT_AUTHOR_EMAIL": "t@example.com", + "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@example.com", + "GIT_CONFIG_GLOBAL": "/dev/null", + "GIT_CONFIG_SYSTEM": "/dev/null", +} + + +def _detect_script() -> str: + """Тело шага, который считает изменённые файлы в job `changes`.""" + spec = yaml.safe_load(DEPLOY.read_text(encoding="utf-8")) + job = spec["jobs"]["changes"] + steps = [s for s in job["steps"] if s.get("id") == "filter"] + assert len(steps) == 1, ( + "в job `changes` нет ровно одного шага с `id: filter` — определение " + "изменённых файлов переехало, гейт #3448 ослеп" + ) + step = steps[0] + assert "run" in step, ( + f"шаг `filter` не считает файлы сам, а делегирует их {step.get('uses')!r}. " + "Именно так и возник #3448: у dorny/paths-filter шаблоны одного фильтра " + "склеиваются через ИЛИ, поэтому `non_caddy: ['**', '!caddy/**']` был true " + "ВСЕГДА и быстрый путь не включался ни разу." + ) + assert step["run"].strip(), "шаг `filter` пуст" + return step["run"] + + +def _git(repo: Path, *args: str) -> None: + subprocess.run( + ["git", "-C", str(repo), *args], check=True, env=dict(GIT_ENV), capture_output=True + ) + + +def _make_repo(tmp_path: Path, changed: tuple[str, ...]) -> tuple[Path, str]: + """Репозиторий с базовым коммитом и МЕРЖ-коммитом поверх него. + + Мерж, а не обычный коммит, — намеренно: #3448 наблюдался именно на мерже + PR'а, и любой фолбэк вида «дифф последнего коммита» на мерж-коммите даёт + пустой список (git show у мержа без -m не печатает ничего). + """ + repo = tmp_path / "repo" + repo.mkdir(parents=True) + _git(repo, "init", "-q", "-b", "main") + for name in BASE_FILES: + path = repo / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("base\n", encoding="utf-8") + _git(repo, "add", "-A") + _git(repo, "commit", "-qm", "base") + base_sha = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + env=dict(GIT_ENV), + ).stdout.strip() + + _git(repo, "checkout", "-q", "-b", "feature") + for name in changed: + path = repo / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("changed\n", encoding="utf-8") + _git(repo, "add", "-A") + _git(repo, "commit", "-qm", "feature") + _git(repo, "checkout", "-q", "main") + _git(repo, "merge", "-q", "--no-ff", "-m", "merge feature", "feature") + return repo, base_sha + + +def _run( + tmp_path: Path, changed: tuple[str, ...], *, before: str | None = None, event: str = "push" +) -> tuple[dict[str, str], str]: + repo, base_sha = _make_repo(tmp_path, changed) + out_file = tmp_path / "outputs" + out_file.touch() + env = { + "PATH": "/usr/bin:/bin:/usr/local/bin", + "BEFORE": base_sha if before is None else before, + "EVENT": event, + "GITHUB_OUTPUT": str(out_file), + **GIT_ENV, + } + proc = subprocess.run( + ["bash", "-c", _detect_script()], + cwd=repo, + env=env, + capture_output=True, + text=True, + ) + assert proc.returncode == 0, f"шаг упал:\n{proc.stdout}\n{proc.stderr}" + outputs = dict( + line.split("=", 1) + for line in out_file.read_text(encoding="utf-8").splitlines() + if "=" in line + ) + return outputs, proc.stdout + + +def test_merge_with_only_caddy_file_takes_the_fast_path(tmp_path: Path) -> None: + """Случай #3448 дословно: мерж, в диффе один файл под caddy/.""" + outputs, _ = _run(tmp_path, ("caddy/sites/apps.caddy",)) + assert outputs["caddy_only"] == "true", ( + f"быстрый путь не включился на правке ТОЛЬКО прокси: {outputs}. " + "Ровно это и есть #3448: deploy-caddy пропускается, идёт полный деплой " + "с пересозданием контейнеров, а Forgejo рисует пропуск зелёным." + ) + assert outputs["backend"] == "false" + assert outputs["frontend"] == "false" + + +def test_caddy_plus_backend_is_a_full_deploy(tmp_path: Path) -> None: + """Обратное направление: быстрый путь НЕ должен красть обычный деплой.""" + outputs, _ = _run(tmp_path, ("caddy/sites/apps.caddy", "backend/app/main.py")) + assert outputs["caddy_only"] == "false", ( + f"быстрый путь включился, хотя вместе с конфигом приехал бэкенд: {outputs}. " + "Так прод остался бы на старом образе при зелёном деплое." + ) + assert outputs["backend"] == "true" + + +def test_missing_base_falls_back_to_full_deploy(tmp_path: Path) -> None: + """База не разрешилась → полный деплой, а не пустой список. + + Пустой список изменений — это `caddy_only` без единого caddy-файла и + отключённая сборка: отказ, который выглядит как успешный быстрый путь. + """ + for before, event in ((NULL_SHA, "push"), ("", "push"), (None, "workflow_dispatch")): + outputs, log = _run( + tmp_path / f"case-{event}-{before!r}", + ("caddy/sites/apps.caddy",), + before=before, + event=event, + ) + assert outputs["caddy_only"] == "false", f"before={before!r} event={event}: {outputs}" + assert outputs["backend"] == "true", f"before={before!r} event={event}: {outputs}" + assert outputs["frontend"] == "true", f"before={before!r} event={event}: {outputs}" + assert outputs["infra"] == "true", f"before={before!r} event={event}: {outputs}" + assert "деплой полный" in log + + +def test_decision_is_visible_in_the_log(tmp_path: Path) -> None: + """Решение печатается: и список файлов, и итоговые флаги. + + Без этого «сработало» и «просто не совпало» неотличимы — единственным + свидетелем остаётся метка Created у контейнера на проде. + """ + _, log = _run(tmp_path, ("caddy/sites/apps.caddy",)) + assert "caddy/sites/apps.caddy" in log, f"шаг не печатает список файлов:\n{log}" + assert "caddy_only=true" in log and "backend=false" in log, ( + f"шаг не печатает итоговые флаги:\n{log}" + ) + + +@pytest.mark.parametrize("path", sorted(WORKFLOWS.glob("*.yml")), ids=lambda p: p.name) +def test_no_paths_filter_relies_on_exclusion_patterns(path: Path) -> None: + """Ни один paths-filter в репозитории не пытается вычитать пути через `!`. + + Класс бага, а не единственный его случай: при `predicate-quantifier: some` + (умолчание) шаблоны фильтра склеиваются через ИЛИ, и `!` ничего не вычитает. + """ + spec = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + for job_name, job in (spec.get("jobs") or {}).items(): + for step in job.get("steps") or []: + if not str(step.get("uses", "")).startswith("dorny/paths-filter"): + continue + with_ = step.get("with") or {} + if with_.get("predicate-quantifier") == "every": + continue + filters = yaml.safe_load(with_.get("filters") or "") or {} + for filter_name, patterns in filters.items(): + bad = [p for p in (patterns or []) if isinstance(p, str) and p.startswith("!")] + assert not bad, ( + f"{path.name}: job {job_name}, фильтр {filter_name!r} вычитает пути " + f"шаблонами {bad} — при `some` (умолчание) они склеиваются через ИЛИ " + "и фильтр становится true ВСЕГДА. Так #2916 не сработал ни разу (#3448)." + ) From 9a8aaa2d2d880720bda4728664101fdd2f64dcfe Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 15:56:24 +0500 Subject: [PATCH 2/4] =?UTF-8?q?deploy-caddy:=20=D1=81=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B6=20=D0=BE=D1=82=D1=81=D1=82=D0=B0=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B4=D0=B0,=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=89=D0=B8=D0=B9=20=D0=BB=D0=BE=D0=BA=20=D0=B8=20=D0=BA=D0=B2?= =?UTF-8?q?=D0=BE=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BF=D1=83=D1=82=D0=B5=D0=B9=20(#3448)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Оживший быстрый путь снимает гард свежести :latest. Пока caddy_only был мёртв, любой push шёл полным деплоем, и гард #2950 прикрывал прод по умолчанию. Теперь при caddy_only=true джоба `deploy` пропускается целиком — вместе с гардом. Сценарий отказа. Push A правит бэкенд, билды ~6 мин, `deploy` в очереди. Через 2 мин push B правит только caddy/. На Forgejo 10.0.3 ещё не стартовавшая `deploy` предыдущего прогона отменяется ДАЖЕ при cancel-in-progress: false (наблюдение 21.08.2026 10:35:13, шапка scripts/check-latest-image-revision.sh; workflow-level concurrency там не исполняется, см. deploy.yml). Дифф A..B — один caddy-файл, быстрый путь включается, `compose pull` + `up -d` не делает никто: прод крутит старый образ, голова main зелёная, сигнала нет. Сторож в ssh-скрипте deploy-caddy: если прод отстаёт от origin/main не только по Caddyfile/caddy/**, быстрый путь запрещён, шаг падает и называет файлы. Стоит ДО `git reset --hard` намеренно — при отказе прод-HEAD остаётся честным для следующего прогона. У Trade-In для того же заведён отдельный маркер (/opt/gendesign/.tradein-deployed-sha, deploy-tradein.yml), у ПТИЦЫ маркера нет, и `git reset` делает прод-HEAD его эквивалентом. ЧЕГО СТОРОЖ НЕ ЛОВИТ: `deploy`, упавшую ПОСЛЕ `git reset --hard` (например на миграции). Тогда прод-HEAD уже равен новому коммиту, а контейнеры старые — это остаётся за настоящим маркером «что задеплоено». Тот же лок, что и у полного деплоя. deploy-caddy делает `git reset --hard` в /opt/gendesign, то есть правит прод-дерево — ровно то, что job `deploy` сериализует через flock /var/lock/gendesign-docker-deploy.lock. Пока путь был мёртв, сталкиваться было нечему; теперь это первая джоба, трогающая прод-дерево в обход сериализации. Квотирование путей. `git diff --name-only` и `git ls-files` при core.quotePath (умолчание true) отдают не-ASCII пути закавыченными с \NNN-экранированием — `^backend/` такую строку не матчит, и файл backend/<кириллица>.py дал бы backend=false. Старый paths-filter брал `--name-status -z`, где квотирования нет: это единственное место, где переход на свой diff менял поведение. В дереве такие пути уже живут (docs/Бизнес-план…). Добавлен `-c core.quotePath=false` в обе команды и в сторож выше. Co-Authored-By: Claude Opus 5 --- .forgejo/workflows/deploy.yml | 58 +++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index c751dbca..3e8cd771 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -181,12 +181,12 @@ jobs: fi if [ -n "$BASE" ]; then - FILES=$(git diff --no-renames --name-only "$BASE" HEAD) + FILES=$(git -c core.quotePath=false diff --no-renames --name-only "$BASE" HEAD) N=$(printf '%s\n' "$FILES" | grep -c . || true) echo "База: $BASE → $(git rev-parse HEAD); изменённых файлов: $N" printf '%s\n' "$FILES" | sed 's/^/ /' else - FILES=$(git ls-files) + FILES=$(git -c core.quotePath=false ls-files) N=$(printf '%s\n' "$FILES" | grep -c . || true) echo "База не определена (event=$EVENT, before='${BEFORE:-}') — считаем изменённым весь репозиторий ($N файлов), деплой полный" fi @@ -1343,8 +1343,62 @@ jobs: fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }} script: | set -euo pipefail + # #3448: ТОТ ЖЕ ЛОК, что берёт полный деплой (см. job `deploy` выше). + # Эта джоба делает `git reset --hard` в /opt/gendesign, то есть правит + # прод-дерево — ровно то, что полный деплой сериализует локом. Пока + # быстрый путь был мёртв, столкнуться было нечему; теперь есть. + exec 9>/var/lock/gendesign-docker-deploy.lock + if flock -n 9; then + echo "→ докер-лок свободен, взят сразу" + else + echo "→ докер-лок занят соседним деплоем, жду (до 900с)…" + lock_wait_started=$(date +%s) + if ! flock -w 900 9; then + echo "ERROR: не дождался лока докер-деплоя за 900с." + echo " Кто держит: ssh на хост, затем fuser -v /var/lock/gendesign-docker-deploy.lock" + exit 1 + fi + echo "→ докер-лок получен через $(( $(date +%s) - lock_wait_started ))с ожидания" + fi cd /opt/gendesign git fetch origin main + + # ── #3448: быстрый путь законен, только если прод отстаёт РОВНО на + # конфиг прокси ──────────────────────────────────────────────────── + # + # Джоба `changes` считает дифф between-push (before→HEAD) и не знает, + # что доехало до прода. Пока caddy_only был мёртв, любой push шёл + # полным деплоем и гард свежести :latest (#2950, job `deploy`) + # прикрывал прод по умолчанию. Оживший быстрый путь этот гард + # обходит: при caddy_only=true джоба `deploy` пропускается целиком. + # + # Сценарий отказа: push A правит бэкенд, билды ~6 мин, `deploy` в + # очереди; через 2 мин push B правит только caddy/. Forgejo на + # 10.0.3 отменяет ещё не стартовавший `deploy` предыдущего прогона + # ДАЖЕ при cancel-in-progress: false (наблюдение 21.08.2026 10:35:13, + # см. шапку scripts/check-latest-image-revision.sh). Дифф A..B — один + # caddy-файл, быстрый путь включается, `compose pull` + `up -d` не + # делает никто: прод крутит старый образ при зелёной голове main. + # + # Единственный источник правды о том, что реально на проде, — HEAD + # прод-дерева (у Trade-In для этого заведён отдельный маркер + # /opt/gendesign/.tradein-deployed-sha, см. deploy-tradein.yml:150; + # у ПТИЦЫ маркера нет, но git reset ниже делает HEAD эквивалентом). + # Проверка стоит ДО reset намеренно: при отказе прод-HEAD остаётся + # честным для следующего прогона. + # + # ЧЕГО ЭТА ПРОВЕРКА НЕ ЛОВИТ: `deploy` прогона A, упавшую ПОСЛЕ + # `git reset --hard` (например на миграции). Тогда прод-HEAD уже + # равен A, а контейнеры старые, и caddy-only push пройдёт быстрым + # путём. Это остаётся за настоящим маркером «что задеплоено». + PROD_HEAD=$(git rev-parse HEAD) + OUTSIDE=$(git -c core.quotePath=false diff --name-only "$PROD_HEAD" origin/main | grep -vE '^(Caddyfile$|caddy/)' || true) + if [ -n "$OUTSIDE" ]; then + echo "::error::прод отстаёт не только по конфигу прокси — быстрый путь запрещён:" + printf '%s\n' "$OUTSIDE" | sed 's/^/ /' + echo "Запусти полный деплой через workflow_dispatch." + exit 1 + fi git reset --hard origin/main # Конфиг примонтирован read-only с хоста, пересборка не нужна — # контейнер читает тот же файл, что только что обновил git. From 2e928c715ba1f59ded0a81dbdc2305fa891578d0 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 15:57:11 +0500 Subject: [PATCH 3/4] =?UTF-8?q?=D0=93=D0=B5=D0=B9=D1=82=20#3448:=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D1=8C=20=D0=B7=D0=B5=D0=BB=D1=91?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=BC=D1=83=D1=82=D0=B0=D1=86=D0=B8=D0=B8?= =?UTF-8?q?,=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=B7=D0=BD=D0=B0=D0=BA=20=D0=BD=D0=B5=D0=BF=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D1=82=D1=8B,=20=D0=B7=D0=B0=D0=BF=D1=83?= =?UTF-8?q?=D1=81=D0=BA=D0=B0=D1=82=D1=8C=20=D0=BD=D0=B0=20ci-tradein.yml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Мутационный прогон нашёл пять зелёных мутаций — то есть мест, где логику можно сломать, а гейт этого не заметит. Закрыты фикстурами, каждая краснеет ровно на своей мутации: * 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 --- .forgejo/workflows/ci.yml | 6 + .../ops/test_3448_caddy_only_detection.py | 292 +++++++++++++++--- 2 files changed, 251 insertions(+), 47 deletions(-) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index b38276cc..b0706e31 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -219,6 +219,12 @@ jobs: - '.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' frontend: - 'frontend/**' - '.forgejo/workflows/ci.yml' diff --git a/backend/tests/ops/test_3448_caddy_only_detection.py b/backend/tests/ops/test_3448_caddy_only_detection.py index 9402210b..3a953a35 100644 --- a/backend/tests/ops/test_3448_caddy_only_detection.py +++ b/backend/tests/ops/test_3448_caddy_only_detection.py @@ -28,12 +28,16 @@ Forgejo рисует зелёной, поэтому «зелёный deploy-cadd когда быстрый путь сработал, и когда его вообще не было. Проверки ниже ИСПОЛНЯЮТ шаг определения файлов из deploy.yml на настоящем временном репозитории (включая мерж-коммит — ровно случай #3448) и смотрят на значения -флагов, а не на текст воркфлоу. Регресс к исключающим шаблонам paths-filter -ловит отдельная проверка в конце. +флагов, а не на текст воркфлоу. Так же исполняется и прод-сторож из джобы +`deploy-caddy`: быстрый путь пропускает джобу `deploy` целиком, а вместе с ней +и гард свежести :latest (#2950), поэтому перезагружать прокси можно, только +если прод отстаёт РОВНО на конфиг прокси. Регресс к исключающим шаблонам +paths-filter ловит отдельная проверка в конце. """ from __future__ import annotations +import re import subprocess from pathlib import Path @@ -95,13 +99,27 @@ def _git(repo: Path, *args: str) -> None: ) -def _make_repo(tmp_path: Path, changed: tuple[str, ...]) -> tuple[Path, str]: - """Репозиторий с базовым коммитом и МЕРЖ-коммитом поверх него. +def _sha(repo: Path) -> str: + return subprocess.run( + ["git", "-C", str(repo), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + env=dict(GIT_ENV), + ).stdout.strip() - Мерж, а не обычный коммит, — намеренно: #3448 наблюдался именно на мерже - PR'а, и любой фолбэк вида «дифф последнего коммита» на мерж-коммите даёт - пустой список (git show у мержа без -m не печатает ничего). - """ + +def _commit(repo: Path, files: tuple[str, ...], msg: str = "c") -> None: + for name in files: + path = repo / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("changed\n", encoding="utf-8") + _git(repo, "add", "-A") + _git(repo, "commit", "-qm", msg, *([] if files else ["--allow-empty"])) + + +def _base_repo(tmp_path: Path) -> tuple[Path, str]: + """Репозиторий с одним базовым коммитом; возвращает его sha — это `before`.""" repo = tmp_path / "repo" repo.mkdir(parents=True) _git(repo, "init", "-q", "-b", "main") @@ -111,35 +129,30 @@ def _make_repo(tmp_path: Path, changed: tuple[str, ...]) -> tuple[Path, str]: path.write_text("base\n", encoding="utf-8") _git(repo, "add", "-A") _git(repo, "commit", "-qm", "base") - base_sha = subprocess.run( - ["git", "-C", str(repo), "rev-parse", "HEAD"], - check=True, - capture_output=True, - text=True, - env=dict(GIT_ENV), - ).stdout.strip() + return repo, _sha(repo) + +def _merge_commit(repo: Path, changed: tuple[str, ...]) -> None: + """Ветка с правкой и мерж `--no-ff` обратно в main. + + Мерж, а не обычный коммит, — потому что #3448 наблюдался именно на мерже + PR'а: у мерж-коммита две родительские линии, и любой разбор диффа обязан + работать на этой форме. Что `before` нельзя заменить на `HEAD^`, стережёт + отдельная проверка — test_multi_commit_push_is_not_truncated: на ОДНОМ + мерж-коммите `HEAD^..HEAD` даёт верный ответ и такую подмену не ловит. + """ _git(repo, "checkout", "-q", "-b", "feature") - for name in changed: - path = repo / name - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("changed\n", encoding="utf-8") - _git(repo, "add", "-A") - _git(repo, "commit", "-qm", "feature") + _commit(repo, changed, "feature") _git(repo, "checkout", "-q", "main") _git(repo, "merge", "-q", "--no-ff", "-m", "merge feature", "feature") - return repo, base_sha -def _run( - tmp_path: Path, changed: tuple[str, ...], *, before: str | None = None, event: str = "push" -) -> tuple[dict[str, str], str]: - repo, base_sha = _make_repo(tmp_path, changed) - out_file = tmp_path / "outputs" +def _exec(repo: Path, before: str, event: str = "push") -> tuple[dict[str, str], str]: + out_file = repo.parent / "outputs" out_file.touch() env = { "PATH": "/usr/bin:/bin:/usr/local/bin", - "BEFORE": base_sha if before is None else before, + "BEFORE": before, "EVENT": event, "GITHUB_OUTPUT": str(out_file), **GIT_ENV, @@ -160,6 +173,14 @@ def _run( return outputs, proc.stdout +def _run( + tmp_path: Path, changed: tuple[str, ...], *, before: str | None = None, event: str = "push" +) -> tuple[dict[str, str], str]: + repo, base_sha = _base_repo(tmp_path) + _merge_commit(repo, changed) + return _exec(repo, base_sha if before is None else before, event) + + def test_merge_with_only_caddy_file_takes_the_fast_path(tmp_path: Path) -> None: """Случай #3448 дословно: мерж, в диффе один файл под caddy/.""" outputs, _ = _run(tmp_path, ("caddy/sites/apps.caddy",)) @@ -215,26 +236,203 @@ def test_decision_is_visible_in_the_log(tmp_path: Path) -> None: ) -@pytest.mark.parametrize("path", sorted(WORKFLOWS.glob("*.yml")), ids=lambda p: p.name) -def test_no_paths_filter_relies_on_exclusion_patterns(path: Path) -> None: - """Ни один paths-filter в репозитории не пытается вычитать пути через `!`. +# ── Быстрый путь на самом проде: джоба deploy-caddy ────────────────────────── +# +# Пока caddy_only был мёртв, каждый push шёл полным деплоем, и гард свежести +# :latest (#2950, job `deploy`) прикрывал прод по умолчанию. Оживший быстрый +# путь его обходит: при caddy_only=true джоба `deploy` пропускается целиком. +# Дифф between-push (before→HEAD) не знает, что реально доехало до прода: +# отменённая очередью `deploy` предыдущего прогона оставляет прод на старом +# образе, а следующий caddy-only push честно видит «изменился один caddy-файл». + + +def _caddy_deploy_script() -> str: + spec = yaml.safe_load(DEPLOY.read_text(encoding="utf-8")) + steps = [s for s in spec["jobs"]["deploy-caddy"]["steps"] if "ssh-action" in str(s.get("uses"))] + assert len(steps) == 1, "в deploy-caddy нет ровно одного ssh-шага — гейт #3448 ослеп" + return steps[0]["with"]["script"] + + +def _prod_lag_guard() -> str: + """Кусок ssh-скрипта от вычисления PROD_HEAD до `git reset --hard`.""" + script = _caddy_deploy_script() + assert "PROD_HEAD=" in script, ( + "джоба deploy-caddy не сверяет отставание прода: быстрый путь перезагрузит " + "прокси и уйдёт зелёным, оставив прод на старом образе (#3448)" + ) + # Ищем КОМАНДУ, а не подстроку: `git reset --hard` упоминается выше в + # комментариях, и поиск по тексту нашёл бы объяснение вместо кода. + reset_cmd = re.search(r"(?m)^\s*git reset --hard", script) + assert reset_cmd, "в deploy-caddy пропал `git reset --hard` — гейт опирается на него" + start, reset = script.index("PROD_HEAD="), reset_cmd.start() + assert start < reset, ( + "проверка отставания прода стоит ПОСЛЕ `git reset --hard` — при отказе " + "прод-HEAD уже переписан, и следующий прогон снова уйдёт быстрым путём" + ) + return "set -euo pipefail\n" + script[start:reset] + + +def _prod_repo(tmp_path: Path, ahead: tuple[str, ...]) -> Path: + """Прод-дерево на базовом коммите, origin/main — на `ahead` впереди.""" + repo, base_sha = _base_repo(tmp_path) + _commit(repo, ahead, "ahead") + _git(repo, "update-ref", "refs/remotes/origin/main", "HEAD") + _git(repo, "reset", "--hard", "-q", base_sha) + return repo + + +def _run_guard(repo: Path) -> subprocess.CompletedProcess: + return subprocess.run( + ["bash", "-c", _prod_lag_guard()], + cwd=repo, + capture_output=True, + text=True, + env={"PATH": "/usr/bin:/bin:/usr/local/bin", **GIT_ENV}, + ) + + +def test_fast_path_allowed_when_prod_lags_only_by_proxy_config(tmp_path: Path) -> None: + proc = _run_guard(_prod_repo(tmp_path, ("caddy/sites/apps.caddy",))) + assert proc.returncode == 0, f"законный быстрый путь заблокирован:\n{proc.stdout}{proc.stderr}" + + +def test_fast_path_refuses_when_prod_lags_by_code(tmp_path: Path) -> None: + """Прод отстаёт не только по конфигу прокси → перезагрузка прокси запрещена.""" + proc = _run_guard(_prod_repo(tmp_path, ("backend/app/main.py", "caddy/sites/apps.caddy"))) + assert proc.returncode != 0, ( + "быстрый путь разрешён, хотя прод отстаёт по коду бэкенда: перезагрузка " + f"прокси подменила бы выкатку, деплой ушёл бы зелёным.\n{proc.stdout}" + ) + assert "backend/app/main.py" in proc.stdout, ( + f"отказ не называет файлы, из-за которых он произошёл:\n{proc.stdout}" + ) + + +def test_fast_path_takes_the_same_host_lock() -> None: + """deploy-caddy правит прод-дерево — значит берёт тот же лок, что `deploy`. + + Проверка текстовая, как в test_2950: исполнить flock-секцию в тесте нельзя, + а её пропажа не даёт ни одного сигнала до совпадения окон двух деплоев. + """ + script = _caddy_deploy_script() + assert "exec 9>/var/lock/gendesign-docker-deploy.lock" in script, ( + "deploy-caddy делает `git reset --hard` в /opt/gendesign в обход лока, " + "которым полный деплой сериализует работу с прод-деревом (#2950)" + ) + assert "flock -w 900 9" in script, "лок открывается, но не захватывается" + + +@pytest.mark.parametrize( + "changed", [("caddy-extra/x.txt",), ("Caddyfile.bak",), ("docs/caddy.md",)] +) +def test_paths_that_merely_start_with_caddy_are_not_the_fast_path( + tmp_path: Path, changed: tuple[str, ...] +) -> None: + """`caddy-extra/…` и `Caddyfile.bak` — НЕ конфиг прокси. + + Граница шаблона — единственное, что отделяет быстрый путь от тихого + пропуска полного деплоя: `^(Caddyfile|caddy)` вместо `^(Caddyfile$|caddy/)` + отправил бы эти правки перезагружать прокси вместо выкатки. + """ + outputs, _ = _run(tmp_path, changed) + assert outputs["caddy_only"] == "false", f"{changed}: {outputs}" + + +def test_empty_diff_is_not_the_fast_path(tmp_path: Path) -> None: + """Пустой дифф (`before` == HEAD, пустой мерж) — не «всё под caddy». + + Без проверки «файлов больше нуля» пустой список формально удовлетворяет + «ни один файл не лежит вне caddy»: сборка отключается, деплой подменяется + перезагрузкой прокси — отказ, выглядящий как успешный быстрый путь. + """ + outputs, log = _run(tmp_path, ()) + assert outputs["caddy_only"] == "false", f"пустой дифф ушёл в быстрый путь: {outputs}" + assert "изменённых файлов: 0" in log + + +def test_data_sql_counts_as_backend(tmp_path: Path) -> None: + """`data/sql/**` собирает backend-образ: миграции едут в нём.""" + outputs, _ = _run(tmp_path, ("data/sql/002.sql",)) + assert outputs["backend"] == "true", outputs + assert outputs["caddy_only"] == "false", outputs + + +def test_non_ascii_path_is_classified(tmp_path: Path) -> None: + """Кириллица в пути не должна прятать файл от классификации. + + `git diff --name-only` при `core.quotePath=true` (умолчание) отдаёт + не-ASCII пути закавыченными и с \\NNN-экранированием — `^backend/` + такую строку не матчит. Старый paths-filter брал `--name-status -z`, где + квотирования нет; при переходе на свой diff это единственное место, где + поведение могло разойтись. В дереве такие пути уже живут (docs/). + """ + outputs, log = _run(tmp_path, ("backend/модуль.py",)) + assert outputs["backend"] == "true", f"кириллический путь потерян: {outputs}\n{log}" + + +def test_multi_commit_push_is_not_truncated(tmp_path: Path) -> None: + """Push из нескольких коммитов разбирается целиком, а не по последнему. + + Ровно та подмена, которую соблазнительно сделать «чтобы не зависеть от + before»: `HEAD^..HEAD`. На одном мерж-коммите она даёт верный ответ и + выглядит рабочей, а здесь — молча теряет бэкенд из первого коммита и + включает быстрый путь, то есть пропускает выкатку кода. + """ + repo, base_sha = _base_repo(tmp_path) + _commit(repo, ("backend/app/main.py",), "backend") + _commit(repo, ("caddy/sites/apps.caddy",), "caddy") + outputs, log = _exec(repo, base_sha) + assert outputs["backend"] == "true", f"первый коммит push'а потерян: {outputs}\n{log}" + assert outputs["caddy_only"] == "false", outputs + + +def _paths_filter_steps() -> list[tuple[Path, str, dict]]: + """Все шаги dorny/paths-filter во всех воркфлоу (включая .yaml).""" + found = [] + for path in sorted(WORKFLOWS.glob("*.y*ml")): + spec = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + for job_name, job in (spec.get("jobs") or {}).items(): + for step in job.get("steps") or []: + if str(step.get("uses", "")).startswith("dorny/paths-filter"): + found.append((path, job_name, step)) + return found + + +def test_exclusion_gate_has_something_to_check() -> None: + """Признак непустоты: проверка ниже обязана что-то находить. + + Переименуют действие, разнесут воркфлоу по .yaml, уедут шаги — и гейт + пройдёт при нулевом охвате, молча (ровно то, от чего страхуется ci.yml:190). + """ + steps = _paths_filter_steps() + assert steps, ( + "не найдено ни одного шага dorny/paths-filter — проверка исключающих " + "шаблонов прошла бы впустую, перепроверь маску поиска" + ) + + +@pytest.mark.parametrize( + "path,job_name,step", + _paths_filter_steps(), + ids=[f"{p.name}:{j}" for p, j, _ in _paths_filter_steps()], +) +def test_no_paths_filter_relies_on_exclusion_patterns( + path: Path, job_name: str, step: dict +) -> None: + """Ни один paths-filter в репозитории не вычитает пути через `!`. Класс бага, а не единственный его случай: при `predicate-quantifier: some` (умолчание) шаблоны фильтра склеиваются через ИЛИ, и `!` ничего не вычитает. """ - spec = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - for job_name, job in (spec.get("jobs") or {}).items(): - for step in job.get("steps") or []: - if not str(step.get("uses", "")).startswith("dorny/paths-filter"): - continue - with_ = step.get("with") or {} - if with_.get("predicate-quantifier") == "every": - continue - filters = yaml.safe_load(with_.get("filters") or "") or {} - for filter_name, patterns in filters.items(): - bad = [p for p in (patterns or []) if isinstance(p, str) and p.startswith("!")] - assert not bad, ( - f"{path.name}: job {job_name}, фильтр {filter_name!r} вычитает пути " - f"шаблонами {bad} — при `some` (умолчание) они склеиваются через ИЛИ " - "и фильтр становится true ВСЕГДА. Так #2916 не сработал ни разу (#3448)." - ) + with_ = step.get("with") or {} + if with_.get("predicate-quantifier") == "every": + pytest.skip("predicate-quantifier: every — шаблоны склеиваются через И") + filters = yaml.safe_load(with_.get("filters") or "") or {} + assert filters, f"{path.name}: job {job_name} — у paths-filter пустой блок filters" + for filter_name, patterns in filters.items(): + bad = [p for p in (patterns or []) if isinstance(p, str) and p.startswith("!")] + assert not bad, ( + f"{path.name}: job {job_name}, фильтр {filter_name!r} вычитает пути " + f"шаблонами {bad} — при `some` (умолчание) они склеиваются через ИЛИ " + "и фильтр становится true ВСЕГДА. Так #2916 не сработал ни разу (#3448)." + ) From 05959464aeaf0779a98131b72170176be282ec25 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 16:20:16 +0500 Subject: [PATCH 4/4] =?UTF-8?q?ci.yml:=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=D1=82=D1=8C=20backend-tests=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=D1=85=20=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D1=80=D0=B8=D0=BA=20(#3467/#3475)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Гейт 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 --- .forgejo/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index b0706e31..385b4f90 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -225,6 +225,14 @@ jobs: # не запустила бы 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' frontend: - 'frontend/**' - '.forgejo/workflows/ci.yml'