From c4f6dc5c3ef4cfcf296df48487f356acd8f9e4b1 Mon Sep 17 00:00:00 2001 From: jakeross Date: Wed, 19 Aug 2026 11:23:12 -0700 Subject: [PATCH] ci(staging): report data migrations that have not been applied Deploys run alembic and nothing else, deliberately: a data migration changes content rather than structure, is often irreversible -- a deletion has no downgrade -- and may be slow enough to hold a deploy hostage. Applying one is a decision, and data_migrations.yml is where it is made. The cost of that choice is that a merged migration can sit unnoticed. This closes the gap without moving the decision: the step reports and never applies. It exits zero even with migrations pending. The deploy succeeded, and a pipeline that goes red for something else is one people learn to ignore, so the finding surfaces as a warning annotation and in the job summary instead. A database it cannot reach is also a warning rather than a failure, for the same reason. Verified against a real database: it found one pending migration of two registered, wrote the annotation and summary, and exited zero -- and exits zero when the database is unreachable. Co-Authored-By: Claude Opus 5 --- .github/workflows/CD_staging.yml | 16 +++++ scripts/__init__.py | 0 scripts/report_pending_data_migrations.py | 88 +++++++++++++++++++++++ 3 files changed, 104 insertions(+) create mode 100644 scripts/__init__.py create mode 100644 scripts/report_pending_data_migrations.py diff --git a/.github/workflows/CD_staging.yml b/.github/workflows/CD_staging.yml index 0cc960e44..b4af038bd 100644 --- a/.github/workflows/CD_staging.yml +++ b/.github/workflows/CD_staging.yml @@ -63,6 +63,22 @@ jobs: run: | uv run --no-dev alembic upgrade head + # Data migrations are deliberately not applied here -- they change content + # rather than structure, are often irreversible, and applying one is a + # decision made in the Data Migrations workflow. This only reports, so a + # merged migration cannot sit unnoticed. It never fails the deploy: the + # deploy worked, and a pipeline that goes red for something else is a + # pipeline people learn to ignore. + - name: Report pending data migrations + env: + DB_DRIVER: "cloudsql" + CLOUD_SQL_INSTANCE_NAME: "${{ secrets.CLOUD_SQL_INSTANCE_NAME }}" + CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}" + CLOUD_SQL_USER: "${{ secrets.CLOUD_SQL_USER }}" + CLOUD_SQL_IAM_AUTH: true + run: | + uv run --no-dev python -m scripts.report_pending_data_migrations + - name: Ensure envsubst is available run: | if ! command -v envsubst >/dev/null 2>&1; then diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/scripts/report_pending_data_migrations.py b/scripts/report_pending_data_migrations.py new file mode 100644 index 000000000..348456f3b --- /dev/null +++ b/scripts/report_pending_data_migrations.py @@ -0,0 +1,88 @@ +# =============================================================================== +# Copyright 2026 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +""" +Report data migrations registered but not yet applied to this environment. + +Deploys run `alembic upgrade head` and nothing else, deliberately: a data +migration changes content rather than structure, is often irreversible -- a +deletion has no downgrade -- and may be slow enough to hold a deploy hostage. +Applying one is a decision, and `data_migrations.yml` is where it is made. + +The cost of that choice is that a merged migration can sit unnoticed. This +closes the gap without moving the decision: it reports, and never applies. + +Exits zero even when migrations are pending. A deploy that succeeded should not +report failure because a separate, deliberate action has not been taken yet -- +people learn to ignore a pipeline that cries wolf. The finding surfaces as a +GitHub warning annotation and in the job summary instead. +""" + +import os + + +def main() -> int: + from data_migrations.runner import get_status + from db.engine import session_ctx + + try: + with session_ctx() as session: + statuses = get_status(session) + except Exception as exc: # noqa: BLE001 - never fail a good deploy over this + print(f"::warning::Could not read data migration status: {exc}") + return 0 + + pending = [s for s in statuses if s.applied_count == 0 and not s.is_repeatable] + applied = len(statuses) - len(pending) + + summary = [ + "## Data migrations", + "", + f"{applied} applied, **{len(pending)} pending**.", + "", + ] + + if pending: + for status in pending: + print( + f"::warning::Data migration not applied: {status.id} " + f"({status.name}). Run it from the Data Migrations workflow." + ) + summary += [ + "| id | name |", + "| --- | --- |", + *[f"| `{s.id}` | {s.name} |" for s in pending], + "", + "These do **not** run on deploy. Apply them from the " + "**Data Migrations** workflow when you intend to.", + ] + else: + summary.append("Nothing pending.") + + print("\n".join(summary)) + + path = os.environ.get("GITHUB_STEP_SUMMARY") + if path: + with open(path, "a") as handle: + handle.write("\n".join(summary) + "\n") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + + +# ============= EOF =============================================