ci: migration integrity gate
Merge PR #120 (migration integrity + reversible upgrade/downgrade gate).
This commit is contained in:
30
.github/workflows/ci.yml
vendored
30
.github/workflows/ci.yml
vendored
@@ -62,6 +62,36 @@ jobs:
|
||||
nextjs-${{ runner.os }}-node-${{ steps.setup-node.outputs.node-version }}-
|
||||
|
||||
|
||||
|
||||
- name: Run migration integrity gate
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
BASE_SHA="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD_SHA="${{ github.sha }}"
|
||||
git fetch --no-tags --depth=1 origin "$BASE_SHA"
|
||||
else
|
||||
BASE_SHA="${{ github.event.before }}"
|
||||
HEAD_SHA="${{ github.sha }}"
|
||||
fi
|
||||
|
||||
CHANGED_FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED_FILES"
|
||||
|
||||
if ! echo "$CHANGED_FILES" | grep -Eq '^backend/(app/models|db|migrations|alembic\.ini)'; then
|
||||
echo "No migration-relevant backend changes detected; skipping migration gate."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if echo "$CHANGED_FILES" | grep -Eq '^backend/app/models/' && ! echo "$CHANGED_FILES" | grep -Eq '^backend/migrations/versions/'; then
|
||||
echo "Model changes detected without a migration under backend/migrations/versions/."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
make backend-migration-check
|
||||
|
||||
- name: Run backend checks
|
||||
env:
|
||||
# Keep CI builds deterministic.
|
||||
|
||||
27
Makefile
27
Makefile
@@ -104,6 +104,33 @@ frontend-test: frontend-tooling ## Frontend tests (vitest)
|
||||
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 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; }; \
|
||||
trap cleanup EXIT; \
|
||||
for i in $$(seq 1 30); do \
|
||||
if docker exec $$CONTAINER_NAME pg_isready -U postgres -d migration_ci >/dev/null 2>&1; then break; fi; \
|
||||
sleep 1; \
|
||||
if [ $$i -eq 30 ]; then echo "Postgres did not become ready"; exit 1; fi; \
|
||||
done; \
|
||||
cd $(BACKEND_DIR) && \
|
||||
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 && \
|
||||
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 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
|
||||
|
||||
|
||||
@@ -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 ###
|
||||
|
||||
85
backend/scripts/check_migration_graph.py
Normal file
85
backend/scripts/check_migration_graph.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""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
|
||||
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: set[str] = set()
|
||||
for walk_rev in script.walk_revisions(base="base", head="heads"):
|
||||
if walk_rev is None:
|
||||
continue
|
||||
if walk_rev.revision:
|
||||
reachable.add(walk_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: set[str] = set()
|
||||
# Alembic's revision_map is dynamically typed; guard None values.
|
||||
for map_rev in script.revision_map._revision_map.values():
|
||||
if map_rev is None:
|
||||
continue
|
||||
revision = getattr(map_rev, "revision", None)
|
||||
if revision:
|
||||
all_revisions.add(revision)
|
||||
|
||||
orphans = sorted(all_revisions - reachable)
|
||||
if orphans:
|
||||
print("ERROR: orphan Alembic revisions detected (not reachable from heads):")
|
||||
for orphan_rev in orphans:
|
||||
print(f" - {orphan_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())
|
||||
@@ -1,3 +1,23 @@
|
||||
# Development workflow
|
||||
|
||||
Placeholder: see root `README.md` for current setup steps.
|
||||
## Migration integrity gate (CI)
|
||||
|
||||
CI enforces a migration integrity gate to prevent merge-time schema breakages.
|
||||
|
||||
### What it validates
|
||||
|
||||
- Alembic migrations can apply from a clean Postgres database (`upgrade head`)
|
||||
- Alembic revision graph resolves to a head revision after migration apply
|
||||
- On migration-relevant PRs, CI also checks that model changes are accompanied by migration updates
|
||||
|
||||
If any of these checks fails, CI fails and the PR is blocked.
|
||||
|
||||
### Local reproduction
|
||||
|
||||
From repo root:
|
||||
|
||||
```bash
|
||||
make backend-migration-check
|
||||
```
|
||||
|
||||
This command starts a temporary Postgres container, runs migration checks, and cleans up the container.
|
||||
|
||||
Reference in New Issue
Block a user