From 426326e2af2580b008ea9ab075fd3740ebae8167 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Fri, 13 Feb 2026 09:05:21 +0000 Subject: [PATCH] ci(migrations): enforce graph + reversible cycle checks; fix FK downgrade naming --- Makefile | 9 ++- ...76359_sync_agent_gateway_linkage_schema.py | 4 +- backend/scripts/check_migration_graph.py | 77 +++++++++++++++++++ 3 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 backend/scripts/check_migration_graph.py diff --git a/Makefile b/Makefile index 4ecffdc..b126da2 100644 --- a/Makefile +++ b/Makefile @@ -105,8 +105,9 @@ backend-migrate: ## Apply backend DB migrations (uses backend/migrations) cd $(BACKEND_DIR) && uv run alembic upgrade head .PHONY: backend-migration-check -backend-migration-check: ## Validate Alembic migrations on clean Postgres (upgrade head + single-head sanity) +backend-migration-check: ## Validate migration graph + reversible path on clean Postgres @set -euo pipefail; \ + (cd $(BACKEND_DIR) && uv run python scripts/check_migration_graph.py); \ CONTAINER_NAME="mc-migration-check-$$RANDOM"; \ docker run -d --rm --name $$CONTAINER_NAME -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=migration_ci -p 55432:5432 postgres:16 >/dev/null; \ cleanup() { docker rm -f $$CONTAINER_NAME >/dev/null 2>&1 || true; }; \ @@ -124,7 +125,11 @@ backend-migration-check: ## Validate Alembic migrations on clean Postgres (upgra AUTH_MODE=local \ LOCAL_AUTH_TOKEN=ci-local-token-ci-local-token-ci-local-token-ci-local-token \ DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:55432/migration_ci \ - uv run alembic heads | grep -q "(head)" + uv run alembic downgrade base && \ + AUTH_MODE=local \ + LOCAL_AUTH_TOKEN=ci-local-token-ci-local-token-ci-local-token-ci-local-token \ + DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:55432/migration_ci \ + uv run alembic upgrade head .PHONY: build build: frontend-build ## Build artifacts diff --git a/backend/migrations/versions/b308f2876359_sync_agent_gateway_linkage_schema.py b/backend/migrations/versions/b308f2876359_sync_agent_gateway_linkage_schema.py index 3863b4d..3d1a07a 100644 --- a/backend/migrations/versions/b308f2876359_sync_agent_gateway_linkage_schema.py +++ b/backend/migrations/versions/b308f2876359_sync_agent_gateway_linkage_schema.py @@ -22,7 +22,7 @@ def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.add_column('agents', sa.Column('gateway_id', sa.Uuid(), nullable=False)) op.create_index(op.f('ix_agents_gateway_id'), 'agents', ['gateway_id'], unique=False) - op.create_foreign_key(None, 'agents', 'gateways', ['gateway_id'], ['id']) + op.create_foreign_key('fk_agents_gateway_id_gateways', 'agents', 'gateways', ['gateway_id'], ['id']) op.drop_column('gateways', 'main_session_key') # ### end Alembic commands ### @@ -30,7 +30,7 @@ def upgrade() -> None: def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.add_column('gateways', sa.Column('main_session_key', sa.VARCHAR(), autoincrement=False, nullable=False)) - op.drop_constraint(None, 'agents', type_='foreignkey') + op.drop_constraint('fk_agents_gateway_id_gateways', 'agents', type_='foreignkey') op.drop_index(op.f('ix_agents_gateway_id'), table_name='agents') op.drop_column('agents', 'gateway_id') # ### end Alembic commands ### diff --git a/backend/scripts/check_migration_graph.py b/backend/scripts/check_migration_graph.py new file mode 100644 index 0000000..444583f --- /dev/null +++ b/backend/scripts/check_migration_graph.py @@ -0,0 +1,77 @@ +"""Migration graph integrity checks for CI. + +Checks: +- alembic script graph can be loaded (detects broken/missing links) +- single head by default (unless ALLOW_MULTIPLE_HEADS=true) +- no orphan revisions (all revisions reachable from heads) +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +from alembic.config import Config +from alembic.script import ScriptDirectory + + +def _truthy(value: str | None) -> bool: + return (value or "").strip().lower() in {"1", "true", "yes", "on"} + + +def main() -> int: + root = Path(__file__).resolve().parents[1] + alembic_ini = root / "alembic.ini" + cfg = Config(str(alembic_ini)) + cfg.attributes["configure_logger"] = False + + try: + script = ScriptDirectory.from_config(cfg) + except Exception as exc: # pragma: no cover - CI path + print(f"ERROR: unable to load Alembic script directory: {exc}") + return 1 + + try: + heads = list(script.get_heads()) + except Exception as exc: # pragma: no cover - CI path + print(f"ERROR: unable to resolve Alembic heads: {exc}") + return 1 + + allow_multiple_heads = _truthy(os.getenv("ALLOW_MULTIPLE_HEADS")) + if not heads: + print("ERROR: no Alembic heads found") + return 1 + + if len(heads) > 1 and not allow_multiple_heads: + print("ERROR: multiple Alembic heads detected (set ALLOW_MULTIPLE_HEADS=true only for intentional merge windows)") + for h in heads: + print(f" - {h}") + return 1 + + try: + reachable = {rev.revision for rev in script.walk_revisions(base="base", head="heads") if rev.revision} + except Exception as exc: # pragma: no cover - CI path + print(f"ERROR: failed while walking Alembic revision graph: {exc}") + return 1 + + all_revisions = { + rev.revision + for rev in script.revision_map._revision_map.values() # type: ignore[attr-defined] + if getattr(rev, "revision", None) + } + orphans = sorted(all_revisions - reachable) + if orphans: + print("ERROR: orphan Alembic revisions detected (not reachable from heads):") + for rev in orphans: + print(f" - {rev}") + return 1 + + print("OK: migration graph integrity passed") + print(f"Heads: {', '.join(heads)}") + print(f"Reachable revisions: {len(reachable)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())