Promote main to release with automated backup, migration assessment, and merge. This is the only path to production.
IMPORTANT: This skill assumes Drizzle ORM for database migrations. For other ORMs, the migration internals section needs adaptation.
Phase 1: Pre-flight Checks
1.1 Verify Linear MCP
ALWAYS call mcp__linear__list_issues with team: "<discovered-team-name>" and state: "Done" directly. Do NOT try to determine MCP availability by inspecting the tool list, checking settings, or reasoning about it — you MUST actually invoke the tool and check the result. If the call fails or returns an error, warn but do not stop — Linear state transitions are cosmetic, the release can proceed without them.
Record any Done issues for the release notes.
1.2 Git State
git branch --show-current
git status --porcelain
Requirements:
- Must be on
mainbranch - Working tree must be clean (no uncommitted changes)
- Must be up to date with remote:
git fetch origin && git rev-list --count HEAD..origin/mainmust be0
If any check fails, STOP and tell the user what to fix.
1.3 Docker (OrbStack)
Ensure OrbStack and Docker are available for migration validation:
orb status
- If Running: proceed
- If Stopped: start it with
orb start, then verify withdocker compose ps - If
orbcommand not found: STOP — "OrbStack is not installed. Install withbrew install orbstack."
Then ensure local Postgres is running (consult CLAUDE.md for docker compose file location):
docker compose ps
If the database service is not running, start it: docker compose up -d
1.4 Verify Drizzle Migration Internals
Only needed if MIGRATIONS.md has entries (checked in Phase 2, but verify early to fail fast). Read the Drizzle migrator source from the exact version in node_modules and confirm our assumptions about the journal table still hold:
# Check migrator comparison logic
grep -q '"drizzle"."__drizzle_migrations"' node_modules/drizzle-orm/pg-proxy/migrator.js
grep -q 'ORDER BY created_at DESC LIMIT 1' node_modules/drizzle-orm/pg-proxy/migrator.js
grep -q 'created_at' node_modules/drizzle-orm/pg-proxy/migrator.js
# Check hash algorithm
grep -q 'createHash("sha256")' node_modules/drizzle-orm/migrator.js
All four must match. If any fails, STOP: "Drizzle migration internals have changed since this skill was written. The journal insert approach may not work. Check node_modules/drizzle-orm/pg-proxy/migrator.js manually and update the skill."
This verification is safe because we deploy the same node_modules — the code we check here is the code that will run in production.
1.5 Check for Pending PLANS.md
Read PLANS.md from project root (if it exists). If it contains incomplete tasks (tasks not marked as done), STOP: "There are incomplete tasks in PLANS.md. Finish implementation first or clear the plan before releasing."
If PLANS.md doesn't exist or has no incomplete tasks, continue.
1.6 Build & Tests
Run the verifier agent (full mode) to confirm unit tests, lint, and build pass:
Use Task tool with subagent_type "verifier"
If verifier reports failures, STOP. Do not proceed with a broken build.
1.7 E2E Tests
Run the verifier agent in E2E mode to confirm end-to-end tests pass:
Use Task tool with subagent_type "verifier" with prompt "e2e"
Docker/OrbStack is already verified in Phase 1.3, so prerequisites are met.
If E2E tests fail, STOP. Do not proceed — E2E failures indicate integration issues that must be fixed before release.
1.8 Release Branch Exists
git rev-parse --verify origin/release
If release branch doesn't exist, STOP and tell the user to create it:
git checkout -b release && git push -u origin release && git checkout main
1.9 Diff Assessment
Check what's changing between release and main:
git log origin/release..origin/main --oneline
git diff origin/release..origin/main --stat
If there are no commits to promote, STOP: "Nothing to promote. main and release are identical."
Show the user the commit list and file diff summary.
First release (no prior tags): If git describe --tags fails, this is the first tagged release. Use the full commit history and treat the current package.json version as the starting version.
IMPORTANT: Wait for the user to acknowledge the diff summary before proceeding to Phase 2.
Phase 2: Assess Migrations
Migration assessment runs before backup so we know whether to stop the service first.
2.1 Read MIGRATIONS.md
Read MIGRATIONS.md from project root.
If MIGRATIONS.md has no entries (only the template header), skip to Phase 4 (backup without stopping service, then merge).
2.2 Analyze Changes
For each entry in MIGRATIONS.md:
- Read the referenced files on
mainto understand the current (new) state - Compare with
releaseto understand what production currently has:git diff origin/release..HEAD -- <relevant-files> - Determine the net migration — what SQL or data transformation is needed to go from production's current state to the new state
2.3 Classify Migration Complexity
Classify each MIGRATIONS.md entry independently. A single release may have entries at different levels.
| Complexity | Criteria | Action |
|---|---|---|
| None | No data affected (new tables only, new columns with defaults) | Skip — Drizzle handles DDL |
| Data-only | Drizzle handles DDL, but a standalone data operation is also needed (cleanup, backfill nullable column, one-time DELETE/UPDATE) | Collect SQL → run against production in Phase 5.1 |
| Simple | DDL changes that Drizzle can't handle alone (column renames, type changes, backfill + NOT NULL constraint) | Write full migration script with Drizzle journal inserts (Phase 3) |
| Complex | Data transformation logic, ambiguous mappings, potential data loss | STOP and discuss with user |
Key distinction: "Data-only" means Drizzle's generated migrations are sufficient for the schema changes — the extra SQL is a data operation that runs after Drizzle migrations complete at deploy startup. "Simple" means the DDL itself needs manual handling (Drizzle's generated SQL would fail or produce wrong results on existing data).
2.4 Handle Data-only Migrations
For entries classified as Data-only:
- Write the SQL statements to a file at
_migrations/data-YYYYMMDD.sql - Use data-agnostic SQL (derive values from existing DB content, never hardcode user data)
- These will be executed against production in Phase 5.1 after the deploy completes and Drizzle migrations run
2.5 Handle Complex Migrations
If any migration is classified as Complex:
Present the issue to the user:
## Migration Requires Discussion **Entry:** [MIGRATIONS.md entry] **Problem:** [why it's complex] **Options:** 1. [Option A with trade-offs] 2. [Option B with trade-offs] 3. Skip this migration (manual intervention needed post-deploy) What would you like to do?Wait for user input before proceeding.
Phase 3: Write & Validate Migration
Only applies if Phase 2 found migrations to execute.
3.1 Identify Covered Drizzle Migrations
Check which Drizzle migration files (consult CLAUDE.md for migration directory location, typically drizzle/) are new since release:
git diff origin/release..HEAD --name-only -- <migration-dir>/
Read the SQL content of each new Drizzle migration file. The manual migration script must cover all DDL and data changes so these Drizzle files don't need to run again. After the manual script executes, we mark these migrations as applied in Drizzle's journal table.
3.2 Write Migration SQL
Create a migration file at _migrations/release-YYYYMMDD.sql with:
- Transaction wrapper (
BEGIN; ... COMMIT;) - All DDL changes from the covered Drizzle migration files (use safe idioms:
IF NOT EXISTS, nullable-then-backfill-then-NOT-NULL) - All data transformations from MIGRATIONS.md entries
- Drizzle journal inserts to mark covered migrations as applied
- Comments explaining each operation
PRIVACY RULE: Never hardcode user data in migration SQL. The migration file lives in gitignored _migrations/, but always write data-agnostic SQL that derives values from existing database content (e.g., SELECT DISTINCT email FROM sessions) rather than hardcoding emails, names, or other personal data. This makes the migration work for any data state and avoids accidental leaks.
Drizzle journal compatibility. Drizzle decides which migrations to run by checking the created_at of the most recent row in "drizzle"."__drizzle_migrations" and running all migrations with a when timestamp (from drizzle/meta/_journal.json) greater than that value. The manual script must insert journal rows so Drizzle skips the covered migrations at deploy startup. These internals are verified in Phase 1.4 against the exact node_modules code that will run in production.
Journal table details:
- Schema-qualified:
"drizzle"."__drizzle_migrations"(NOTpublic) - Columns:
idserial PK,hashtext NOT NULL,created_atnumeric hash= SHA-256 hex of the SQL file contents:shasum -a 256 <migration-dir>/XXXX.sql | cut -d' ' -f1created_at= thewhenvalue from the corresponding entry indrizzle/meta/_journal.json(epoch milliseconds)
Important: Drizzle only compares created_at timestamps, not hashes, to decide what to run. But always insert correct hashes for integrity.
For a full example migration script, read references/migration-example.md if it exists.
3.3 Validate Against Backup
Restore the production backup to a local test database and run the migration:
Restore backup:
docker compose up -d docker compose exec -T db psql -U postgres -c "DROP DATABASE IF EXISTS migration_test;" docker compose exec -T db psql -U postgres -c "CREATE DATABASE migration_test;" # Adjust pg_restore path based on OS (use `which pg_restore` or check common paths) pg_restore \ --dbname="postgresql://postgres:postgres@localhost:5432/migration_test" \ --no-owner --no-privileges \ _migrations/backup-*.dumpRun the migration:
# Adjust psql path based on OS psql \ "postgresql://postgres:postgres@localhost:5432/migration_test" \ -f _migrations/release-YYYYMMDD.sqlVerify the migration succeeded (no errors)
Verify Drizzle journal entries were inserted:
docker compose exec -T db psql -U postgres -d migration_test \ -c "SELECT id, hash, created_at FROM drizzle.__drizzle_migrations ORDER BY id;"Confirm the new entries appear for the covered migrations with correct
created_atvalues matchingdrizzle/meta/_journal.json.Clean up:
docker compose exec -T db psql -U postgres -c "DROP DATABASE migration_test;"
If local Docker is not available, show the migration SQL to the user and ask for manual approval.
3.4 Show Migration to User
Regardless of validation, display the full migration SQL to the user for review:
## Migration SQL (will be applied to production)
[SQL content]
Validated against production backup: [yes/no]
Drizzle journal entries: [list of covered migration files]
Proceed with release?
Wait for user confirmation.
Phase 4: Backup Production Database
4.1 Create Backup Directory
Ensure _migrations/ exists. Use Bash:
mkdir -p _migrations
Note: If mkdir triggers a permission prompt, the user should add Bash(mkdir *) to their allow list, or you can verify the directory exists with ls _migrations first and skip if it already does.
4.2 Get Production Database URL
Read CLAUDE.md to discover the deployment platform and environment variable names. For Railway:
railway run -e production -s <service-name> printenv DATABASE_PUBLIC_URL
Adjust command based on deployment platform discovered from CLAUDE.md.
If the command fails or returns empty, STOP: "Cannot connect to production database. Check deployment configuration and database URL variable."
4.3 Stop Production Service (if migration needed)
If migration SQL was written in Phase 3, stop the production app service before taking the backup. This ensures the backup captures every last write — no data can be lost between backup and migration.
Consult CLAUDE.md for the deployment platform and stop command. For Railway:
railway down -y -e production -s <service-name>
If the command fails, STOP: "Cannot stop production service. Check deployment CLI configuration."
Note: Stopping the service removes the most recent deployment — the app stops serving requests. The database is a separate service and continues running. No data is lost. Pushing to release later creates a new deployment that brings the service back.
If no migration is needed, skip this step — the service stays running during backup and merge.
4.4 Dump Production Database
Use two separate Bash calls to avoid permission pattern issues. First compute the filename, then run pg_dump with the literal path (no subshells in the pg_dump command):
date +%Y%m%d-%H%M%S
Use the output to construct the filename _migrations/backup-YYYYMMDD-HHMMSS.dump, then run:
pg_dump "$DATABASE_PUBLIC_URL" --format=custom --no-owner --no-privileges -f _migrations/backup-YYYYMMDD-HHMMSS.dump
Important: Keep pg_dump as a single-line command starting with the full path. Multi-line \ continuations and $(...) subshells can break Bash permission pattern matching against Bash(/opt/homebrew/opt/libpq/bin/pg_dump *).
Verify the dump file was created and is non-empty:
ls -lh _migrations/backup-*.dump | tail -1
If dump fails, STOP: "Database backup failed. Do not proceed without a backup."
Report: "Production backup saved to _migrations/backup-YYYYMMDD-HHMMSS.dump (X KB)"
Phase 5: Execute Release
5.1 Apply Migrations to Production
Full migration script (Simple complexity):
If a migration script was written in Phase 3, run it using the URL from Phase 4.2 before merging to release (service is already stopped):
# Adjust psql path based on OS
psql "$DATABASE_PUBLIC_URL" -f _migrations/release-YYYYMMDD.sql
If the migration fails, STOP: "Migration failed on production. Service is down. Investigate before proceeding. Backup available at _migrations/backup-*.dump. To restore service, push current release branch again or redeploy from deployment dashboard."
Data-only SQL (Data-only complexity):
If data-only SQL was written in Phase 2.4, run it after merging to release and confirming the deploy succeeded (Drizzle migrations must run first at deploy startup). Wait for the deployment to complete, then:
# Adjust psql path based on OS
psql -d "$DATABASE_PUBLIC_URL" -f _migrations/data-YYYYMMDD.sql
If no migrations or data operations are needed, skip this step.
5.2 Clear MIGRATIONS.md
Reset MIGRATIONS.md to its empty template:
# Pending Production Migrations
Log potential production data migrations here during development. These notes are assessed and implemented by the `push-to-production` skill when promoting `main` to `release`.
**Format:** Describe what changed and what data is affected. Simple reference SQL is fine; do not write full migration scripts — those are built at release time.
<!-- Add entries below this line -->
5.3 Update Version and Changelog
Determine version (follows Semantic Versioning 2.0.0):
- Read
CHANGELOG.mdand extract the current version from the first## [x.y.z]header. IfCHANGELOG.mddoesn't exist, this is the first release — create it fresh. - If
<arguments>contains a version (e.g.,2.0.0):- Validate it's valid semver (X.Y.Z)
- Validate it's strictly higher than current version
- If invalid, STOP: "Invalid version. Must be higher than current [current]."
- If no argument, deduce the bump from the commits being promoted (from Phase 1.9):
- MAJOR (
x+1.0.0): Incompatible/breaking changes — removed or renamed API routes, changed API response shapes, DB schema changes that break existing clients, removed features - MINOR (
x.y+1.0): Backward-compatible new functionality — new screens, new API endpoints, new features, significant UI additions - PATCH (
x.y.z+1): Backward-compatible bug fixes — bug fixes, UI tweaks, refactoring, performance improvements, documentation, dependency updates - When commits span multiple categories, use the highest bump level (MAJOR > MINOR > PATCH)
- Show the user which bump level was chosen and why, so they can override if they disagree
- MAJOR (
Write changelog entry (follows Keep a Changelog 1.1.0):
See references/changelog-guidelines.md if it exists for full INCLUDE/EXCLUDE criteria and writing style rules.
Process:
- Review the commit list from Phase 1.9
- Determine the net effect against production — use
git diff origin/release..origin/main --stat(not the commit list) as the source of truth for what actually changed. Commits that introduce and fix the same issue within the cycle, or that rework/remove staging-only code, produce zero changelog entries. The commit list helps understand intent; the diff shows what's actually shipping. - Filter out purely internal changes (they get zero entries)
- Move any items from the
## [Unreleased]section into the new version entry - Write a
## [version] - YYYY-MM-DDentry, grouping changes under these section headers (omit empty sections):### Added— new features, new screens### Changed— changes to existing functionality, UI improvements### Deprecated— features that will be removed in a future release### Removed— removed features### Fixed— bug fixes### Security— security-related changes, vulnerability fixes
- Group minor fixes into single items (e.g., "Minor bug fixes" or "Minor UI polish")
- Keep each section concise — aim for 3-8 items total across all sections
- Insert the new entry between
## [Unreleased]and the previous version (keep Unreleased section empty) - Update the comparison links at the bottom of the file:
- Discover the repository URL from git remote or CLAUDE.md
[Unreleased]link: compare new version tag to HEAD- New version link: compare previous version tag to new version tag
- Format:
[Unreleased]: https://github.com/<owner>/<repo>/compare/vNEW...HEAD - Format:
[NEW]: https://github.com/<owner>/<repo>/compare/vOLD...vNEW - First release: Use
[NEW]: https://github.com/<owner>/<repo>/commits/vNEW(no previous tag to compare against)
Update package.json:
Edit package.json to set "version" to the new version string.
5.4 Commit and Push to Main
Stage and commit all release housekeeping files:
git add MIGRATIONS.md CHANGELOG.md package.json
git commit -m "release: v<version>"
git push origin main
5.5 Merge Main to Release
git checkout release
git pull origin release
git merge origin/main --no-edit
git push origin release
If merge conflicts occur, STOP and tell the user to resolve them manually.
Note: Pushing to release triggers auto-deploy (if configured). If the service was stopped in Phase 4.3, the deploy will bring it back up automatically. Drizzle runs at startup, sees the covered migrations already in __drizzle_migrations, and skips them.
5.6 Tag Release
Create an annotated git tag on the release branch and push it:
git tag -a "v<version>" -m "v<version>"
git push origin "v<version>"
git checkout main
5.7 Create GitHub Release
Create a GitHub Release from the tag pushed in Phase 5.6. The release notes come from the changelog entry written in Phase 5.3.
Extract release notes from CHANGELOG.md — the content between the new ## [version] header and the next ## [ header (excluding both headers). This is the same section written in Phase 5.3.
Create the release:
First, write the release notes to a temporary file to avoid multi-line Bash command issues:
Use the Write tool to create release-notes.md with the extracted changelog content
Then create the release using --notes-file (avoids multi-line --notes strings that break Bash permission patterns):
gh release create "v<version>" --title "v<version>" --notes-file release-notes.md --verify-tag
Clean up the temp file after:
rm -f release-notes.md
Flags reference:
--verify-tag— Abort if the tag doesn't exist on the remote (safety check)--title— Release title (use the tag name, e.g.,v1.12.0)--notes-file— Read release notes from file (preferred over--notesto avoid multi-line Bash issues)- Do NOT use
--latest— let GitHub auto-detect based on semver (default behavior is correct) - Do NOT use
--draftor--prerelease— all releases from this skill are production releases
Error handling: If gh release create fails, do NOT stop the release. Log a warning in the Phase 6 report:
**Warning:** GitHub Release creation failed: [error message]. Create manually with:
gh release create "v<version>" --title "v<version>" --notes-file release-notes.md --verify-tag
The git tag and deploy already succeeded — the GitHub Release is cosmetic and can be created manually later.
5.8 Verify Deployment (if monitoring is available)
If deployment monitoring is available (e.g., Railway MCP, deployment API), check deployment logs to confirm the release deployed successfully.
Check whether deployment tooling is accessible — if not, skip this step silently.
If available:
- Wait briefly (30 seconds) for the deploy to initialize
- List recent deployments to find the one triggered by the push to
release - Fetch logs and look for:
- Successful build completion
- Server startup confirmation
- No crash loops or error patterns
If the deployment appears to have failed:
- Show the user the relevant log lines
- Do NOT stop — the git tag and GitHub Release already succeeded. The deployment issue needs separate investigation.
- Note the failure in the Phase 6 report
If deployment monitoring is not available, skip this step and note "Deployment monitoring not available — verify manually" in the Phase 6 report.
Phase 6: Post-Release
6.1 Move Issues to Released
Transition all Linear issues in "Done" or "Merge" to "Released" now that the code is live in production.
Look up the Released state UUID using
mcp__linear__list_issue_statuseswith team name. Find the status withname: "Released"(or similar — check what states exist in the team).Query issues stuck in "Merge" state (PR was merged but Linear automation didn't fire):
mcp__linear__list_issues with team: "<discovered-team-name>", state: "Merge"Query all issues in "Done" state:
mcp__linear__list_issues with team: "<discovered-team-name>", state: "Done"For each issue found (from both queries), transition to Released using the state UUID (both Done and Released are
type: completed— passing by name could silently no-op):mcp__linear__update_issue with id: <issue-id>, state: "<released-state-uuid>"Batch efficiently: Call up to 10
update_issuecalls in parallel. If there are more than 30 issues, update the first 30 and note the remainder in the report for manual transition.Collect the list of moved issues (identifier + title) for the report.
If no issues are in Done or Merge, that's fine — skip silently.
If the Linear MCP is unavailable (tools fail), do not STOP — log a warning in the report and continue. The release itself succeeded; issue state is cosmetic.
6.2 Report
## Release Complete
**Version:** X.Y.Z
**Promoted:** main → release
**Commits:** N commits
**Backup:** _migrations/backup-YYYYMMDD-HHMMSS.dump
**Migration:** [Applied successfully | No migration needed]
**Data operations:** [Applied successfully (list queries) | None]
**GitHub Release:** [Created | Failed (see warning above)]
**Deployment:** [Verified — server started successfully | Failed — see below | Monitoring not available — verify manually]
### Issues Released
[List of PROJ-xxx: title moved from Done/Merge → Released, or "None"]
### Environment Variable Changes
[List any env var renames/additions from MIGRATIONS.md, or "None"]
### Next Steps
- Monitor deployment at production
- Verify production at [production URL from CLAUDE.md]
- Check deploy logs if issues arise
6.3 Remind About Env Vars
If MIGRATIONS.md mentioned any environment variable changes, remind the user:
**ACTION REQUIRED:** Update these environment variables in production before the deploy completes:
- [OLD_VAR → NEW_VAR]
- [NEW_VAR=value to add]
Error Handling
| Situation | Action |
|---|---|
Not on main |
STOP — switch to main first |
| Dirty working tree | STOP — commit or stash |
| Behind remote | STOP — pull latest |
| Build/tests fail | STOP — fix before releasing |
| Incomplete tasks in PLANS.md | STOP — finish implementation first |
| No commits to promote | STOP — nothing to do |
| DB backup fails | STOP — never release without backup |
| Complex migration | STOP — discuss with user |
| Migration SQL fails locally | STOP — fix migration before proceeding |
| Migration fails on production | STOP — service is down, investigate, backup available, restore service |
| Data-only SQL fails on production | Log the error — deploy already succeeded, Drizzle migrations are fine. Report to user and suggest manual fix |
| Cannot stop production service | STOP — check deployment CLI |
| Drizzle internals changed | STOP — read migrator source, update skill before proceeding |
| Merge conflicts | STOP — user resolves manually |
| Deployment CLI not available | STOP — install/login deployment CLI |
| pg_dump not found | STOP — install PostgreSQL client tools |
| OrbStack not installed | STOP — brew install orbstack |
| OrbStack stopped | Start with orb start, then continue |
| Invalid/lower version argument | STOP — must be valid semver higher than current |
| GitHub Release creation fails | Warn in report — release succeeded, create manually later |
Rules
- Stop service before backup when migrating — Prevents data loss between backup and migration; backup captures every last write
- Never skip migration assessment — Even if MIGRATIONS.md is empty, check the diff
- Show migration SQL to user — Always get explicit confirmation before applying
- Stop → backup → migrate → deploy — Stopping the service stops writes, backup is consistent, migration applies cleanly, push to
releaserestarts the service - Manual migration covers Drizzle DDL + data + journal — One atomic script does everything; Drizzle skips already-applied migrations at startup
- Clear MIGRATIONS.md after release — Reset to empty template on main
- No co-author attribution — Commit messages must NOT include
Co-Authored-Bytags - Never force-push — Use normal merge only
- Backup files stay local —
_migrations/is gitignored - Never hardcode user data in SQL — Derive from existing DB content (SELECT DISTINCT, JOINs), never hardcode emails, names, or personal data
- Semantic Versioning 2.0.0 — Version bumps follow semver rules: MAJOR for breaking changes, MINOR for new features, PATCH for bug fixes. Every release gets a CHANGELOG.md entry and matching package.json version
- Never defer SQL to the user — All data operations from MIGRATIONS.md must be executed by this skill as part of the release. Never tell the user to run SQL manually post-deploy.
- Linear is cosmetic — Issue state transitions are nice-to-have. Never block a release because Linear MCP is down.
- Stop on any failure — Better to abort than corrupt production