Some checks failed
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
CI Trade-In / backend-tests (pull_request) Failing after 21s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 55s
CI Trade-In / frontend-checks (pull_request) Successful in 1m26s
CI / openapi-codegen-check (pull_request) Successful in 2m7s
CI / backend-tests (pull_request) Successful in 16m53s
_manifest_applied.txt по построению не мог покраснеть. Тест считал «новым» любой файл, которого нет в списке, а новые файлы от списка освобождены (докстринг test_manifest_covers_all_but_new_files: «НЕ требует, чтобы новый файл уже был в manifest»). Забытое имя и новая миграция PR для гейта — одно и то же, поэтому дрейф был не пропуском проверки, а её штатным исключением. Замер на main 2026-08-07: 15 имён не дописано, все четыре теста зелёные — через сутки после того, как #2692 догнал список руками. Список при этом был лишь копией того, что git и так знает: deploy-tradein.yml применяет КАЖДЫЙ data/sql/*.sql из main под ON_ERROR_STOP, то есть «файл доехал до main» и есть «имя закреплено на проде». Ведём эталон в git — и дрейфовать становится нечему. Кросс-ветковая дыра закрыта тем же ходом: номер нового файла сверяется с ПОЛНЫМ origin/main, а не с рабочим деревом, поэтому коллизия с миграцией, смерженной после ветвления, находится. Проверено на живом PR #2754 (234_trade_in_estimates_retain_until против 234_scrape_runs_ban_kind_unknown из main): старый гейт зелёный, новый красный. Удаление/переименование применённой миграции сверяется с ТОЧКОЙ ВЕТВЛЕНИЯ, а не с origin/main: иначе ветка недельной давности краснела бы за чужие миграции. Проверено — ветка от 2026-07-30 при +43 миграциях в main зелёная. CI: checkout переведён на fetch-depth 0 + отдельный fetch main. Этот Forgejo не публикует refs/pull/N/merge (1620 */head, ноль */merge), а на depth=1 нет ни origin/main, ни общего предка — без этого гейту не с чем сверять, и он намеренно красный, а не тихо пропущенный. Контракт сведён к одной формулировке — докстринг test_migration_numbering.py; шапка манифеста, правило 3, хвост манифеста и рецепт из .claude/rules удалены или заменены ссылкой. Заодно исправлен сам рецепт: `git ls-tree` без `-r` печатает каталог, а не файлы. Refs #2683
215 lines
12 KiB
Python
215 lines
12 KiB
Python
"""Инварианты нумерации миграций trade-in (issues #2216, #2683).
|
||
|
||
КОНТРАКТ. ОДНА ФОРМУЛИРОВКА, И ОНА ЗДЕСЬ — больше нигде её дублировать не надо.
|
||
|
||
Эталон «что уже закреплено на проде» — origin/main, а не файл-список.
|
||
deploy-tradein.yml прогоняет КАЖДЫЙ data/sql/*.sql из main под ON_ERROR_STOP
|
||
(падение миграции => красный деплой) и трекает применённое по bare filename в
|
||
public._schema_migrations. То есть «файл доехал до main» == «имя закреплено на
|
||
проде», и вести это знание отдельно от git незачем: git и есть журнал.
|
||
|
||
Отсюда ровно два инварианта, и ниже проверяются именно они.
|
||
|
||
1. Имя, существовавшее в точке ветвления, нельзя переименовать или удалить.
|
||
Прод помнит СТАРОЕ имя; новое считается неприменённым и прогоняется
|
||
повторно — дубль-INSERT / повторный DDL / PK violation под ON_ERROR_STOP,
|
||
то есть либо красный деплой, либо тихо задвоенные данные. Нужно изменить
|
||
уже применённую миграцию — заводи НОВЫЙ файл, старый оставь как есть.
|
||
|
||
2. НОВЫЙ файл обязан нести NN-префикс, свободный не только в рабочем дереве,
|
||
но и в origin/main. Порядок применения — `ls | sort`, два файла с одним NN
|
||
дают неопределённый порядок. Шесть исторических дублей (084/108/113/121/
|
||
124/130) не «новые» и не флагаются.
|
||
|
||
ДОПИСЫВАТЬ НИЧЕГО НЕ НАДО. Автор миграции кладёт файл со свободным номером — и
|
||
всё. Списка, который можно забыть обновить, здесь больше нет: до #2683 таким
|
||
списком был data/sql/_manifest_applied.txt, и он по построению не мог
|
||
покраснеть — «файл, которого нет в списке» и «новый файл этого PR» были для
|
||
теста одним и тем же, поэтому забытое имя навсегда оставалось зелёным
|
||
(замер 2026-08-07: 15 забытых имён, сьют зелёный).
|
||
|
||
ПОЧЕМУ ТОЧКА ВЕТВЛЕНИЯ, А НЕ САМ origin/main. Ветка, отведённая неделю назад,
|
||
не содержит миграций, смерженных после неё. Правило «origin/main ⊆ рабочее
|
||
дерево» красило бы каждую такую ветку без вины автора — и его отключили бы
|
||
через неделю. Сверка с merge-base ловит ровно то, что удалила или
|
||
переименовала ЭТА ветка, а номера при этом сверяются с ПОЛНЫМ origin/main,
|
||
чтобы коллизия с миграцией, смерженной после ветвления, всё-таки нашлась.
|
||
|
||
КАК УБЕДИТЬСЯ, ЧТО СТОРОЖ УМЕЕТ КРАСНЕТЬ (не на слово):
|
||
test_collision_rule_flags_a_taken_number ниже проверяет само правило на
|
||
литеральных входах, а сквозной прогон воспроизводится так —
|
||
git worktree add --detach /tmp/wt <sha до нужной миграции>
|
||
touch /tmp/wt/tradein-mvp/backend/data/sql/<занятый-NN>_probe.sql
|
||
(cd /tmp/wt/tradein-mvp/backend && pytest tests/test_migration_numbering.py)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import re
|
||
import subprocess
|
||
from collections.abc import Iterable
|
||
from pathlib import Path, PurePosixPath
|
||
|
||
import pytest
|
||
|
||
_TESTS_DIR = Path(__file__).resolve().parent
|
||
_SQL_DIR = _TESTS_DIR.parent / "data" / "sql"
|
||
_REPO_ROOT = _TESTS_DIR.parents[2]
|
||
# Путь каталога ОТ КОРНЯ РЕПО — им адресуем дерево коммита через git ls-tree.
|
||
_SQL_PATHSPEC = "tradein-mvp/backend/data/sql"
|
||
|
||
_NN_PREFIX = re.compile(r"^(\d+)_")
|
||
# origin — штатный remote; forgejo/main оставлен как исторический алиас.
|
||
_MAIN_REFS = ("origin/main", "forgejo/main", "main")
|
||
|
||
|
||
def _git(*args: str) -> str | None:
|
||
"""stdout git-команды, либо None если git недоступен/команда упала."""
|
||
try:
|
||
done = subprocess.run(
|
||
# Фиксированный argv, без shell — args приходят только из этого модуля.
|
||
["git", "-C", str(_REPO_ROOT), *args],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=30,
|
||
check=False,
|
||
)
|
||
except (OSError, subprocess.SubprocessError):
|
||
return None
|
||
return done.stdout if done.returncode == 0 else None
|
||
|
||
|
||
def _sql_names_at(rev: str) -> set[str]:
|
||
"""Bare-имена *.sql в data/sql на ревизии rev."""
|
||
out = _git("ls-tree", "-r", "-z", "--name-only", rev, "--", _SQL_PATHSPEC) or ""
|
||
return {PurePosixPath(p).name for p in out.split("\0") if p.endswith(".sql")}
|
||
|
||
|
||
def _sql_names_on_disk() -> set[str]:
|
||
"""Bare-имена *.sql в рабочем дереве — включая ещё не закоммиченные."""
|
||
return {p.name for p in _SQL_DIR.glob("*.sql")}
|
||
|
||
|
||
def _prefix(name: str) -> str | None:
|
||
m = _NN_PREFIX.match(name)
|
||
return m.group(1) if m else None
|
||
|
||
|
||
def _collisions(new_names: Iterable[str], universe: Iterable[str]) -> list[str]:
|
||
"""Для каждого НОВОГО имени — чужие имена с тем же NN-префиксом."""
|
||
by_prefix: dict[str, set[str]] = {}
|
||
for name in universe:
|
||
p = _prefix(name)
|
||
if p is not None:
|
||
by_prefix.setdefault(p, set()).add(name)
|
||
|
||
found: list[str] = []
|
||
for name in sorted(new_names):
|
||
p = _prefix(name)
|
||
if p is None:
|
||
continue
|
||
others = sorted(by_prefix.get(p, set()) - {name})
|
||
if others:
|
||
found.append(f"{name} — номер {p} уже занят: {', '.join(others)}")
|
||
return found
|
||
|
||
|
||
def _baseline() -> tuple[set[str], set[str]]:
|
||
"""(имена в точке ветвления, имена в main). Без git-эталона проверять нечего."""
|
||
main_ref = next(
|
||
(r for r in _MAIN_REFS if _git("rev-parse", "--verify", "--quiet", f"{r}^{{commit}}")),
|
||
None,
|
||
)
|
||
merge_base = None
|
||
if main_ref is not None:
|
||
out = _git("merge-base", main_ref, "HEAD")
|
||
merge_base = out.strip() if out else None
|
||
|
||
if main_ref is None or merge_base is None:
|
||
why = (
|
||
f"нет git-эталона миграций: ни один из {_MAIN_REFS} не резолвится либо у него "
|
||
f"нет общего предка с HEAD (repo={_REPO_ROOT}). Лечится "
|
||
"`git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main` "
|
||
"и НЕ shallow-клоном (нужен общий предок)."
|
||
)
|
||
# В CI это не «нечего проверять», а сломанный гейт: пропуск здесь и есть
|
||
# тот зелёный, который ничего не проверяет. Поэтому красим.
|
||
if os.environ.get("CI") or os.environ.get("GITHUB_ACTIONS"):
|
||
pytest.fail(why)
|
||
pytest.skip(why)
|
||
|
||
base_names = _sql_names_at(merge_base)
|
||
main_names = _sql_names_at(main_ref)
|
||
# Анти-вакуум: пустой эталон сделал бы обе проверки зелёными всегда.
|
||
# Ровно так ломается сторож, если _SQL_PATHSPEC разъедется с раскладкой репо.
|
||
assert base_names, (
|
||
f"эталон пуст: git ls-tree {merge_base} -- {_SQL_PATHSPEC} не вернул ни одного "
|
||
"*.sql. Проверка номеров была бы вакуумно-зелёной — почини путь."
|
||
)
|
||
assert main_names, f"в {main_ref} не найдено *.sql по пути {_SQL_PATHSPEC} — то же самое."
|
||
return base_names, main_names
|
||
|
||
|
||
def test_applied_migration_is_not_renamed_or_deleted() -> None:
|
||
"""Файл, существовавший в точке ветвления, обязан существовать и сейчас.
|
||
|
||
Red => эта ветка переименовала или удалила миграцию, которую прод уже
|
||
применил и помнит по СТАРОМУ имени. Верни исходное имя; нужно поправить
|
||
поведение — заводи новый файл с новым номером.
|
||
"""
|
||
on_disk = _sql_names_on_disk()
|
||
assert on_disk, f"не найдено *.sql в {_SQL_DIR}"
|
||
base_names, _ = _baseline()
|
||
|
||
gone = sorted(base_names - on_disk)
|
||
assert not gone, (
|
||
f"миграции пропали из {_SQL_PATHSPEC}/ (переименованы или удалены): {gone}. "
|
||
"Прод трекает их по bare-filename в _schema_migrations — под новым именем "
|
||
"миграция прогонится повторно. Верни имена как были."
|
||
)
|
||
|
||
|
||
def test_new_migration_takes_a_free_number() -> None:
|
||
"""Новый файл не переиспользует NN, занятый в origin/main или в этой ветке.
|
||
|
||
Red => номер уже занят. Возьми следующий свободный, сверяясь с origin/main:
|
||
|
||
git fetch origin main
|
||
git ls-tree -r --name-only origin/main -- tradein-mvp/backend/data/sql | tail
|
||
|
||
`-r` обязателен: без него ls-tree печатает сам каталог, а не файлы (в этом
|
||
виде рецепт и ходил по issue #2683 — и молча возвращал одну строку).
|
||
Локального `ls` недостаточно: он не видит миграций, смерженных после
|
||
ветвления — ровно так разъехались 212 в #2682 и 234 в #2754.
|
||
"""
|
||
on_disk = _sql_names_on_disk()
|
||
base_names, main_names = _baseline()
|
||
|
||
new_names = on_disk - base_names
|
||
problems = _collisions(new_names, main_names | on_disk)
|
||
assert not problems, "коллизия номеров миграций: " + "; ".join(problems)
|
||
|
||
|
||
def test_collision_rule_flags_a_taken_number() -> None:
|
||
"""Проверка самого правила — сторож обязан уметь краснеть (#2683 п.6).
|
||
|
||
Ожидания здесь — ЛИТЕРАЛЫ, а не производные от содержимого data/sql: тест,
|
||
который берёт ожидание из охраняемой настройки, зелен при любой настройке.
|
||
"""
|
||
# Реальный случай #2754: ветка отвелась до того, как в main приехал 234_scrape.
|
||
assert _collisions(
|
||
["234_trade_in_estimates_retain_until.sql"],
|
||
{
|
||
"233_payments.sql",
|
||
"234_scrape_runs_ban_kind_unknown.sql",
|
||
"234_trade_in_estimates_retain_until.sql",
|
||
},
|
||
) == [
|
||
"234_trade_in_estimates_retain_until.sql — номер 234 уже занят: "
|
||
"234_scrape_runs_ban_kind_unknown.sql"
|
||
]
|
||
# Два новых файла с одним номером внутри одной ветки — оба названы.
|
||
assert len(_collisions(["300_a.sql", "300_b.sql"], {"300_a.sql", "300_b.sql"})) == 2
|
||
# Свободный номер — тишина; исторические дубли не новые и не флагаются.
|
||
assert _collisions(["300_a.sql"], {"084_x.sql", "084_y.sql", "300_a.sql"}) == []
|