issue/4466/api-access/api-docs #1320
Workflow file for this run
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
| # ============================================================================= | |
| # PR Preview Environment Workflow | |
| # ============================================================================= | |
| # | |
| # This workflow creates ephemeral preview environments for pull requests: | |
| # 1. Builds Docker image and pushes to GitHub Container Registry (ghcr.io) | |
| # 2. Creates a NeonDB database branch for isolated PostgreSQL | |
| # 3. Creates a Redis database via Fly.io CLI (managed Upstash) for isolated caching | |
| # 4. Deploys the preview app to Fly.io | |
| # 5. Cleans up resources when PR is closed | |
| # | |
| # NOTE: This workflow only runs for internal PRs (from the same repository). | |
| # Fork PRs are skipped because GitHub Actions does not provide secrets access | |
| # to workflows triggered by fork PRs for security reasons. | |
| # | |
| # Required Secrets: | |
| # ----------------- | |
| # - NEON_PROJECT_ID: Your Neon project ID (found in Neon Console project settings) | |
| # - NEON_API_KEY: API key from Neon Console (https://console.neon.tech/app/settings/api-keys) | |
| # - FLY_API_TOKEN: API token from Fly.io (run `fly tokens create deploy`) | |
| # - SECRET_KEY: Django secret key for preview environments | |
| # - MAILGUN_API_KEY: Mailgun API key for email sending | |
| # - AWS_SECRET_ACCESS_KEY: AWS secret access key for S3 storage | |
| # - SCREENSHOT_SERVICE_API_KEY: Screenshot service API key | |
| # - SENTRY_AUTH_TOKEN: Sentry authentication token | |
| # - OPENAI_API_KEY: OpenAI API key | |
| # - SERPER_API_KEY: Serper API key for search functionality | |
| # | |
| # Required Variables: | |
| # ------------------- | |
| # - FLY_ORG: Fly.io organization (defaults to 'personal') | |
| # - FLY_REDIS_REGION: Redis region for Fly.io managed Upstash (defaults to 'iad') | |
| # - AWS_ACCESS_KEY_ID: AWS access key ID for S3 storage | |
| # - AWS_STORAGE_BUCKET_NAME: AWS S3 bucket name | |
| # - AWS_S3_REGION_NAME: AWS S3 region name | |
| # - MAILGUN_DOMAIN: Mailgun domain for email sending | |
| # - SCREENSHOT_SERVICE_API_URL: Screenshot service API URL | |
| # - PUBLIC_SCREENSHOT_SERVICE_ENABLED: Enable screenshot service | |
| # - SENTRY_DNS: Sentry DSN for error tracking (for backend) | |
| # - PUBLIC_FRONTEND_SENTRY_DSN: Frontend Sentry DSN | |
| # - PUBLIC_POSTHOG_KEY: PostHog project API key | |
| # ============================================================================= | |
| name: PR Preview Environment | |
| on: | |
| pull_request: | |
| types: | |
| - opened | |
| - reopened | |
| - synchronize | |
| - closed | |
| workflow_dispatch: | |
| inputs: | |
| pr_number: | |
| description: "PR number to deploy/redeploy preview for" | |
| required: true | |
| type: number | |
| action: | |
| description: "Action to perform" | |
| required: false | |
| type: choice | |
| options: | |
| - deploy | |
| - cleanup | |
| default: deploy | |
| cleanup_reason: | |
| description: "Reason for cleanup (shown in PR comment)" | |
| required: false | |
| type: string | |
| default: "" | |
| reset_db: | |
| description: "Reset database branch to parent before deploy (fresh data)" | |
| required: false | |
| type: boolean | |
| default: false | |
| concurrency: | |
| group: pr-preview-${{ github.event.number || inputs.pr_number }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| packages: write | |
| pull-requests: write | |
| deployments: write | |
| env: | |
| FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} | |
| jobs: | |
| # =========================================================================== | |
| # Fork Check - Skip workflow for fork PRs (no secrets access) | |
| # =========================================================================== | |
| fork-check: | |
| name: Check PR Source | |
| runs-on: ubuntu-latest | |
| outputs: | |
| is_fork: ${{ steps.check.outputs.is_fork }} | |
| steps: | |
| - name: Check if PR is from a fork | |
| id: check | |
| run: | | |
| if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then | |
| echo "is_fork=false" >> $GITHUB_OUTPUT | |
| echo "✅ Triggered via workflow_dispatch (always internal)" | |
| elif [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then | |
| echo "is_fork=true" >> $GITHUB_OUTPUT | |
| echo "⚠️ This PR is from a fork - preview deployments are not available" | |
| else | |
| echo "is_fork=false" >> $GITHUB_OUTPUT | |
| echo "✅ This PR is from the same repository" | |
| fi | |
| - name: Post comment for fork PRs | |
| if: | | |
| steps.check.outputs.is_fork == 'true' && | |
| github.event.action != 'closed' | |
| uses: peter-evans/find-comment@v3 | |
| id: find_comment | |
| with: | |
| issue-number: ${{ github.event.number }} | |
| comment-author: "github-actions[bot]" | |
| body-includes: "Preview environments are not available for fork PRs" | |
| - name: Create comment for fork PRs | |
| if: | | |
| steps.check.outputs.is_fork == 'true' && | |
| github.event.action != 'closed' && | |
| steps.find_comment.outputs.comment-id == '' | |
| uses: peter-evans/create-or-update-comment@v4 | |
| with: | |
| issue-number: ${{ github.event.number }} | |
| body: | | |
| ## ℹ️ Preview Environment Not Available | |
| Preview environments are not available for fork PRs due to GitHub Actions security restrictions. | |
| Your changes will be reviewed and tested by maintainers. Thank you for contributing! | |
| # =========================================================================== | |
| # Setup Job - Get branch name and PR info | |
| # =========================================================================== | |
| setup: | |
| name: Setup | |
| needs: fork-check | |
| if: needs.fork-check.outputs.is_fork != 'true' | |
| runs-on: ubuntu-latest | |
| outputs: | |
| fly_app: ${{ steps.names.outputs.fly_app }} | |
| neon_branch: ${{ steps.names.outputs.neon_branch }} | |
| redis_name: ${{ steps.names.outputs.redis_name }} | |
| pr_head_ref: ${{ steps.pr_head_ref.outputs.pr_head_ref }} | |
| steps: | |
| - name: Resolve PR head ref | |
| id: pr_head_ref | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const inputPrNumber = `${{ inputs.pr_number || '' }}`.trim(); | |
| if (context.payload.pull_request?.head?.ref) { | |
| core.setOutput('pr_head_ref', context.payload.pull_request.head.ref); | |
| return; | |
| } | |
| if (inputPrNumber) { | |
| const pr = await github.rest.pulls.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: Number(inputPrNumber), | |
| }); | |
| core.setOutput('pr_head_ref', pr.data.head.ref); | |
| return; | |
| } | |
| core.setOutput('pr_head_ref', context.ref.replace('refs/heads/', '')); | |
| - name: Generate resource names | |
| id: names | |
| env: | |
| BRANCH_REF: ${{ steps.pr_head_ref.outputs.pr_head_ref || github.head_ref }} | |
| PR_NUMBER: ${{ github.event.number || inputs.pr_number }} | |
| run: | | |
| # Slugify branch name: lowercase, replace invalid chars, truncate, then cleanup again | |
| BRANCH_SLUG="$(printf '%s' "$BRANCH_REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]/-/g' | sed 's/--*/-/g' | sed 's/^[-_]*//' | sed 's/[-_]*$//' | cut -c1-30 | sed 's/^[-_]*//' | sed 's/[-_]*$//')" | |
| # Base preview identifier: pr-{number}-{branch_slug} | |
| PREVIEW_ID="pr-${PR_NUMBER}-${BRANCH_SLUG}" | |
| # Resource names with service-specific prefixes | |
| echo "fly_app=metaculus-${PREVIEW_ID}" >> $GITHUB_OUTPUT | |
| echo "neon_branch=preview/${PREVIEW_ID}" >> $GITHUB_OUTPUT | |
| echo "redis_name=mtc-redis-${PREVIEW_ID}" >> $GITHUB_OUTPUT | |
| echo "Preview ID: ${PREVIEW_ID}" | |
| # =========================================================================== | |
| # Build Docker Image - Build and push to GitHub Container Registry | |
| # =========================================================================== | |
| build-image: | |
| name: Build Docker Image | |
| needs: [fork-check, setup] | |
| if: | | |
| github.event.action != 'closed' && | |
| inputs.action != 'cleanup' && | |
| needs.fork-check.outputs.is_fork != 'true' | |
| uses: ./.github/workflows/docker_build.yml | |
| secrets: inherit | |
| # =========================================================================== | |
| # Deploy to Fly.io - Deploy preview app with the built image | |
| # =========================================================================== | |
| deploy-preview: | |
| name: Deploy Preview App | |
| needs: [fork-check, setup, build-image] | |
| if: | | |
| github.event.action != 'closed' && | |
| inputs.action != 'cleanup' && | |
| needs.fork-check.outputs.is_fork != 'true' | |
| runs-on: ubuntu-latest | |
| environment: | |
| name: Preview | |
| url: ${{ steps.deploy.outputs.url }} | |
| outputs: | |
| url: ${{ steps.deploy.outputs.url }} | |
| app_name: ${{ steps.deploy.outputs.app_name }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v6 | |
| - name: Setup Fly.io CLI | |
| uses: superfly/flyctl-actions/setup-flyctl@master | |
| with: | |
| version: 0.4.14 | |
| - name: Create NeonDB Branch | |
| id: neon | |
| uses: neondatabase/create-branch-action@v5 | |
| with: | |
| project_id: ${{ secrets.NEON_PROJECT_ID }} | |
| branch_name: ${{ needs.setup.outputs.neon_branch }} | |
| api_key: ${{ secrets.NEON_API_KEY }} | |
| username: neondb_owner | |
| - name: Reset NeonDB Branch to parent | |
| if: github.event_name == 'workflow_dispatch' && (inputs.reset_db == true || inputs.reset_db == 'true') | |
| id: reset-branch | |
| uses: neondatabase/reset-branch-action@v1 | |
| with: | |
| project_id: ${{ secrets.NEON_PROJECT_ID }} | |
| branch: ${{ needs.setup.outputs.neon_branch }} | |
| parent: true | |
| api_key: ${{ secrets.NEON_API_KEY }} | |
| - name: Create or Get Fly Redis Database | |
| id: redis | |
| run: | | |
| REDIS_NAME="${{ needs.setup.outputs.redis_name }}" | |
| FLY_ORG="${{ vars.FLY_ORG || 'personal' }}" | |
| REDIS_REGION="${{ vars.FLY_REDIS_REGION || 'iad' }}" | |
| # Check if database already exists | |
| if flyctl redis list --org "${FLY_ORG}" | grep -qw "${REDIS_NAME}"; then | |
| echo "✅ Found existing Redis database: ${REDIS_NAME}" | |
| else | |
| echo "Creating new Redis database: ${REDIS_NAME}" | |
| flyctl redis create \ | |
| --name "${REDIS_NAME}" \ | |
| --region "${REDIS_REGION}" \ | |
| --no-replicas \ | |
| --enable-eviction \ | |
| --enable-auto-upgrade=false \ | |
| --enable-prodpack=false \ | |
| --org "${FLY_ORG}" | |
| echo "✅ Created new Redis database: ${REDIS_NAME}" | |
| fi | |
| # Get Redis connection URL from status (parse text output) | |
| REDIS_URL=$(flyctl redis status "${REDIS_NAME}" | grep -i "Private URL" | awk -F'= ' '{print $2}' | tr -d ' ') | |
| if [ -z "$REDIS_URL" ]; then | |
| echo "❌ Failed to get Redis URL from status" | |
| flyctl redis status "${REDIS_NAME}" | |
| exit 1 | |
| fi | |
| # Set outputs | |
| echo "redis_cache_url=${REDIS_URL}" >> $GITHUB_OUTPUT | |
| echo "redis_mq_url=${REDIS_URL}" >> $GITHUB_OUTPUT | |
| echo "Redis URL retrieved successfully" | |
| - name: Create or update Fly app | |
| id: deploy | |
| run: | | |
| APP_NAME="${{ needs.setup.outputs.fly_app }}" | |
| # Check if app exists, create if not | |
| if ! flyctl apps list | grep -qw "${APP_NAME}"; then | |
| echo "Creating new Fly app: ${APP_NAME}" | |
| flyctl apps create "${APP_NAME}" \ | |
| --org ${{ vars.FLY_ORG || 'personal' }} | |
| fi | |
| # Define app URLs (used for secrets and outputs) | |
| APP_DOMAIN="${APP_NAME}-preview.mtcl.cc" | |
| PUBLIC_APP_URL="https://${APP_DOMAIN}" | |
| # Set all secrets and environment variables for the app | |
| # Secrets are encrypted at rest in Fly.io | |
| flyctl secrets set \ | |
| DATABASE_URL="${{ steps.neon.outputs.db_url }}" \ | |
| REDIS_CACHE_URL="${{ steps.redis.outputs.redis_cache_url }}" \ | |
| REDIS_MQ_URL="${{ steps.redis.outputs.redis_mq_url }}" \ | |
| APP_DOMAIN="${APP_DOMAIN}" \ | |
| PUBLIC_APP_URL="${PUBLIC_APP_URL}" \ | |
| METACULUS_ENV="preview" \ | |
| SECRET_KEY="${{ secrets.SECRET_KEY }}" \ | |
| AWS_ACCESS_KEY_ID="${{ vars.AWS_ACCESS_KEY_ID }}" \ | |
| AWS_SECRET_ACCESS_KEY="${{ secrets.AWS_SECRET_ACCESS_KEY }}" \ | |
| AWS_STORAGE_BUCKET_NAME="${{ vars.AWS_STORAGE_BUCKET_NAME }}" \ | |
| AWS_S3_REGION_NAME="${{ vars.AWS_S3_REGION_NAME }}" \ | |
| MAILGUN_API_KEY="${{ secrets.MAILGUN_API_KEY }}" \ | |
| MAILGUN_DOMAIN="${{ vars.MAILGUN_DOMAIN }}" \ | |
| SCREENSHOT_SERVICE_API_KEY="${{ secrets.SCREENSHOT_SERVICE_API_KEY }}" \ | |
| SCREENSHOT_SERVICE_API_URL="${{ vars.SCREENSHOT_SERVICE_API_URL }}" \ | |
| PUBLIC_SCREENSHOT_SERVICE_ENABLED="${{ vars.PUBLIC_SCREENSHOT_SERVICE_ENABLED }}" \ | |
| OPENAI_API_KEY="${{ secrets.OPENAI_API_KEY }}" \ | |
| SERPER_API_KEY="${{ secrets.SERPER_API_KEY }}" \ | |
| SENTRY_DNS="${{ vars.SENTRY_DNS }}" \ | |
| PUBLIC_FRONTEND_SENTRY_DSN="${{ vars.PUBLIC_FRONTEND_SENTRY_DSN }}" \ | |
| SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}" \ | |
| PUBLIC_POSTHOG_KEY="${{ vars.PUBLIC_POSTHOG_KEY }}" \ | |
| --app "${APP_NAME}" \ | |
| --stage | |
| # Deploy using the pre-built image (immutable SHA tag for reproducibility) | |
| # Retry couple of times because Fly deploy can occasionally fail transiently. | |
| MAX_ATTEMPTS=3 | |
| ATTEMPT=1 | |
| until flyctl deploy \ | |
| --app "${APP_NAME}" \ | |
| --config fly.preview.toml \ | |
| --image ${{ needs.build-image.outputs.image_url }} \ | |
| --ha=false \ | |
| --now; do | |
| if [ "${ATTEMPT}" -ge "${MAX_ATTEMPTS}" ]; then | |
| echo "❌ Deploy failed after ${MAX_ATTEMPTS} attempts" | |
| exit 1 | |
| fi | |
| ATTEMPT=$((ATTEMPT + 1)) | |
| echo "⚠️ Deploy failed, retrying (${ATTEMPT}/${MAX_ATTEMPTS}) in 10s..." | |
| sleep 10 | |
| done | |
| # Start machines after deployment (continue on failure) | |
| echo "Starting machines for ${APP_NAME}..." | |
| flyctl machine list -q -a "${APP_NAME}" | while read id; do | |
| if [ -n "$id" ]; then | |
| echo "Starting machine: $id" | |
| flyctl machine start "$id" -a "${APP_NAME}" || echo "⚠️ Failed to start machine $id (may already be running or old version)" | |
| fi | |
| done | |
| echo "✅ Machine start commands completed" | |
| # Output the app URL for other jobs | |
| echo "url=${PUBLIC_APP_URL}" >> $GITHUB_OUTPUT | |
| echo "app_name=${APP_NAME}" >> $GITHUB_OUTPUT | |
| echo "✅ Deployed to: ${PUBLIC_APP_URL}" | |
| # =========================================================================== | |
| # Post Deployment Comment - Add comment to PR with preview URLs | |
| # =========================================================================== | |
| comment-on-pr: | |
| name: Comment on PR | |
| needs: [fork-check, setup, build-image, deploy-preview] | |
| if: | | |
| github.event.action != 'closed' && | |
| inputs.action != 'cleanup' && | |
| needs.fork-check.outputs.is_fork != 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Find existing comment | |
| uses: peter-evans/find-comment@v3 | |
| id: find_comment | |
| with: | |
| issue-number: ${{ github.event.number || inputs.pr_number }} | |
| comment-author: "github-actions[bot]" | |
| body-includes: "Preview Environment" | |
| - name: Create or update comment | |
| uses: peter-evans/create-or-update-comment@v4 | |
| with: | |
| comment-id: ${{ steps.find_comment.outputs.comment-id }} | |
| issue-number: ${{ github.event.number || inputs.pr_number }} | |
| edit-mode: replace | |
| body: | | |
| ## 🚀 Preview Environment | |
| Your preview environment is ready! | |
| | Resource | Details | | |
| |----------|---------| | |
| | 🌐 **Preview URL** | ${{ needs.deploy-preview.outputs.url }} | | |
| | 📦 **Docker Image** | `${{ needs.build-image.outputs.image_url }}` | | |
| | 🗄️ **PostgreSQL** | NeonDB branch `${{ needs.setup.outputs.neon_branch }}` | | |
| | ⚡ **Redis** | Fly Redis `${{ needs.setup.outputs.redis_name }}` | | |
| ### Details | |
| - **Commit:** `${{ github.sha }}` | |
| - **Branch:** `${{ needs.setup.outputs.pr_head_ref || github.head_ref }}` | |
| - **Fly App:** `${{ needs.deploy-preview.outputs.app_name }}` | |
| --- | |
| <details> | |
| <summary>ℹ️ Preview Environment Info</summary> | |
| **Isolation:** | |
| - PostgreSQL and Redis are fully isolated from production | |
| - Each PR gets its own database branch and Redis instance | |
| - Changes pushed to this PR will trigger a new deployment | |
| **Limitations:** | |
| - Background workers and cron jobs are not deployed in preview environments | |
| - If you need to test background jobs, use Heroku staging environments | |
| **Cleanup:** | |
| - This preview will be automatically destroyed when the PR is closed | |
| </details> | |
| # =========================================================================== | |
| # Cleanup - Delete preview resources when PR is closed | |
| # =========================================================================== | |
| cleanup-preview: | |
| name: Cleanup Preview Resources | |
| needs: [fork-check, setup] | |
| # Only cleanup for internal PRs (forks never had resources created) | |
| if: | | |
| (github.event.action == 'closed' || inputs.action == 'cleanup') && | |
| needs.fork-check.outputs.is_fork != 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Setup Fly.io CLI | |
| uses: superfly/flyctl-actions/setup-flyctl@master | |
| with: | |
| version: 0.4.14 | |
| - name: Delete Fly app | |
| continue-on-error: true | |
| run: | | |
| echo "Deleting Fly app: ${{ needs.setup.outputs.fly_app }}" | |
| flyctl apps destroy "${{ needs.setup.outputs.fly_app }}" --yes || echo "App may not exist or already deleted" | |
| - name: Delete Neon Branch | |
| continue-on-error: true | |
| uses: neondatabase/delete-branch-action@v3 | |
| with: | |
| project_id: ${{ secrets.NEON_PROJECT_ID }} | |
| branch: ${{ needs.setup.outputs.neon_branch }} | |
| api_key: ${{ secrets.NEON_API_KEY }} | |
| - name: Delete Fly Redis Database | |
| continue-on-error: true | |
| run: | | |
| FLY_ORG="${{ vars.FLY_ORG || 'personal' }}" | |
| REDIS_NAME="${{ needs.setup.outputs.redis_name }}" | |
| if flyctl redis list --org "${FLY_ORG}" | grep -qw "${REDIS_NAME}"; then | |
| echo "Deleting Fly Redis database: ${REDIS_NAME}" | |
| flyctl redis destroy "${REDIS_NAME}" --yes | |
| echo "✅ Deleted Fly Redis database" | |
| else | |
| echo "ℹ️ Fly Redis database not found or already deleted" | |
| fi | |
| - name: Delete PR Deployments from Preview Environment | |
| continue-on-error: true | |
| uses: strumwolf/delete-deployment-environment@v3 | |
| with: | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| environment: Preview | |
| onlyRemoveDeployments: true | |
| ref: ${{ needs.setup.outputs.pr_head_ref || github.head_ref }} | |
| - name: Find existing comment | |
| uses: peter-evans/find-comment@v3 | |
| id: find_comment | |
| with: | |
| issue-number: ${{ github.event.number || inputs.pr_number }} | |
| comment-author: "github-actions[bot]" | |
| body-includes: "Preview Environment" | |
| - name: Update comment to show cleanup | |
| if: steps.find_comment.outputs.comment-id != '' | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const commentId = ${{ steps.find_comment.outputs.comment-id }}; | |
| const isClosed = ${{ github.event.action == 'closed' }}; | |
| const cleanupReason = ${{ toJSON(inputs.cleanup_reason || '') }}; | |
| const closedAt = ${{ toJSON(github.event.pull_request.closed_at || '') }}; | |
| const actor = ${{ toJSON(github.actor) }}; | |
| const table = [ | |
| '| Resource | Status |', | |
| '|----------|--------|', | |
| '| 🌐 Preview App | Deleted |', | |
| '| 🗄️ PostgreSQL Branch | Deleted |', | |
| '| ⚡ Redis Database | Deleted |', | |
| '| 🔧 GitHub Deployments | Removed |', | |
| '| 📦 Docker Image | Retained (auto-cleanup via GHCR policies) |' | |
| ].join('\n'); | |
| let body; | |
| if (isClosed) { | |
| body = `## Cleanup: Preview Environment Removed | |
| The preview environment for this PR has been destroyed. | |
| ${table} | |
| --- | |
| *Cleanup triggered by PR close at ${closedAt}*`; | |
| } else if (cleanupReason.toLowerCase().includes('stale')) { | |
| body = `## Cleanup: Preview Environment Removed (Stale) | |
| This preview environment has been deleted because the PR was marked as **Stale** (no activity detected). | |
| ${table} | |
| Push a new commit to this PR to recreate the preview environment. | |
| --- | |
| *Automated cleanup by weekly maintenance*`; | |
| } else { | |
| body = `## Cleanup: Preview Environment Removed | |
| The preview environment has been manually destroyed. | |
| ${table} | |
| --- | |
| *Manual cleanup triggered by @${actor}*`; | |
| } | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: commentId, | |
| body | |
| }); |