"""Сторож расхождения «прод ↔ main» исполняется, а не пересказывается (#3029). Прод-факт, ради которого сторож появился. 27.08.2026 замена IP сервера (#3110) осиротила секрет `DEPLOY_HOST`: `deploy.yml` падал на 2026/08/27 08:47:10 dial tcp ***:***: i/o timeout при этом CI оставался зелёным, PR продолжали мержиться, а прод сутки жил на позавчерашнем коммите. Расхождение нашлось руками — сверкой двух `git log`. ГЛАВНЫЙ ИНВАРИАНТ, который сторожат тесты ниже: «хост не ответил» и «на хосте не тот код» — РАЗНЫЕ исходы (2 и 1), а не один. Ровно на их смешении и погорели: недоступность выглядела как обычный красный прогон, и первая команда в разборе оказалась не та. Если кто-то упростит скрипт до «не совпало → 1», тест покраснеет. Тесты исполняют НАСТОЯЩИЙ `scripts/check-deploy-drift.sh` через bash — не копию логики и не чтение исходника глазами: ошибка в самом bash (несработавшая подстановка, опечатка в сравнении) видна только при запуске. """ from __future__ import annotations import shlex import subprocess from pathlib import Path import pytest REPO_ROOT = Path(__file__).resolve().parents[3] SCRIPT = REPO_ROOT / "scripts" / "check-deploy-drift.sh" MAIN_SHA = "c6c934fb99aa1122334455667788990011223344" OLD_SHA = "8a4215fe203930b3a01bb33d3c4fadda7e873129" def _run(**env: str) -> subprocess.CompletedProcess[str]: """Запустить настоящий скрипт с заданными переменными. Переменные передаются ПРЕФИКСОМ КОМАНДЫ (`VAR=... bash script`), а не через `env=` у subprocess: под Windows оболочка git-bash окружение из `env=` не донесла — скрипт видел пустые значения и тест проверял бы не то, что нужно. Префикс команды ведёт себя одинаково на всех платформах, а сам скрипт всё так же читает обычные переменные окружения — контракт не меняется. """ assert SCRIPT.is_file(), f"нет {SCRIPT} — сторож переехал, гейт ослеп" prefix = " ".join(f"{k}={shlex.quote(v)}" for k, v in env.items()) # Относительный путь + cwd=REPO_ROOT: совпадает с вызовом из workflow и не # ломается о букву диска в Windows-пути. cmd = f"{prefix} bash scripts/check-deploy-drift.sh".strip() return subprocess.run( ["bash", "-c", cmd], cwd=str(REPO_ROOT), capture_output=True, text=True, timeout=30, ) def test_sovpadenie_daet_nol() -> None: """Прод на том же коммите — тишина и ноль.""" r = _run(DEPLOYED_SHA=MAIN_SHA[:12], EXPECTED_SHA=MAIN_SHA) assert r.returncode == 0, r.stdout + r.stderr assert "OK" in r.stdout def test_korotkiy_sha_s_hosta_sravnivaetsya_po_prefiksu() -> None: """С хоста приходит короткий SHA, из git — полный; сравнение по префиксу. Без этого сторож кричал бы на КАЖДОМ прогоне: `rev-parse --short` и `rev-parse` не равны как строки никогда. """ r = _run(DEPLOYED_SHA=MAIN_SHA[:8], EXPECTED_SHA=MAIN_SHA) assert r.returncode == 0, r.stdout + r.stderr def test_staroe_rashozhdenie_daet_edinicu() -> None: """Прод отстал давно — это авария.""" r = _run( DEPLOYED_SHA=OLD_SHA, EXPECTED_SHA=MAIN_SHA, EXPECTED_COMMIT_EPOCH="1000", NOW_EPOCH="99999", ) assert r.returncode == 1, r.stdout + r.stderr assert "РАСХОЖДЕНИЕ" in r.stdout def test_svezhiy_kommit_v_lgotnom_periode_ne_krichit() -> None: """Деплой ещё в полёте — сторож молчит. Ежечасный сторож неизбежно попадёт в момент между мержем и концом деплоя. Без льготного периода он давал бы ложную тревогу несколько раз в неделю, и его перестали бы читать — это дороже, чем отсутствие сторожа. """ r = _run( DEPLOYED_SHA=OLD_SHA, EXPECTED_SHA=MAIN_SHA, EXPECTED_COMMIT_EPOCH="1000", NOW_EPOCH="1300", GRACE_MINUTES="30", ) assert r.returncode == 0, r.stdout + r.stderr assert "ПОКА НОРМА" in r.stdout def test_lgota_istekla_krichit() -> None: """Граница льготы работает в обе стороны, а не «всегда молчим».""" r = _run( DEPLOYED_SHA=OLD_SHA, EXPECTED_SHA=MAIN_SHA, EXPECTED_COMMIT_EPOCH="1000", NOW_EPOCH=str(1000 + 31 * 60), GRACE_MINUTES="30", ) assert r.returncode == 1, r.stdout + r.stderr @pytest.mark.parametrize("deployed", ["", "abc"]) def test_neprochitannoe_sostoyanie_eto_dva_a_ne_odin(deployed: str) -> None: """Пустой или бессмысленно короткий ответ хоста — исход 2, отдельный от 1. Это и есть форма сегодняшней аварии: хост был недоступен. Смешать её с «прод отстал» — значит послать разбирающегося не туда. """ r = _run(DEPLOYED_SHA=deployed, EXPECTED_SHA=MAIN_SHA) assert r.returncode == 2, r.stdout + r.stderr assert "НЕИЗВЕСТНО" in r.stdout def test_bez_expected_sha_ne_pritvoryaemsya_chto_vsyo_horosho() -> None: """Не с чем сверять — это тоже «неизвестно», а не «ок». Обратный конец инварианта: сторож, который при поломке собственного входа возвращает ноль, хуже отсутствующего — он создаёт ложную уверенность. """ r = _run(DEPLOYED_SHA=MAIN_SHA, EXPECTED_SHA="") assert r.returncode == 2, r.stdout + r.stderr def test_workflow_zovyot_imenno_etot_skript() -> None: """Workflow и скрипт не разъезжаются: имя файла сверяется структурно.""" wf = REPO_ROOT / ".forgejo" / "workflows" / "deploy-drift.yml" assert wf.is_file(), "нет workflow сторожа" text = wf.read_text(encoding="utf-8") assert "scripts/check-deploy-drift.sh" in text, "workflow зовёт другой скрипт" assert "schedule:" in text and "cron:" in text, "сторож перестал быть периодическим"