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)." + )