Files
xiaoxia-saas/scripts/ci/check_migration_chain.py
xiaoxia 61dcd196fd
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m48s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 5m12s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m10s
CI/CD Pipeline / Frontend Lint (push) Successful in 7m35s
CI/CD Pipeline / Unit Tests (push) Successful in 7m46s
CI/CD Pipeline / Integration Tests (push) Successful in 2m53s
CI/CD Pipeline / Build Staging API Image (push) Successful in 8m15s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 10m28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 52s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 36s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m22s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m18s
feat(ci): Validate阶段增加migration编号连续性检查 (#652)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 20:46:48 +08:00

175 lines
5.7 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
检查 Alembic migration 编号连续性。
扫描 alembic/versions/ 下所有 migration 文件,提取 revision 和 down_revision
验证整条链是否完整——每个 down_revision(除了 baseline 的 None)都必须对应一个存在的 revision。
支持两种格式:
revision: str = "001" # 旧格式(带类型注解)
revision = "038_error_retry" # 新格式(带描述后缀)
匹配策略:提取 revision 名称的数字前缀(如 "001"、"038")作为唯一标识进行匹配,
兼容纯数字编号和"数字_描述"两种命名风格。
用法:
python3 scripts/ci/check_migration_chain.py [alembic_versions_dir]
默认目录: alembic/versions/
退出码:
0 - 链完整
1 - 有断链或其他错误
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
# 匹配 revision / down_revision,支持带类型注解和不带类型注解两种格式
# revision: str = "xxx" 或 revision = "xxx"
REV_PATTERN = re.compile(
r'^\s*revision\s*(?::\s*str\s*)?=\s*["\']([^"\']+)["\']',
re.MULTILINE,
)
DOWN_PATTERN = re.compile(
r'^\s*down_revision\s*(?::\s*(?:Union\[str,\s*None\]|str\s*\|\s*None|None|str)\s*)?=\s*(["\']([^"\']+)["\']|None)',
re.MULTILINE,
)
# 提取 revision 名称的数字前缀,如 "001" 或 "038_error_retry" → "038"
NUM_PREFIX_PATTERN = re.compile(r"^(\d+)")
def num_prefix(name: str) -> str:
"""提取 revision 名称的数字前缀。"""
m = NUM_PREFIX_PATTERN.match(name)
return m.group(1) if m else name
def extract_migration_info(filepath: Path) -> tuple[str, str | None]:
"""从 migration 文件中提取 revision 和 down_revision(返回完整名称)。"""
content = filepath.read_text(encoding="utf-8")
rev_match = REV_PATTERN.search(content)
down_match = DOWN_PATTERN.search(content)
if not rev_match:
raise ValueError(f"{filepath.name}: 未找到 revision 定义")
revision = rev_match.group(1)
if not down_match:
raise ValueError(f"{filepath.name}: 未找到 down_revision 定义")
# down_match group(2) 是引号内的值,如果是 None 则 group(2) 为 None
down_revision = down_match.group(2)
return revision, down_revision
def check_chain(versions_dir: Path) -> list[str]:
"""检查 migration 链是否完整,返回错误列表。"""
errors: list[str] = []
if not versions_dir.is_dir():
return [f"目录不存在: {versions_dir}"]
py_files = sorted(versions_dir.glob("*.py"))
if not py_files:
return [f"目录下没有 migration 文件: {versions_dir}"]
# 收集所有 revision(用数字前缀做唯一标识)
revisions_by_num: dict[str, str] = {} # 数字前缀 -> 完整 revision 名
revision_files: dict[str, str] = {} # 数字前缀 -> 文件名
down_revisions: list[tuple[str, str | None]] = [] # (文件名, down_revision 数字前缀或None)
for f in py_files:
if f.name.startswith("__"):
continue
try:
rev, down = extract_migration_info(f)
except ValueError as e:
errors.append(str(e))
continue
rev_num = num_prefix(rev)
if rev_num in revisions_by_num:
errors.append(
f"编号重复: 编号 {rev_num} 同时出现在 "
f"{f.name} (revision={rev}) 和 {revision_files[rev_num]} (revision={revisions_by_num[rev_num]})"
)
else:
revisions_by_num[rev_num] = rev
revision_files[rev_num] = f.name
down_num = num_prefix(down) if down else None
down_revisions.append((f.name, down_num))
if errors:
return errors
# 检查每个 down_revision 是否存在
baselines = 0
for filename, down_num in down_revisions:
if down_num is None:
baselines += 1
continue
if down_num not in revisions_by_num:
errors.append(
f"断链: {filename} 的 down_revision 指向编号 '{down_num}',但没有任何 migration 的 revision 是这个编号"
)
if baselines == 0:
errors.append("没有找到 baseline migrationdown_revision = None 的文件)")
elif baselines > 1:
errors.append(f"发现 {baselines} 个 baseline migration,通常只能有 1 个")
# 额外检查:数字编号是否连续(只对能提取出数字的)
if revisions_by_num and not errors:
nums = sorted(int(n) for n in revisions_by_num if n.isdigit())
if nums:
expected = list(range(nums[0], nums[-1] + 1))
missing = [n for n in expected if n not in nums]
if missing:
missing_str = ", ".join(f"{n:03d}" for n in missing)
errors.append(f"编号不连续: 缺少编号 {missing_str}")
return errors
def main() -> int:
if len(sys.argv) > 1:
versions_dir = Path(sys.argv[1])
else:
versions_dir = Path("alembic/versions")
print(f"检查 migration 编号连续性: {versions_dir}")
print()
errors = check_chain(versions_dir)
py_files = [f for f in versions_dir.glob("*.py") if not f.name.startswith("__")]
if errors:
print(f"❌ Migration 链有问题(共 {len(py_files)} 个文件,{len(errors)} 个错误):")
for e in errors:
print(f" - {e}")
print()
print("请修复后再提交。常见原因:")
print(" 1. 新 migration 的 down_revision 编号写错了")
print(" 2. 多个 PR 同时加 migration,编号冲突")
print(" 3. 合并代码时漏了某个 migration 文件")
return 1
print(f"✅ Migration 链完整,共 {len(py_files)} 个版本")
return 0
if __name__ == "__main__":
sys.exit(main())