Skip to content

issue/4466/api-access/api-docs #1320

issue/4466/api-access/api-docs

issue/4466/api-access/api-docs #1320

Workflow file for this run

# =============================================================================
# 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
});