Skip to content

Metaculus Site Stats Warmup Cache #3965

Metaculus Site Stats Warmup Cache

Metaculus Site Stats Warmup Cache #3965

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: Checkout scripts
uses: actions/checkout@v6
with:
sparse-checkout: .github/scripts
- 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: |
BRANCH_SLUG="$(bash .github/scripts/slugify_branch.sh "$BRANCH_REF" 30)"
# 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 }}
cs_role_name: neondb_owner
cs_database: neondb
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
# Resources are matched by `pr-{number}-` prefix rather than by the exact
# name computed in `setup`, so a preview created before a branch-slug
# change still gets destroyed instead of leaking.
- name: Delete Fly apps
id: fly_apps
continue-on-error: true
env:
FLY_ORG: ${{ vars.FLY_ORG || 'personal' }}
APP_PREFIX: metaculus-pr-${{ github.event.number || inputs.pr_number }}-
run: |
# pipefail so a flyctl failure isn't masked by jq succeeding on empty input,
# which would otherwise look identical to "no apps found".
set -o pipefail
STATUS=ok
if ! APPS="$(flyctl apps list --json --org "${FLY_ORG}" \
| jq -r --arg prefix "${APP_PREFIX}" '.[].Name | select(startswith($prefix))')"; then
echo "⚠️ Could not list Fly apps"
echo "status=failed" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ -z "$APPS" ]; then
echo "ℹ️ No Fly apps matching ${APP_PREFIX}*"
echo "status=ok" >> "$GITHUB_OUTPUT"
exit 0
fi
# Here-string, not a pipe: a piped `while` runs in a subshell and STATUS
# changes would be discarded.
while IFS= read -r APP_NAME; do
[ -z "$APP_NAME" ] && continue
echo "Deleting Fly app: ${APP_NAME}"
if ! flyctl apps destroy "${APP_NAME}" --yes; then
echo "⚠️ Failed to destroy ${APP_NAME}"
STATUS=failed
fi
done <<< "$APPS"
echo "status=${STATUS}" >> "$GITHUB_OUTPUT"
- name: Delete Neon branches
id: neon_sweep
continue-on-error: true
env:
NEON_API_KEY: ${{ secrets.NEON_API_KEY }}
NEON_PROJECT_ID: ${{ secrets.NEON_PROJECT_ID }}
BRANCH_PREFIX: preview/pr-${{ github.event.number || inputs.pr_number }}-
run: |
set -o pipefail
STATUS=ok
API="https://console.neon.tech/api/v2/projects/${NEON_PROJECT_ID}/branches"
# --fail-with-body so HTTP 4xx/5xx exits non-zero while still returning the
# error body; plain `curl -sS` exits 0 on an error response, which would read
# as "no branches found". curl applies no total timeout by default, so bound
# both calls explicitly — otherwise a hung connection stalls the whole job.
CURL_OPTS=(-sS --fail-with-body --connect-timeout 10 --max-time 120)
# 10000 is the documented maximum for `limit`, far above any plausible branch
# count, so one request returns everything and no cursor walk is needed.
if ! BRANCH_IDS="$(curl "${CURL_OPTS[@]}" -H "Authorization: Bearer ${NEON_API_KEY}" "${API}?limit=10000" \
| jq -r --arg prefix "${BRANCH_PREFIX}" \
'.branches[]? | select(.name | startswith($prefix)) | .id')"; then
echo "⚠️ Could not list Neon branches"
echo "status=failed" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ -z "$BRANCH_IDS" ]; then
echo "ℹ️ No Neon branches matching ${BRANCH_PREFIX}*"
echo "status=ok" >> "$GITHUB_OUTPUT"
exit 0
fi
while IFS= read -r BRANCH_ID; do
[ -z "$BRANCH_ID" ] && continue
echo "Deleting Neon branch: ${BRANCH_ID}"
if ! BODY="$(curl "${CURL_OPTS[@]}" -X DELETE -H "Authorization: Bearer ${NEON_API_KEY}" "${API}/${BRANCH_ID}")"; then
echo "⚠️ Failed to delete ${BRANCH_ID}: ${BODY:-no response body}"
STATUS=failed
fi
done <<< "$BRANCH_IDS"
echo "status=${STATUS}" >> "$GITHUB_OUTPUT"
- name: Delete Fly Redis Databases
id: fly_redis
continue-on-error: true
env:
FLY_ORG: ${{ vars.FLY_ORG || 'personal' }}
REDIS_PREFIX: mtc-redis-pr-${{ github.event.number || inputs.pr_number }}-
run: |
STATUS=ok
# `flyctl redis list` has no --json output (unlike `apps list`), so the names
# have to be scraped out of the table. Capture the listing first so a flyctl
# failure is distinguishable from grep simply finding no matches.
if ! REDIS_LIST="$(flyctl redis list --org "${FLY_ORG}")"; then
echo "⚠️ Could not list Fly Redis databases"
echo "status=failed" >> "$GITHUB_OUTPUT"
exit 0
fi
REDIS_NAMES="$(printf '%s' "$REDIS_LIST" \
| grep -oE "${REDIS_PREFIX}[a-z0-9-]*" | sort -u || true)"
if [ -z "$REDIS_NAMES" ]; then
echo "ℹ️ No Fly Redis databases matching ${REDIS_PREFIX}*"
echo "status=ok" >> "$GITHUB_OUTPUT"
exit 0
fi
while IFS= read -r REDIS_NAME; do
[ -z "$REDIS_NAME" ] && continue
echo "Deleting Fly Redis database: ${REDIS_NAME}"
if ! flyctl redis destroy "${REDIS_NAME}" --yes; then
echo "⚠️ Failed to destroy ${REDIS_NAME}"
STATUS=failed
fi
done <<< "$REDIS_NAMES"
echo "status=${STATUS}" >> "$GITHUB_OUTPUT"
- name: Delete PR Deployments from Preview Environment
id: deployments
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) }};
// Cleanup steps are continue-on-error, so report what actually happened
// rather than asserting every resource was removed.
const failed = '⚠️ Failed — see workflow logs';
const stepStatus = {
app: ${{ toJSON(steps.fly_apps.outputs.status) }},
neon: ${{ toJSON(steps.neon_sweep.outputs.status) }},
redis: ${{ toJSON(steps.fly_redis.outputs.status) }},
deployments: ${{ toJSON(steps.deployments.outcome) }},
};
const ok = (key) => ['ok', 'success'].includes(stepStatus[key]);
const anyFailed = Object.keys(stepStatus).some((k) => !ok(k));
const table = [
'| Resource | Status |',
'|----------|--------|',
`| 🌐 Preview App | ${ok('app') ? 'Deleted' : failed} |`,
`| 🗄️ PostgreSQL Branch | ${ok('neon') ? 'Deleted' : failed} |`,
`| ⚡ Redis Database | ${ok('redis') ? 'Deleted' : failed} |`,
`| 🔧 GitHub Deployments | ${ok('deployments') ? 'Removed' : failed} |`,
'| 📦 Docker Image | Retained (auto-cleanup via GHCR policies) |'
].join('\n');
const removedPhrase = anyFailed ? 'partially removed' : 'fully removed';
const heading = anyFailed
? 'Cleanup: Preview Environment Partially Removed'
: 'Cleanup: Preview Environment Removed';
const warning = anyFailed
? '> ⚠️ Some resources could not be removed and may still be billing. Check the workflow logs, then re-run cleanup via the `PR Preview Environment` workflow (`action: cleanup`).'
: '';
let body;
if (isClosed) {
body = `## ${heading}
${warning}
The preview environment for this PR has been ${removedPhrase}.
${table}
---
*Cleanup triggered by PR close at ${closedAt}*`;
} else if (cleanupReason.toLowerCase().includes('stale')) {
body = `## ${heading} (Stale)
${warning}
This preview environment was ${removedPhrase} 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 = `## ${heading}
${warning}
The preview environment has been ${removedPhrase} (manual cleanup).
${table}
---
*Manual cleanup triggered by @${actor}*`;
}
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: commentId,
body
});