Repository navigation
migrate #2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. The action already passes --accept-routes, which | |
| # Linux nodes need to use app connector routes; repeating it in args | |
| # makes `tailscale up` reject the duplicate flag. | |
| - 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 | |
| - 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 |