Apply migrations on deploy via a Procfile release phase - #134
Merged
Conversation
Standard Heroku practice, and the reason the game-night table had to be applied by hand: there was no `release:` line, so every migration in this repo was a manual step somebody had to remember. `release: alembic upgrade head` runs before the new dynos start, and a failing migration fails the release — the old code keeps serving rather than a new release booting against a schema it doesn't have. Three things had to be fixed for the phase to work at all, each of which would have failed the *first* deploy after adding it: - **alembic was in the dev dependency group.** Heroku's buildpack installs `uv sync --no-dev`, so it wasn't in the slug and the phase would have died with "alembic: not found". Moved to `[project] dependencies`, where it now belongs — it is runtime, not tooling. `uv export --no-dev` confirms it resolves into the production set. - **`alembic/env.py` didn't normalize `postgres://`.** Heroku's addon-managed DATABASE_URL uses the legacy scheme and SQLAlchemy 2.x has no dialect under that name. `db_utils` and `bootstrap_db.py` both normalize, but neither helps here: `alembic upgrade` re-reads the raw environment variable in its own process, so normalizing before shelling out does nothing for the subprocess. - **`env.py` printed the full DSN, password included.** Release-phase output is app log. Now redacted. Runs bare `alembic upgrade head` rather than `scripts/bootstrap_db.py` on purpose: that script's empty-database branch (`create_all` + `stamp head`) exists for a fresh local volume, and on production it would silently manufacture a schema instead of failing loudly if DATABASE_URL ever pointed somewhere empty. tests/test_deploy_config.py pins all of it — nothing in application code imports these, so a plausible-looking cleanup would otherwise only surface on a push to production. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RQ4vgARjcMsiLg3Gk9bnLS
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.
Split out of #133, which merged just before this landed on the branch.
Applying the
game_night_summary_cachemigration to production by hand exposed that there was norelease:phase in theProcfile— every migration in this repo was a manual step somebody had to remember, with no failure mode other than noticing. Heroku's release phase is the standard fix (Rails shipsrelease: rails db:migrate, Djangomanage.py migrate):It runs after the build, before the new dynos start, and a failing migration fails the release — the old code keeps serving rather than new code booting against a schema it doesn't have.
Three things had to be fixed for it to work at all
Each would have failed the first deploy after adding the line. None is visible from application code, which is probably why the phase never existed.
alembicwas in the dev dependency group. Heroku's buildpack installsuv sync --no-dev, so it wasn't in the slug and the phase would have died withalembic: not found. Moved to[project] dependencies— it's runtime now, not tooling.uv export --no-devconfirms it resolves into the production set (and that mypy/ruff/pytest still don't).alembic/env.pydidn't normalizepostgres://. Heroku's addon-managedDATABASE_URLuses the legacy scheme and SQLAlchemy 2.x has no dialect under that name.db_utils.pyandbootstrap_db.pyboth normalize — but neither helps here, becausealembic upgradere-reads the raw environment variable in its own process, so normalizing before shelling out does nothing for the subprocess. Verified locally: apostgres://URL now connects where it previously raised.env.pyprinted the full DSN, password included. Release-phase output is app log, readable by anyone with access to the app. Now redacted touser:***@host.Why bare
alembic upgrade headand notscripts/bootstrap_db.pyThat script's empty-database branch runs
Base.metadata.create_all()+alembic stamp head, which exists for a fresh local Docker volume. On production, unattended, it would silently manufacture a schema instead of failing loudly ifDATABASE_URLever pointed somewhere empty — exactly the failure a release phase takes out of human view. Production always hasalembic_version, so the branch is pure downside there.Caveat worth knowing
Release phase applies the migration while the old dynos are still serving. Additive changes (a new table, a nullable column) are safe; a drop or a rename breaks requests in the window before the swap, and should be split across two deploys. Written up in
CLAUDE.mdalongside the rest.Checks
tests/test_deploy_config.pypins all five invariants — the release line, the survivingwebline, alembic's dependency group, the scheme normalization, and the redaction. Nothing in application code imports any of them, so a plausible-looking cleanup would otherwise only surface on a push to production.920 passing, 5 skipped.
make allclean.Note: production is already at
b2c9e0a4d715 (head)— that migration was applied by hand before this existed. This changes nothing about the current schema; it means the next one applies itself.🤖 Generated with Claude Code
https://claude.ai/code/session_01RQ4vgARjcMsiLg3Gk9bnLS