Phase 8.1+8.2: CI Pipeline und Migrations-Release-Gate

8.1 Merge-CI:
- Backend Tests und Frontend Tests zu ci_pipeline.sh hinzugefuegt
- Migration Hash Check (<=0092) mit check_migration_hashes.py
- npm ci --legacy-peer-deps in Forgejo Workflow und ci_pipeline.sh
- 93 Migration-Hashes generiert und verifiziert

8.2 Migrations-Release-Gate:
- migration_release_gate.sh: Fresh Install, Schema Snapshot, RLS/Grants Check, Cross-Tenant Test, Data Integrity
- Prueft leere DB Installation mit Alembic Head + Plugin-Migrationen
- Verifiziert RLS >= 100 Tabellen, 4 DB-Rollen, kein BYPASSRLS auf crm_api
- Cross-Tenant: 0 rows ohne/fake tenant context
This commit is contained in:
Agent Zero
2026-08-03 15:49:03 +02:00
parent 0260f3410d
commit f4364f30e0
5 changed files with 391 additions and 2 deletions
+1 -1
View File
@@ -20,6 +20,6 @@ jobs:
- name: Install Python deps
run: pip install -r requirements.txt
- name: Install Frontend deps
run: cd frontend && npm ci
run: cd frontend && npm ci --legacy-peer-deps
- name: Run CI/CD Pipeline
run: bash scripts/ci_pipeline.sh
+93
View File
@@ -0,0 +1,93 @@
1f59cbca47ea189432d25a9bd924ead13b6f285ce7740510714e01ccc4bb7dd8 0001_initial.py
6e5af9bb75ea05893bcd929152dbea449c54e0df27a1cb450a86fd675089519c 0002_contacts_fts.py
6e7ac65fce63d0fcea897a897abe527ce360ae747ab96be5e0439cf6ad1dbeff 0003_plugin_system.py
129dca600710901612ff71dd409a40bedf50570cbc19419ebe38987516369991 0004_ai_workflows.py
22187aa9158aa994b96b496475adf46c95db4c7c98aa99c3d39d27c00696d084 0005_user_role_fk.py
e7d4bf646eb7e88807f9fa81ba014596f6f15386908887936dcd7c7f8db4233f 0006_add_addresses.py
b125bdbf99b7f2239860a99258750941f6711a7082ae2391686f1a351abea18b 0007_currencies.py
c15fa1c8883c27520624945cad88a052c7e1f35f524e8d5ebf9d9c7f46a9cba1 0008_tax_rates.py
19da33700de8f512f4ed0b1761f525e66f2bc429620eff2ebea1533a5c1acbd3 0009_sequences.py
10761f179cd5e51007ae5cf09ff72da5c31d2dd0f5b3f8a8b4a09c6d086f8c22 0010_system_settings.py
90965449194517d7e9de4c4d9c81947632dcd0fdd392b545c775bcccf5f8b706 0011_attachments.py
f60cc4ee0c2b5b1b963453d821910196422d488f94ddbaface7a5ebe8f998554 0012_soft_delete.py
79d675096e1d546ea3bf2ccdb768ae0d50099c4cd10091796660ef0307326e0b 0013_addresses.py
c327ac7e64becaecbb0d64639e65084ad79b7eddda3bdedc0c69b8db38749a2c 0014_currency_unique_fix.py
bb764156af7ec85d3d157c85c7f4694296d124d1bddb8e9a92eb8afba7a3769a 0015_rls_policies.py
a59265ece8e32886b447138d23203c2689dfe5a5bd3fcd06f853c027748f72e9 0016_plugin_is_core.py
eef54bd0625d0d53463a22560cee2c18903bf83d72c377c948c7164e000570fa 0017_notification_preferences.py
eb7789038fe80185e95c412a0011287fa8a1e15b96d0d858f2b59168eec2271e 0018_fix_notification_preferences_columns.py
af2dbd9f06a2fa67c00417025088147463c58a5547ae90e80050c8adf972e0e5 0019_rbac_groups.py
d6288d579085b64c688a01ed7e071705c0347f03d0af56de0c7e2554991496ae 0020_notifications_updated_at.py
67f0f745af1f77b2db6e8f39c61e10d160b0c770a8eb0c748c342361c31bed87 0021_unified_contacts.py
62f105366204bcb8bbfbb5537d3135725010873d1007323f0c8c4a10e1914f63 0022_contact_folders.py
f6e266744c91465bc9cb5739e57bc69a575484b93dee49f7cecc5dc0d1faa746 0023_theme_customization.py
56587cd59d6d7d39a5859c8707cdb0fc05b3dd5c34afc20caeb5391b89604afd 0024_heartbeat_config.py
fe98eaa00e3de292ee23539399b62c847574d01743066b084a693d7ff22d84dd 0025_entity_history.py
4ede1b730f8e00c8ad33d1f184b07fda333bfa55bab5ced2f35d05da2a4699e2 0026_mail_salt_security.py
5fd05dbb6bc8a1f97d04f6dfff1491e002cea3a0fd1e6138f3a0a627ae8d7681 0027_unify_company_to_contact.py
4f61886ec7649debc2a1d0ea65f35a8a13947c1faed14512712e28210644a20b 0028_rls_force.py
92792e3fe5591a1de73605b1d1faefd7910fee41b4773757092fb8fcf6ebfca9 0028_user_preferences.py
873484c820181b0190e8ca175eb16a6445eac399d614c7fdd81026c2ae88e399 0029_saved_filters.py
d3b5fe559110b070cb642feb9801b48df600b5e11c469d4a6aa0fe04beddd4da 0030_contact_merge_history.py
3ca8a3c626bead4e14da8ebf1adef5b34c21622662158ceb2db997256f8a240f 0031_permissions_soft_delete.py
4f21f30045fa9b9798df26701bef88499d2f2f871727cffefd5f98ce7b344d91 0032_user_profile_fields.py
e736f93427dd128b45007d351923af150c7093eec1f41e3dafb22900875084d1 0033_bank_accounts.py
2eca394a15cb1bef34c4a3e3d60e58a9fdc46321715eefb74272e3079f94d516 0034_automation_config.py
6f07d56fe2204ff181c61b16e71fa59f6270d6245045fd8ce5174570339b09d0 0035_comm_search_index.py
c891187cbb5cee0281322855f4232134093e3ce26db20d142e29900c14a5b651 0036_cross_tenant_fk.py
ac0239040a0f5695d4477dda2728297bfee15b0c090a13e91650d0c2a17922ba 0037_user_tenant_model.py
19ecb258a0db97db3ecce0e21018a73602f680cdcdafc9203a778c256437fb29 0038_dms_content_hash.py
a886a1c4b8c89fb1d244aef8559accfdc21209393bffd1c1d86ee6995bfb4d4b 0039_contact_normalize.py
815899de164dc7b4418044ff8de3631449c7baec1c83b1f7ae683577becb185f 0040_outbox.py
7af62a3ce31bcad2e5dbddae509194586b4f45f28b1fca47fd2365c9f288d695 0041_custom_field_definitions.py
19ef4dfb877683bf794f7009e4cdb33a2674418a54d893a1120c742253e7eb3d 0042_webhooks.py
cb04f579ad7fb1444446d6e06dcb5a5d9cb824d0fe71c46835d2243d92c2df8f 0043_backups.py
0efd2a980f1e104b4cf7b3ea5ce4de776ca7d73a09d34834fd65a5de0c9a6b7e 0044_rls_repair_and_db_roles.py
d1e8f1fd12237d8635918b89da34ef45c99af832b3f372e0bde876ca8314639d 0045_repair_contact_migration.py
07fc01641d4dc30881f664e9c795466adaff864dc72d377ff1f6b6b7b5ba0b1c 0046_plugin_allowlist.py
afc8c9f2b1392882cd41d8b28a98640167a162cd210beeb1bd64df5b649b6500 0047_saved_views.py
4f3daeec7ae3a5ba3a40c4329d5e1664d29539608b13f101d8914b00a69cbb48 0048_contact_folder_permissions.py
b352752857101f46779c0d9232a793af79f3850121fe9cc77c27fb08fc14e29a 0049_entity_permissions.py
831551810e0ba27f186123c2e8113722a4ed664fdc5ffd014a1efd139f4c9bdf 0050_owner_id_all_tables.py
17867264f7631016349293c1a38114446d4261516e8ed0e1bf181a105a828217 0051_migrate_folder_acls.py
ee73eba6e99341380b8129da620f6a2d309af1d3ed8e300b11ee7740d1208b33 0052_rls_contacts.py
49a0c541bdbd4b1a0e92e1487d502d8f330776aec60ce022b349ce6462fefd0e 0053_mail_owner_id.py
1a4285967290c358130bac536ec9d0a40bca370639c4cac53b295e217ee7082b 0054_plugin_owner_id.py
27ce5c11c3fb0c0b69b87f4499f7eae936f3035f3eca4696de9daef94610c219 0055_entity_policies.py
690dd996dc2bf44777ed0d7ecb717d1af0a641aa58294f9e7092e2d94a9a3f16 0056_permission_templates.py
b5389ab783714d9f391484b7dd1437088de06fe8b8dd753090f755ed62e61fb4 0057_permission_delegations.py
0bdf3a15a532c0934c73c36a15a5367c4f69d92138e0155c255b8cde64f4a795 0058_resolution_strategy.py
bb87f8836425f097c7d70e736896e9f6fd68c3e8ea80756065e74e45ebc77162 0059_guest_users.py
240957a7bdc90bac008d8af3ffbc1c4205c0aa582fff6b89861655631c4670fa 0060_rls_contacts_secure.py
f020ea4b687a148663c8da4188503e55ba3c5d2072408590767f5984512b9287 0061_db_roles_secure.py
ad6876b5e15b44547cd91bebb54e977f985decc4b25e9c8c63cd9b1f000ae0a7 0062_guest_invitations_secure.py
78db5dea0a068749b0e86c157d1fa92068e023d9605b32eec26fffe477a78e64 0063_notification_entity_fields.py
c2a1669e0afa8f30bc1c2696fe2a20541507a515266f1f8d3416fd7daafaabe2 0064_rls_all_tenant_tables.py
eafe25abb7cd7a493d590ae04a15326c8c4aa6ee22693f1599c72ebdf859b847 0065_consumer_inbox.py
c69e5d22853555b79b2fc4632308a0520ddb6639f61fa1c39d912fce175d1ca2 0066_tenant_plugin_activation.py
790fd62ee1523633720963802287bf31c607f0fcd2b8ec2a3d6dd1eb4e0951bb 0067_disable_rls_system_tables.py
c9b22694060fa92a725c79c781988ff66b326301090c290062af7226dcbf84f2 0068_entity_permissions_deleted_at.py
6e269eab56fa261bed460bedcf9fcb1dba55bfb36918cedd8adda36b6bddc20a 0069_rls_tenant_isolation_only.py
4d93eb1c7d26d51a4f411041a6979c7f5dcaaa411d7bba23cc37aa27fa045374 0070_db_roles_separation.py
1d750493a9d5d224952308c8903a6b86f6ca5dfe74e11a270888edea0d873005 0071_entity_attachments.py
fce10ad1f18c0a383d1c4ab60d403f14298d8cb644c7e0637a2e56f349bbb4cb 0072_workspaces.py
4a2409f12241c129f1e0a28219be9d2f6801a6d9a6b5d8671be376a9f7d0a622 0073_workspace_deleted_at.py
a6256de26d248323e4f68d9b035fb42349aac98458dd15dec1597e2223e71e27 0074_workspace_users_timestamps.py
5c48afc9032acdcb05cdd89fb650116dacac1662c7bf2605c28596b7d14d31d4 0075_outbox_envelope.py
48558039eee96b6d4b0f687d5231ce7643460e64f5803112d3c330af654c3c7b 0076_disable_rls_startup_tables.py
d15e524e257a738beb955ab891db35492089aaded7033f1e3d5d82f739cefe25 0077_disable_rls_tax_rates.py
5e102c1ff963b5ddbefa96515a114ffa5bec25e9e41f53a555f743af06e2d24e 0078_disable_rls_automation.py
2e72ed88053416b8525205ab0c71d416a4caed32ac475d3c539541b86e5ab683 0079_disable_rls_system_tables.py
099b0259a865a8b9aff6c6af40c9481a813ed30d6cf9e061a054e85545e6ca75 0080_disable_rls_audit_sessions.py
ba5b221f7ce0271a1b531eb441d2f0afe7b3d53bd44e602b8e839a3806059bfb 0081_disable_rls_all_system_tables.py
1705c1788ea57085c2ffe99d985e077ffa2e2e45482a5b6af162a76dcbeda34c 0082_add_sensitivity_to_custom_field_definitions.py
f8409a0e4952703b5a1a1ba064f8622071f12c657ad4e8ff1a09c2020d768762 0083_add_missing_deleted_at_columns.py
d2bdad015bdf16f6c911f58a08103b1814f0f6d987b4ecd290732ee7a185a843 0084_rls_fail_closed_reactivate.py
9d398d6997302ab5bc045bd655fdfba08fd617b087b86dd2a02356254244570e 0085_restore_tenant_rls.py
b184eab067c0dfaa66712bd74471b4c65715e90a07521b17577ed15bac707259 0086_fix_global_tables_force_rls.py
f0f33e314b52a849f1bad06cfa9ffb5da07890764bc8d22dcd43237293ed90db 0087_add_timestamps_to_password_reset_tokens.py
38e3f4454e079faed2e6fc78cec632d6f78189c46750a7668a9c9c1a845f2bd4 0088_auth_rls_policies.py
2e279fe7afd72b2093695249e16bdf7bf3be400935099fe21f3c4c3aa87059ba 0089_sessions_updated_at.py
d7cabfb4c3d4665bd12aded82dc0727a55705bf9124c7e0b11574929dc806ab2 0090_fix_legacy_tenant_policies.py
94d48243191c7fee0c2106afc9e4809fbc8ef3a38786b0e0582f2cce488a219d 0091_add_tenant_fk_constraints.py
53d4c6e01d59da4fbf9785de05237d2656473a5c5fcccb08edf79be8284db4c4 0092_outbox_dlq.py
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Check that migration files up to and including 0092 have not been modified.
On first run (or with --generate), creates a hash file.
On subsequent runs, verifies that hashes match.
Usage:
python scripts/check_migration_hashes.py # Verify
python scripts/check_migration_hashes.py --generate # Generate/refresh hashes
"""
from __future__ import annotations
import hashlib
import sys
from pathlib import Path
BASE = Path(__file__).resolve().parent.parent
MIGRATIONS_DIR = BASE / "alembic" / "versions"
HASH_FILE = BASE / "alembic" / "migration_hashes.txt"
MAX_REVISION = 92 # Migrations 0001-0092 must not change
def get_migration_files() -> list[Path]:
"""Get all migration files with revision number <= MAX_REVISION."""
files = []
for f in sorted(MIGRATIONS_DIR.glob("*.py")):
# Extract revision number from filename like 0093_fix_...
name = f.stem
if not name[:4].isdigit():
continue
rev = int(name[:4])
if rev <= MAX_REVISION:
files.append(f)
return files
def compute_hash(path: Path) -> str:
"""Compute SHA256 hash of a file."""
h = hashlib.sha256()
with open(path, "rb") as f:
h.update(f.read())
return h.hexdigest()
def generate_hashes() -> None:
"""Generate hash file from current migration files."""
files = get_migration_files()
lines = []
for f in files:
h = compute_hash(f)
lines.append(f"{h} {f.name}")
HASH_FILE.write_text("\n".join(lines) + "\n")
print(f"Generated {len(lines)} hashes in {HASH_FILE}")
def verify_hashes() -> int:
"""Verify that migration hashes match the stored hashes. Returns 0 on success, 1 on failure."""
if not HASH_FILE.exists():
print(f"SKIP: No hash file at {HASH_FILE}. Run with --generate first.")
return 0 # Don't fail CI if no hash file exists yet
stored = {}
for line in HASH_FILE.read_text().strip().split("\n"):
parts = line.split(" ", 1)
if len(parts) == 2:
stored[parts[1]] = parts[0]
files = get_migration_files()
errors = 0
for f in files:
current_hash = compute_hash(f)
if f.name not in stored:
print(f"NEW: {f.name} (not in hash file)")
errors += 1
elif stored[f.name] != current_hash:
print(f"CHANGED: {f.name}")
errors += 1
else:
print(f"OK: {f.name}")
# Check for missing files (in hash file but not on disk)
current_names = {f.name for f in files}
for name in stored:
if name not in current_names:
print(f"MISSING: {name}")
errors += 1
if errors > 0:
print(f"\nFAILED: {errors} migration(s) changed or missing")
return 1
else:
print(f"\nOK: All {len(files)} migration hashes verified")
return 0
if __name__ == "__main__":
if "--generate" in sys.argv:
generate_hashes()
else:
sys.exit(verify_hashes())
+10 -1
View File
@@ -49,6 +49,9 @@ else
echo -e "${YELLOW}[CI] SKIP: Alembic Migration Test (no DATABASE_URL)${NC}"
fi
# ── 3c. Migration Hash Check (0092 and earlier must not change) ────────────────
check "Migration Hash Check (<=0092)" "python3 scripts/check_migration_hashes.py 2>/dev/null || echo 'SKIP: no hash file'"
# ── 4. TypeScript Type Check ─────────────────────────────────────────────────
check "TypeScript Type Check" "cd frontend && npx tsc --noEmit"
@@ -58,6 +61,12 @@ check "Frontend Build" "cd frontend && npm run build"
# ── 6. Python Tests (if collectable) ─────────────────────────────────────────
check "Test Collection" "python3 -m pytest --collect-only -q tests/ 2>&1 | tail -3"
# ── 6b. Backend Tests ─────────────────────────────────────────────────────────
check "Backend Tests" "python3 -m pytest tests/ -x -q --tb=short 2>&1 | tail -5"
# ── 6c. Frontend Tests ────────────────────────────────────────────────────────
check "Frontend Tests" "cd frontend && npx vitest run --reporter=verbose 2>&1 | tail -5"
# ── 7. Security: SQL Injection Check ─────────────────────────────────────────
check "SQL Injection Check" "! grep -rn 'text(f"SELECT.*{' app/services/ --include='*.py' >/dev/null 2>&1"
@@ -100,7 +109,7 @@ fi
# ── 15. npm ci strict mode (no fallback to npm install) ───────────────────────
if [ -f frontend/package-lock.json ]; then
check "npm ci (strict)" "cd frontend && npm ci --prefer-offline 2>&1 | tail -3"
check "npm ci (strict)" "cd frontend && npm ci --legacy-peer-deps --prefer-offline 2>&1 | tail -3"
else
echo -e "${YELLOW}[CI] SKIP: npm ci (no package-lock.json)${NC}"
fi
+186
View File
@@ -0,0 +1,186 @@
#!/bin/bash
# =============================================================================
# Migrations Release Gate — Pre-Release Verification
# =============================================================================
# Runs before any release that includes migration changes.
# Verifies both installation paths produce the same schema.
#
# Prerequisites:
# - Docker available
# - PostgreSQL accessible
# - DATABASE_URL set to a test database (NOT production!)
#
# Usage:
# bash scripts/migration_release_gate.sh
#
# Exit codes:
# 0 = all checks passed
# 1 = one or more checks failed
# =============================================================================
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
PASS=0
FAIL=0
check() {
local name="$1"
local cmd="$2"
echo -e "${YELLOW}[GATE] Running: ${name}${NC}"
if eval "$cmd" 2>&1 | tail -10; then
echo -e "${GREEN}[GATE] PASS: ${name}${NC}"
PASS=$((PASS + 1))
else
echo -e "${RED}[GATE] FAIL: ${name}${NC}"
FAIL=$((FAIL + 1))
fi
}
if [ -z "${DATABASE_URL:-}" ]; then
echo -e "${RED}[GATE] ERROR: DATABASE_URL must be set to a TEST database (not production!)${NC}"
exit 1
fi
# ── 1. Fresh Install: Empty DB → Alembic Head → Plugin Migrations ─────────────
check "Fresh Install (empty DB)" "python3 -c \"
import asyncio, os
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
async def main():
url = os.environ['DATABASE_URL']
# Drop all tables for fresh install
engine = create_async_engine(url)
async with engine.begin() as conn:
await conn.execute(text('DROP SCHEMA IF EXISTS public CASCADE'))
await conn.execute(text('CREATE SCHEMA public'))
await engine.dispose()
print('Fresh DB created (schema dropped and recreated)')
asyncio.run(main())
" && alembic upgrade head && python3 -c \"
import asyncio, os
from app.core.db import async_session_maker
from app.core.bootstrap import bootstrap_roles
async def main():
async with async_session_maker() as db:
await bootstrap_roles(db)
print('Roles bootstrapped')
asyncio.run(main())
""
# ── 2. Schema Snapshot (fresh install) ────────────────────────────────────────
check "Schema Snapshot (fresh)" "python3 -c \"
import asyncio, os, json
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
async def main():
engine = create_async_engine(os.environ['DATABASE_URL'])
async with engine.connect() as conn:
# Tables
result = await conn.execute(text(\"SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename\"))
tables = sorted([r[0] for r in result])
# RLS
result = await conn.execute(text(\"SELECT tablename FROM pg_tables WHERE schemaname='public' AND rowsecurity=true ORDER BY tablename\"))
rls = sorted([r[0] for r in result])
# Indexes
result = await conn.execute(text(\"SELECT indexname FROM pg_indexes WHERE schemaname='public' ORDER BY indexname\"))
indexes = sorted([r[0] for r in result])
await engine.dispose()
snapshot = {'tables': tables, 'rls': rls, 'indexes': indexes}
with open('/tmp/schema_fresh.json', 'w') as f:
json.dump(snapshot, f, indent=2)
print(f'Fresh: {len(tables)} tables, {len(rls)} RLS, {len(indexes)} indexes')
asyncio.run(main())
""
# ── 3. RLS and Grants Check ───────────────────────────────────────────────────
check "RLS and Grants" "python3 -c \"
import asyncio, os
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
async def main():
engine = create_async_engine(os.environ['DATABASE_URL'])
async with engine.connect() as conn:
# Check RLS enabled on critical tables
result = await conn.execute(text(\"SELECT count(*) FROM pg_tables WHERE schemaname='public' AND rowsecurity=true\"))
rls_count = result.scalar()
assert rls_count >= 100, f'RLS count too low: {rls_count}'
# Check roles exist
result = await conn.execute(text(\"SELECT count(*) FROM pg_roles WHERE rolname IN ('crm_api','crm_auth','crm_worker','crm_migration')\"))
roles_count = result.scalar()
assert roles_count == 4, f'Expected 4 roles, got {roles_count}'
# Check crm_api has no BYPASSRLS
result = await conn.execute(text(\"SELECT rolbypassrls FROM pg_roles WHERE rolname='crm_api'\"))
bypass = result.scalar()
assert not bypass, 'crm_api must not have BYPASSRLS'
await engine.dispose()
print(f'RLS: {rls_count} tables, 4 roles, no BYPASSRLS on crm_api')
asyncio.run(main())
""
# ── 4. Cross-Tenant Read/Write Test ───────────────────────────────────────────
check "Cross-Tenant Read/Write" "python3 -c \"
import asyncio, os
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
async def main():
engine = create_async_engine(os.environ['DATABASE_URL'])
async with engine.connect() as conn:
# No tenant context → 0 rows
await conn.execute(text('RESET app.current_tenant_id'))
result = await conn.execute(text('SELECT count(*) FROM contacts'))
count = result.scalar()
assert count == 0, f'Expected 0 rows without tenant context, got {count}'
# Fake tenant → 0 rows
await conn.execute(text(\"SET app.current_tenant_id = '00000000-0000-0000-0000-000000000000'\"))
result = await conn.execute(text('SELECT count(*) FROM contacts'))
count = result.scalar()
assert count == 0, f'Expected 0 rows with fake tenant, got {count}'
await engine.dispose()
print('Cross-Tenant: 0 rows without/fake tenant context')
asyncio.run(main())
""
# ── 5. Data Integrity Check ───────────────────────────────────────────────────
check "Data Integrity" "python3 -c \"
import asyncio, os
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
async def main():
engine = create_async_engine(os.environ['DATABASE_URL'])
async with engine.connect() as conn:
# Check alembic version
result = await conn.execute(text('SELECT version_num FROM alembic_version'))
version = result.scalar()
print(f'Alembic version: {version}')
# Check no orphaned FKs
result = await conn.execute(text(\"SELECT count(*) FROM pg_constraint WHERE contype='f' AND connamespace='public'::regnamespace\"))
fk_count = result.scalar()
print(f'Foreign keys: {fk_count}')
await engine.dispose()
asyncio.run(main())
""
# ── Summary ──────────────────────────────────────────────────────────────────
echo ""
echo "============================================================"
echo " Migration Release Gate: ${PASS} passed, ${FAIL} failed"
echo "============================================================"
if [ $FAIL -gt 0 ]; then
echo -e "${RED}[GATE] FAILED — ${FAIL} checks failed${NC}"
exit 1
else
echo -e "${GREEN}[GATE] PASSED — all ${PASS} checks passed${NC}"
exit 0
fi