Repository navigation
Conversation
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>
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>
Contributor
Author
|
Review fixes applied ( The four bypasses
Also fixed the CodeQL "incomplete URL substring sanitization" finding, which was pointing at something real: the checker accepted a bare Two claims I made earlier in this PR were wrong, in case you read them before the description was rewritten:
|
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>
mzeier
approved these changes
Jul 27, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.shrunsalembic upgrade headbefore 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 adrop_indexin one):drop_column/drop_table/drop_constraint/drop_index/rename_table·alter_columnrename, narrowing, or dropped default ·add_column(nullable=False)with noserver_default· unique index/constraint on an existing table · destructivebatch_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 ordinaryadd_column. The AST scoping is what makes this usable.)Escape hatch
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 7sthe migration it was run against
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
mainhas no required status checks — this is advisory untilmigration-additivityis added to branch protection.concurrency: group: validateisn't ref-scoped, so a push on one branch cancels in-flight runs on another.Context: thunderbird/platform-infrastructure#781.
🤖 Generated with Claude Code