"""Гейт: быстрый путь «правка только прокси» действительно включается (#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) и смотрят на значения флагов, а не на текст воркфлоу. Так же исполняется и прод-сторож из джобы `deploy-caddy`: быстрый путь пропускает джобу `deploy` целиком, а вместе с ней и гард свежести :latest (#2950), поэтому перезагружать прокси можно, только если прод отстаёт РОВНО на конфиг прокси. Регресс к исключающим шаблонам paths-filter ловит отдельная проверка в конце. """ from __future__ import annotations import re 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 _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() 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") 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") 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") _commit(repo, changed, "feature") _git(repo, "checkout", "-q", "main") _git(repo, "merge", "-q", "--no-ff", "-m", "merge feature", "feature") 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": 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 _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",)) 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}" ) # ── Быстрый путь на самом проде: джоба 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` (умолчание) шаблоны фильтра склеиваются через ИЛИ, и `!` ничего не вычитает. """ 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)." )