Skip to content

ci(backend): block non-additive Alembic migrations pre-merge - #1770

Draft
aatchison wants to merge 4 commits into
mainfrom
ci/migration-additivity
Draft

aatchison wants to merge 4 commits into
mainfrom
ci/migration-additivity

Conversation

@aatchison

@aatchison aatchison commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

Draft — feedback welcome before this becomes a required check.

Blocks a PR from adding an Alembic migration that isn't expand/contract safe.

Why

For a window during every deploy, the OLD and NEW replicas run against the SAME database, and a schema change cannot be undone by aborting the deploy. So migrations must only ADD; removals ship a release later.

That window is short and incidental on today's ECS rolling deploy. It gets long and deliberate under the Argo Rollouts blue-green deploy going up on EKS tb-dev/tb-prod (thunderbird/platform-infrastructure#781) — which also adds an abort path that looks like a rollback but can't undo DDL. This is groundwork for that.

Why pre-merge, not at deploy time

We built the deploy-time version first and it can't work: backend/scripts/entry.sh runs alembic upgrade head before uvicorn binds, and Rollouts only runs pre-promotion analysis once pods are Ready. The migration has already landed by then. Pre-merge is the last point at which it hasn't run anywhere.

What's flagged

upgrade() only, following module-level helpers it calls (the repo's newest migration already hides a drop_index in one):

drop_column / drop_table / drop_constraint / drop_index / rename_table · alter_column rename, narrowing, or dropped default · add_column(nullable=False) with no server_default · unique index/constraint on an existing table · destructive batch_alter_table · .execute() with destructive raw SQL, on any receiver.

Not flagged: NOT NULL/unique on a table created in the same migration · existing_nullable= · add_column(nullable=False, server_default=...). Enum widenings print as non-blocking notes.

Calibration

67 existing migrations: 56 pass, 11 blocking. Existing migrations are untouched — CI only looks at what a branch changes. (A regex over whole files flags ~45; most are the autogenerated destructive downgrade() on an ordinary add_column. The AST scoping is what makes this usable.)

Escape hatch

# additivity-exempt: https://github.com/thunderbird/appointment/issues/1234
#   invites table; nothing has referenced it since v1.9

Thunderbird issue/PR URL + reason required, honoured only in a real comment token. Waived findings are still printed. Prefer splitting into expand/contract.

Proof it fires

This PR adds no migrations, so its own run reports "nothing to check" — the checker invocation path never executes here. #1771 (throwaway, now closed) added one deliberately non-additive migration to exercise it.

migration-additivity — red in 7s

Migrations to check:
  src/appointment/migrations/versions/..._demo_non_additive.py
FAIL   not additive
  line 29: op.add_column(nullable=False) without server_default
     why: old replicas' INSERTs omit the column, so they fail
     fix: Add server_default=... (SQLAlchemy's default= is client-side, emits no DDL default)
  line 31: execute(<destructive SQL>)
     why: raw SQL contains 'ALTER TABLE subscribers DROP'
     fix: Defer the destructive statement to a later release.
  line 22: op.drop_column
     why: drops a column the old replicas may still SELECT
     fix: Ship the removal in a LATER release (contract phase).
the migration it was run against
def _tidy_up():
    """Hidden in a helper on purpose: proves the scan follows call graphs."""
    op.drop_column('subscribers', 'ftue_level')

def upgrade() -> None:
    _tidy_up()                                              # 1. destructive, via helper
    op.add_column('subscribers',
                  sa.Column('demo_flag', sa.Boolean(), nullable=False))   # 2. NOT NULL, no default
    op.get_bind().execute(                                  # 3. raw DDL, non-`op` receiver
        sa.text('ALTER TABLE subscribers DROP COLUMN avatar_url'))

def downgrade() -> None:
    op.drop_column('subscribers', 'demo_flag')              # destructive, must NOT be flagged

The destructive downgrade() was correctly ignored. The corpus calibration test caught it independently too (1 failed, 491 passed), which shows that test isn't vacuous.

Two things for the team

  1. main has no required status checks — this is advisory until migration-additivity is added to branch protection.
  2. concurrency: group: validate isn't ref-scoped, so a push on one branch cancels in-flight runs on another.

Context: thunderbird/platform-infrastructure#781.

🤖 Generated with Claude Code

Appointment is deployed blue-green: for a window during every deploy the OLD and
NEW replicas run against the SAME database. A migration that removes or narrows
something the old replicas still use breaks them the moment it lands -- and a
schema change cannot be undone by aborting the deploy. So migrations must be
expand/contract: add now, remove a release later.

This adds an AST-based check to the existing validate-backend job, scoped to the
migrations a branch ADDS. Pre-merge is the last point at which the migration has
not yet run anywhere, which is what makes the check a real gate rather than a
post-hoc alarm.

Flagged in upgrade() only (downgrade() is never run by the deploy):
  drop_column / drop_table / drop_constraint / drop_index, rename_table,
  alter_column(new_column_name=) and alter_column(nullable=False),
  add_column(nullable=False) with no server_default, create_index(unique=True),
  create_unique_constraint, batch_alter_table, and op.execute() with
  destructive raw SQL (the obvious way to sidestep an op.*-only ruleset).

Deliberately NOT flagged, because these are additive:
  anything in downgrade(); nullable=False on a column of a brand-new
  create_table (no replica knows the table exists); existing_nullable=False (a
  descriptor of current state, not a change); add_column(nullable=False,
  server_default=...) (the default backfills for old replicas).

Calibration against the 66 committed migrations: 15 flagged, and every one is a
genuine upgrade()-side risk -- the 4 drop_* migrations, the drop_index +
create_index(unique=True) pair, 4 raw-DDL op.execute() migrations, 3
add_column NOT NULL without a server_default, and the narrowing/rename/unique
cases. Zero false positives: the AST scope means the autogenerated destructive
downgrade() body that accompanies every ordinary add_column is ignored (a
regex over whole files flags ~45/66, of which ~80% are that artifact).

Escape hatch, because a contract migration must eventually ship: declare it in
the migration itself so the schema reviewer sees the justification in the diff --
`# additivity-exempt: <issue-url> <reason>`. A URL is required.

Includes unit tests pinning both directions (additive shapes must pass,
destructive shapes must fail) plus a calibration test over the real corpus so a
future ruleset change cannot silently start flagging historical migrations.

Refs thunderbird/platform-infrastructure#781.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread backend/test/unit/test_migration_additivity.py Fixed
The first cut had four verified ways to defeat it and one class of false
positive. All are fixed and pinned by tests.

Bypasses closed:
- Destructive ops in module-level HELPERS were invisible. The scan now follows
  calls from upgrade() transitively. This was already live: the newest migration
  (d4e5f6a7b8c9) hides drop_index + drop_constraint in _drop_global_slug_unique(),
  and the checker reported only the two unrelated findings.
- Raw SQL via session.execute / connection.execute / op.get_bind().execute was
  unchecked -- 13 of 67 migrations already use that handle. .execute() is now
  inspected on ANY receiver, and DDL ops are receiver-gated to op/batch_op.
- The exemption marker matched anywhere in the file, including docstrings and SQL
  strings -- the script even exempted itself via its own docstring example. Markers
  are now read from real comment tokens only, must be a Thunderbird issue/PR URL
  with a reason, and no longer skip the scan: waived findings are printed so the
  waiver is auditable.
- A path the checker could not read was silently dropped and the run exited 0.
  Unreadable/missing/non-.py arguments are now a hard exit 2, so a wiring slip
  fails loudly instead of turning the gate into a green no-op.

False positives removed:
- 4 of the 15 previously-flagged migrations were enum WIDENINGS (a value appended
  to `MODIFY COLUMN ... enum(...)`), which are additive. Enum changes are usually
  interpolated and cannot be compared statically, so they are now a non-blocking
  note. Corrected calibration: 67 migrations, 11 blocking, 56 pass.

Also: only the first upgrade() was scanned (a helper named upgrade_* shadowed it,
and upgrade_<engine>/async forms were skipped) -- all upgrade entrypoints are now
scanned and a file with none is flagged rather than passing; server_default=None
no longer satisfies the add_column rule; unique constraints and NOT NULL on a table
created in the same migration are exempt; drop_index paired with a create_index on
the same table is an index swap, not a removal; batch_alter_table is flagged only
when its body is destructive; added rules for type narrowing and for removing a
server default.

CI: promoted to its own job so a stdlib-only ~100ms check no longer sits behind
pip install and the full pytest suite; --diff-filter=AMR --no-renames so editing or
renaming a migration is checked too, not just adding one; base is the repo default
branch rather than a hardcoded 'main'.

Tests: 46 cases, both directions. The calibration test previously asserted only
flagged-subset-of-known, which passes vacuously if the checker stops flagging
anything; it now asserts both directions plus a corpus-size floor.

Refs thunderbird/platform-infrastructure#781.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@aatchison

aatchison commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Review fixes applied (7657be7f, ccc48eaa, f75e856a). The review found four ways to get a non-additive migration past the check — all reproduced, all fixed, each pinned by a test.

The four bypasses
  1. Helper functions were invisible — and this was already live: d4e5f6a7b8c9 hides drop_index + drop_constraint in _drop_global_slug_unique(). Now follows calls transitively.
  2. session.execute / op.get_bind().execute unchecked — 13 of 67 migrations use that handle. Now inspects .execute() on any receiver.
  3. The script exempted itself via its own docstring example. Now comment-tokens only, with a strict Thunderbird issue-URL + reason.
  4. A missing path exited 0 — any wiring slip would have made this a green no-op. Now exits 2.

Also fixed the CodeQL "incomplete URL substring sanitization" finding, which was pointing at something real: the checker accepted a bare https:// as a valid exemption URL.

Two claims I made earlier in this PR were wrong, in case you read them before the description was rewritten:

  • "Zero false positives" — 4 of 15 were enum widenings, which are additive. Now non-blocking notes; the description carries the corrected counts.
  • "Appointment is deployed blue-green" — it isn't yet. Today's deploy is an ECS Fargate rolling update; blue-green is what this is groundwork for.

aatchison and others added 2 commits July 27, 2026 12:47
The docstring asserted appointment is already deployed blue-green. It is not:
the current Pulumi deploy is an ECS Fargate rolling update (AWS-default
controller, min 100% / max 200%). The mixed-version window is real there but
short and incidental.

This check is groundwork for the Argo Rollouts blue-green deploy on the new EKS
tb-dev / tb-prod clusters (thunderbird/platform-infrastructure#781), which holds
both versions up on purpose and offers an abort path that cannot undo DDL.

No behaviour change; calibration is unchanged (67 migrations, 56 pass,
11 blocking).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…step

- ruff format (0.9.3, the version CI pins) over the two new files. The repo is
  not format-clean overall -- CI runs a bare `ruff format`, which rewrites and
  exits 0 rather than failing -- so this touches only the files this PR adds.
- Move the default-branch name into `env:` instead of interpolating it into the
  shell body, so a ref name can never be spliced into the command.
- Correct the job comment's deploy-model claim to match the docstring: the
  current deploy is ECS rolling; blue-green is what this is groundwork for.

No behaviour change. 46 unit tests pass; corpus calibration unchanged
(67 migrations, 56 pass, 11 blocking).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants