Skip to content

Apply migrations on deploy via a Procfile release phase - #134

Merged
f4hy merged 1 commit into
mainfrom
feat/deploy-migrations
Aug 23, 2026
Merged

Apply migrations on deploy via a Procfile release phase#134
f4hy merged 1 commit into
mainfrom
feat/deploy-migrations

Conversation

@f4hy

@f4hy f4hy commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Split out of #133, which merged just before this landed on the branch.

Applying the game_night_summary_cache migration to production by hand exposed that there was no release: phase in the Procfileevery 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 ships release: rails db:migrate, Django manage.py migrate):

release: alembic upgrade head
web: fastapi run radarvan/main.py

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.

  • 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 — it's runtime now, not tooling. uv export --no-dev confirms it resolves into the production set (and that mypy/ruff/pytest still don't).
  • 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.py and bootstrap_db.py both normalize — but neither helps here, because alembic upgrade re-reads the raw environment variable in its own process, so normalizing before shelling out does nothing for the subprocess. Verified locally: a postgres:// URL now connects where it previously raised.
  • env.py printed the full DSN, password included. Release-phase output is app log, readable by anyone with access to the app. Now redacted to user:***@host.

Why bare alembic upgrade head and not scripts/bootstrap_db.py

That 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 if DATABASE_URL ever pointed somewhere empty — exactly the failure a release phase takes out of human view. Production always has alembic_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.md alongside the rest.

Checks

tests/test_deploy_config.py pins all five invariants — the release line, the surviving web line, 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 all clean.

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

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
@f4hy
f4hy merged commit 18bda98 into main Aug 23, 2026
3 checks passed
@f4hy
f4hy deleted the feat/deploy-migrations branch August 23, 2026 00:06
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.

1 participant