Repository: getsentry/sentry

Issue:
Catch in CI if a migration does not match the lock file
We recently had two migrations (#24749 and #24762) land around the same time with the same ID. Unfortunately, the 2nd one had not updated the lock file, thus, there was no merge conflict (even though there was a CI failure on master that was missed).

This, unfortunately, lined up with a release bug and caused a shipped Docker nightly image with the colliding migrations.

We should be able to catch this on CI.

From Dan:
> All you need to do is to is run this code and compare the generated lockfile to what we have on disk https://github.com/getsentry/sentry/blob/fbf245e76c0dbada594a45de84b727f9278df419/src/sentry/management/commands/makemigrations.py

EDIT: Updated links to PRs


Discussion:

Resolving patch:
diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py
--- a/src/sentry/conf/server.py
+++ b/src/sentry/conf/server.py
@@ -2092,7 +2092,12 @@ def create_partitioned_queues(name):
 MIGRATIONS_TEST_MIGRATE = os.environ.get("MIGRATIONS_TEST_MIGRATE", "0") == "1"
 # Specifies the list of django apps to include in the lockfile. If Falsey then include
 # all apps with migrations
-MIGRATIONS_LOCKFILE_APP_WHITELIST = ()
+MIGRATIONS_LOCKFILE_APP_WHITELIST = (
+    "jira_ac",
+    "nodestore",
+    "sentry",
+    "social_auth",
+)
 # Where to write the lockfile to.
 MIGRATIONS_LOCKFILE_PATH = os.path.join(PROJECT_ROOT, os.path.pardir, os.path.pardir)
 
diff --git a/src/sentry/management/commands/makemigrations.py b/src/sentry/management/commands/makemigrations.py
--- a/src/sentry/management/commands/makemigrations.py
+++ b/src/sentry/management/commands/makemigrations.py
@@ -1,4 +1,5 @@
 import os
+import sys
 
 from django.conf import settings
 from django.core.management.commands import makemigrations
@@ -15,6 +16,26 @@
 """
 
 
+# We check that the latest migration is the one stored in the lockfile
+def validate(migrations_filepath, latest_migration_by_app):
+    infile = {}
+    with open(migrations_filepath, encoding="utf-8") as file:
+        for line in file:
+            try:
+                app_label, name = line.split(": ")
+                infile[app_label] = name.strip()
+            except ValueError:
+                pass
+
+    for app_label, name in sorted(latest_migration_by_app.items()):
+        if infile[app_label] != name:
+            print(  # noqa: B314
+                f"ERROR: The latest migration does not match the one in the lockfile -> `{app_label}` app: {name} vs {infile[app_label]}"
+            )
+            # makemigrations.Command --check exits with 1 if a migration needs to be generated
+            sys.exit(2)
+
+
 class Command(makemigrations.Command):
     """
     Generates a lockfile so that Git will detect merge conflicts if there's a migration
@@ -22,7 +43,7 @@ class Command(makemigrations.Command):
     """
 
     def handle(self, *app_labels, **options):
-        if not options["name"]:
+        if not options["name"] and not options.get("check_changes"):
             self.stderr.write(
                 "Please name your migrations using `-n <migration_name>`. "
                 "For example, `-n backfill_my_new_table`"
@@ -44,11 +65,16 @@ def handle(self, *app_labels, **options):
                 latest_migration_by_app.get(app_label, ""), name
             )
 
-        result = "\n".join(
-            f"{app_label}: {name}" for app_label, name in sorted(latest_migration_by_app.items())
+        migrations_filepath = os.path.join(
+            settings.MIGRATIONS_LOCKFILE_PATH, "migrations_lockfile.txt"
         )
+        if options.get("check_changes"):
+            validate(migrations_filepath, latest_migration_by_app)
+        else:
+            result = "\n".join(
+                f"{app_label}: {name}"
+                for app_label, name in sorted(latest_migration_by_app.items())
+            )
 
-        with open(
-            os.path.join(settings.MIGRATIONS_LOCKFILE_PATH, "migrations_lockfile.txt"), "w"
-        ) as f:
-            f.write(template % result)
+            with open(migrations_filepath, "w") as f:
+                f.write(template % result)


Test patch: