ADRs
ADR 0029 — Auto-apply migrations в CI для solo-dev workflow
  • Date: 2026-05-27
  • Status: Accepted (deviation от master spec §III.2.5; revisit when team > 1)
  • Affects:
    • .github/workflows/ci.yml — new "Apply DB migrations" step before backend deploy
    • Rules.md — softened "Things to NEVER do" rule on migration manual approval
    • _index.md §III.2.5 — superseded by this ADR for current operating phase
  • References: master spec §III.2.5 (migration workflow), Rules.md "Things to NEVER do", ADR 0027 (composition zones — recent migration that demonstrated the gap)

Context

Master spec §III.2.5 mandates:

Migration workflow: GHA с production-migration environment (manual approval, prevent self-review).

Rules.md "Things to NEVER do" reinforces:

Skip GHA production-migration manual approval gate.

This rule was authored для multi-developer team scenarios where one developer's PR could accidentally drop a column another's code depends on. The manual approval requires a second engineer to review the migration SQL before it lands в prod.

Reality today:

  • Solo developer (single owner Cloudflare account, single owner GitHub repo)
  • The reviewer would be the same person who wrote the migration — no second pair of eyes anyway
  • Manual approval becomes pure friction: each prod deploy requires the user to remember "click Approve in GitHub Actions UI"
  • Forgetting the approval = backend deploy succeeds but runtime crashes on missing schema (as observed today after ADR 0027 — migration 0011 had to be applied manually before backend redeploy worked)
  • User explicit feedback (this session): "Я то точно забуду. Сам разберись с этим."

The original rule's risk model (accidental column drop by careless co-worker) does not apply. The rule's cost (forgotten approvals causing prod outages) does.

Decision

Auto-apply pending Drizzle migrations в CI on push to main, before the backend deploy step. No manual approval gate while we operate solo.

Step definition

- name: Apply DB migrations (prod)
  if: github.event_name == 'push' && github.ref == 'refs/heads/main'
  env:
    DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
  run: pnpm --filter @arno/migrate push

Drizzle migrate() is idempotent — applied migrations are skipped via the drizzle.__drizzle_migrations tracking table. A clean state = instant no-op. Pending migrations apply in order.

Ordering: runs before the backend deploy step. If migration fails → CI red → backend deploy skipped → no broken deploy lands.

What we accept

  • A bad migration committed to main will execute against prod without a human checkpoint
  • Mitigation 1: code review через PR (even self-review surfaces obvious mistakes — schema diff is small)
  • Mitigation 2: tools/migrate runs against the same Drizzle schema files in ci.yml's existing "Migration drift check" step (line 40-55) before any deploy step. The drift check catches uncommitted schema changes; the auto-apply catches committed-but-unapplied migrations
  • Mitigation 3: Neon PITR 7 days — incidents recoverable
  • Mitigation 4: expand-contract pattern (per ADR 0027) makes most migrations additive and reversible

Required secret

PROD_DATABASE_URL must be set в GitHub Actions repo secrets (Settings → Secrets and variables → Actions). Same value as the DATABASE_URL Wrangler secret on the prod Worker.

If the secret is missing, the migration step fails fast with a clear error, blocking deploy — better than silently skipping.

Re-evaluation triggers

Re-open this ADR and restore the manual approval gate when:

  • Team size > 1. Second engineer joining means second-pair-of-eyes review becomes meaningful again.
  • First production incident caused by a bad migration. If we ever ship a destructive change accidentally, the cost of friction starts looking reasonable.
  • Compliance requirement (SOC2 etc.) demands documented manual review of schema changes.
  • Migration volume > 1/week sustained. If we're shipping schema changes constantly, automated rollback testing becomes worth investing in before removing the gate entirely.

Until one of these fires, the friction cost dominates.

Consequences

Positive:

  • Deploys are end-to-end automated: migrate → backend → frontend → healthcheck. No "remember to apply migration" step in the human loop.
  • Solo-dev velocity restored — same workflow as Vercel/Railway/Fly.io for similar-scope projects
  • Failure mode (CI red on bad migration) is loud and blocks the bad code from reaching prod

Negative / debt:

  • Deviation from master spec §III.2.5 documented but real — anyone reading the spec without ADR awareness will be surprised
  • No second-pair-of-eyes safety net for migration SQL
  • Bad migrations land instantly if PR self-review misses them — only PITR rollback available, not pre-flight catch

Test env: unchanged for now. deploy-test.yml already auto-deploys backend on feat/**, but auto-migrate not added there because the test Neon branch has a divergent drizzle.__drizzle_migrations journal (initial seed was done via manual SQL before journal tracking existed — see HANDOFF "Test env quirk"). Running migrate push against test currently re-attempts migration 0007 and fails on existing tables. Test migrations stay manual until the test branch is reseeded from prod or its journal is reconstructed. This gap does not affect prod, where journal has been clean from 0000. Re-evaluate test auto-migrate after one of:

  • Test Neon branch reseeded from current prod snapshot
  • drizzle.__drizzle_migrations rows manually inserted to match journal hashes for 0000-0011