From 6f315b6fe2debbe0d81c4159e64fcfbd19ece902 Mon Sep 17 00:00:00 2001 From: level09 Date: Wed, 22 Jul 2026 13:04:17 +0300 Subject: [PATCH] test(db): fail the build when a branch leaves multiple alembic heads --- CONTRIBUTING.md | 20 ++++++++++++++++++++ enferno/commands.py | 11 +++++++++-- tests/test_migrations.py | 20 ++++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 tests/test_migrations.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9c50a9007..92b924e65 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -101,6 +101,26 @@ uv run flask db upgrade - Use idempotent SQL (`IF NOT EXISTS`, `IF EXISTS`) when writing raw SQL in migrations. - Never edit a migration that has already been applied to production. +### Keeping a Single Head + +Each migration hardcodes its parent in `down_revision`, so two branches that both add a +migration will both claim the same parent. Git merges them cleanly because they touch +different files, and the break only surfaces as `Multiple head revisions are present` +when `flask db upgrade` runs on deploy. + +`tests/test_migrations.py` fails the build when a branch has more than one head. If it +fires after merging `main` into your branch: + +- **Your migration has not been applied anywhere yet** (the normal case): edit its + `down_revision` to point at the new head. Check the head with + `uv run flask db heads`. +- **It has already been applied on staging or production**, or the two migrations touch + the same tables and order matters: do not re-parent. Run `uv run flask db merge heads` + to create a merge revision instead. + +Note that only the first branch to merge keeps a valid parent. If another migration lands +on `main` before yours, re-parent again onto the new head. + # Tests Bayanat comes with e2e tests using pytest and pydantic models. To run the tests, install the dependencies with diff --git a/enferno/commands.py b/enferno/commands.py index 6b298bd5b..5ecde7c85 100644 --- a/enferno/commands.py +++ b/enferno/commands.py @@ -558,13 +558,20 @@ def fail(msg): config = migrate_ext.get_config() script = ScriptDirectory.from_config(config) - head = script.get_current_head() + # get_current_head() raises when a branch merge left two heads, which would be + # swallowed as a warning below. Read them all so the break is reported as a failure. + heads = script.get_heads() context = MigrationContext.configure(db.session.connection()) current = context.get_current_heads() current_rev = current[0] if current else None + head = heads[0] if heads else None - if current_rev is None: + if len(heads) > 1: + fail( + f"Multiple migration heads ({', '.join(h[:8] for h in heads)}): db upgrade will abort" + ) + elif current_rev is None: warn("No Alembic revision stamped (run: flask db upgrade)") elif current_rev == head: ok("Migrations up to date") diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 000000000..5dfdf2d91 --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,20 @@ +from pathlib import Path + +from alembic.config import Config +from alembic.script import ScriptDirectory + + +def test_single_alembic_head(): + """Guard against two branches each parenting a migration on the same revision. + + Git merges such branches cleanly (different files), but the result has two heads + and `flask db upgrade` aborts mid-deploy. Fix by re-parenting the newer migration + onto the current head, or `alembic merge heads` if both have already been applied. + """ + config = Config() + config.set_main_option( + "script_location", str(Path(__file__).resolve().parent.parent / "migrations") + ) + heads = ScriptDirectory.from_config(config).get_heads() + + assert len(heads) == 1, f"Expected a single Alembic head, found {len(heads)}: {heads}"