Release Management and Database Migrations
(We'll explain why in Part 2)
Deploy whatever accumulated on main
Create tagged releases (v1.2.3)
Release: v2.1.0
Type: MINOR
Changelog:
Features:
- "[#123] Add user email preferences"
- "[#456] Support bulk export from dashboard"
Fixes:
- "[#789] Fix password validation edge case"
Database Changes:
Migrations:
- "Added user_preferences table"
- "Added email_verified column to users (nullable)"
Risk Level: LOW (additive only)
Rollback Impact: "New data would be lost"
API Changes:
New Endpoints:
- "POST /api/user/preferences"
Breaking Changes: None
Service Dependencies:
Requires: "frontend >= v2.1.0"
Compatible With: "All existing API consumers"
Rollback Strategy:
Code: "Safe to roll back to v2.0.x"
Database: "New tables/columns left in place"
Data Impact: "User preferences would be orphaned"
up has a downdown to rollbackup has a downdown to rollback✅ Works in development
❌ Fails in production
-- up: Add column
ALTER TABLE users ADD COLUMN preferences JSONB;
-- down: Remove column
ALTER TABLE users DROP COLUMN preferences;
After deployment, users write data. Rollback = permanent data loss.
v2.0 adds enum values ("paused", "archived")
Production runs for 2 hours, creates records
Rollback to v1.5 → v1.5 doesn't understand these values
Step 1: ALTER TABLE users ADD COLUMN email_verified BOOLEAN; ✓
Step 2: UPDATE users SET email_verified = FALSE; ✓
Step 3: ALTER TABLE ... SET NOT NULL; ✗
Step 4: CREATE INDEX ... (never runs)
Down migration assumes full completion → creates more problems
down first, then deploy old codeMake breaking changes through backward-compatible steps
Expand
Add new schema, dual-write, read from oldBackfill
Populate new schema with existing dataSwitch Reads
Read from new, keep dual-writeStop Old Writes
Write only to new schemaContract
Remove old schemaEach step is independently deployable and code-rollback-safe
users table: full_name VARCHAR(255) -- "First Last"
ALTER TABLE users ADD COLUMN first_name VARCHAR(100);
ALTER TABLE users ADD COLUMN last_name VARCHAR(100);
Add new columns, dual-write to both, continue reading from old
All phases are MINOR (additive) until final DROP (MAJOR)
Application keeps both columns synchronized:
full_name changes → split and write to first_name / last_namefirst_name / last_name change → combine to full_name
class User < ApplicationRecord
before_save :sync_name_fields
def sync_name_fields
# Write to both old and new columns
if will_save_change_to_full_name?
parts = full_name.to_s.split(' ', 2)
self.first_name = parts[0] || ''
self.last_name = parts[1] || ''
elsif will_save_change_to_first_name? || will_save_change_to_last_name?
self.full_name = "#{first_name} #{last_name}".strip
end
end
end
Reads prioritize new fields with fallback to old
UPDATE users
SET
first_name = SPLIT_PART(full_name, ' ', 1),
last_name = COALESCE(NULLIF(SPLIT_PART(full_name, ' ', 2), ''), '')
WHERE
full_name IS NOT NULL
AND (first_name IS NULL OR last_name IS NULL);
# Code now READS from first_name/last_name
# But still WRITES to both (dual-write continues)
def full_name
"#{first_name} #{last_name}".strip
end
class User < ApplicationRecord
# sync_name_fields callback REMOVED
# full_name column is now ignored (becomes stale)
# All writes go exclusively to new columns
def update_name(first, last)
update!(first_name: first, last_name: last)
# full_name is NOT updated anymore
end
end
sync_name_fields callback from Phase 1first_name / last_namefull_name becomes stale — safe to drop in next phase
ALTER TABLE users DROP COLUMN full_name;
Ensure new code works before removing safety net
Decision:
Breaking change? → Expand-Contract
Additive only? → Traditional
GitOps operator pulls from Git and reconciles cluster
Questions we still need to answer:
Questions we still need to answer:
The Gap: Simple GitOps doesn't handle promotion
We need something more...
Container image + config + dependencies promoted as a unit
Example Tools: Kargo, Argo Rollouts, ArgoCD
Emergencies are exactly when undocumented, unreviewed changes cause the most damage
main firstGuarantees the fix is part of mainline history and won't be lost in future releases
main has unreleased changes:main normallyhotfix/v1.3.1v1.3.1)If the fix requires a migration or breaking change, it needs a more considered approach, not an emergency patch