Skip to content

migrate

migrate #1

Workflow file for this run

name: migrate
on:
workflow_call:
inputs:
environment:
description: Target environment (production, staging, or dev)
required: true
type: string
secrets:
DATABASE_URL:
required: false
STAGING_DATABASE_URL:
required: false
DEV_DATABASE_URL:
required: false
MIGRATION_DATABASE_URL:
required: false
STAGING_MIGRATION_DATABASE_URL:
required: false
TS_OAUTH_CLIENT_ID:
required: false
TS_AUDIENCE:
required: false
workflow_dispatch:
inputs:
environment:
description: Target environment
required: true
type: choice
options:
- production
- staging
- dev
permissions:
contents: read
jobs:
migrate:
name: migrate
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
# Bulk projection loads and concurrent index builds can outlast ordinary schema changes.
timeout-minutes: 300
# One migrate job per environment at a time, so a manual dispatch queues
# behind a CI run instead of racing it for migrate.ts's advisory lock, which
# gives up after 30 minutes. Job-level because a called workflow evaluates
# workflow-level concurrency with the caller's github context; the group
# must also never equal ci.yml's ci-<ref> group or the two deadlock.
concurrency:
group: migrate-${{ inputs.environment }}
cancel-in-progress: false
permissions:
contents: read
# Tailscale workload identity federation exchanges this job's OIDC token.
id-token: write
steps:
# An environment only migrates from its own branch, so a manual dispatch
# from a feature branch cannot apply that branch's migrations to a shared
# database. Under workflow_call github.ref is the caller's ref (ci.yml's
# push to main/staging/dev). This fails rather than skips: ci.yml's
# promote gates on migrate succeeding, and a skipped run must never read
# as a migration that happened. It runs before any secret is read.
- name: Require the environment's own branch
env:
ENVIRONMENT: ${{ inputs.environment }}
REF: ${{ github.ref }}
run: |
case "${ENVIRONMENT}" in
production) expected=refs/heads/main ;;
staging) expected=refs/heads/staging ;;
dev) expected=refs/heads/dev ;;
*)
echo "::error::Unknown migrate environment '${ENVIRONMENT}'"
exit 1
;;
esac
if [ "${REF}" != "${expected}" ]; then
echo "::error::Refusing to migrate ${ENVIRONMENT} from ${REF}; ${ENVIRONMENT} migrates only from ${expected}"
exit 1
fi
# Database IP restrictions admit the tailnet's app connector, never a
# runner's address. Linux nodes only use app connector routes with
# --accept-routes.
- name: Join the tailnet
if: vars.CI_DB_TAILNET == 'true'
uses: tailscale/github-action@d1b6cd204f8dceda5b3eaad7f1f767be390056cd # v4.2.0
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
audience: ${{ secrets.TS_AUDIENCE }}
tags: tag:ci
args: --accept-routes
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
with:
persist-credentials: false
- name: Setup workspace
uses: ./.github/actions/setup
with:
provider: ${{ vars.CI_PROVIDER }}
node-version: ''
- name: Validate Project migration database prerequisites
if: inputs.environment != 'dev'
working-directory: ./packages/db
env:
DATABASE_URL: ${{ inputs.environment == 'production' && secrets.DATABASE_URL || inputs.environment == 'staging' && secrets.STAGING_DATABASE_URL || '' }}
MIGRATION_DATABASE_URL: ${{ inputs.environment == 'production' && secrets.MIGRATION_DATABASE_URL || inputs.environment == 'staging' && secrets.STAGING_MIGRATION_DATABASE_URL || '' }}
run: bun --no-env-file scripts/project-contract-required.ts
# The expression maps the explicit environment input to exactly one repo
# secret, so the job never holds another environment's database URL. An
# unknown environment resolves to empty and the guard below fails the job.
# MIGRATION_DATABASE_URL is the optional direct (non-pooled) DSN preferred
# by migrate.ts; when the secret is unset it resolves to empty and the
# script falls back to DATABASE_URL.
- name: Apply database schema changes
working-directory: ./packages/db
env:
DATABASE_URL: ${{ inputs.environment == 'production' && secrets.DATABASE_URL || inputs.environment == 'staging' && secrets.STAGING_DATABASE_URL || inputs.environment == 'dev' && secrets.DEV_DATABASE_URL || '' }}
MIGRATION_DATABASE_URL: ${{ inputs.environment == 'production' && secrets.MIGRATION_DATABASE_URL || inputs.environment == 'staging' && secrets.STAGING_MIGRATION_DATABASE_URL || '' }}
ENVIRONMENT: ${{ inputs.environment }}
run: |
set -euo pipefail
if [ -z "$DATABASE_URL" ]; then
echo "ERROR: no database URL secret resolved for environment '${ENVIRONMENT}'" >&2
exit 1
fi
if [ "${ENVIRONMENT}" = "dev" ]; then
echo "Dev environment — pushing schema directly (db:push)"
# Dev deliberately forces direct schema reconciliation; staging and
# production use guarded versioned migrations in the other branch.
SIM_DEV_DB_PUSH=1 bun run db:push --force < /dev/null
else
echo "Applying versioned migrations (db:migrate)"
bun run ./scripts/migrate.ts
fi