name: Cleanup PR Previews on: # Run hourly to check for expired previews schedule: - cron: '0 * * * *' # Immediately cleanup when PR is closed pull_request: types: [closed, unlabeled] # Allow manual trigger workflow_dispatch: inputs: pr_number: description: 'PR number to cleanup (optional, cleans all expired if empty)' required: false type: string permissions: contents: read deployments: write jobs: cleanup-on-close: name: Cleanup closed PR preview if: github.event_name == 'pull_request' && (github.event.action == 'closed' || (github.event.action == 'unlabeled' && github.event.label.name == 'preview-cf')) runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout code uses: actions/checkout@v5 - name: Setup Node.js uses: actions/setup-node@v5 with: node-version: "24" - name: Install Wrangler run: npm install -g wrangler - name: Delete preview Worker env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} run: | WORKER_NAME="sure-preview-${{ github.event.pull_request.number }}" echo "Deleting Worker: $WORKER_NAME" # Delete the worker (this also stops any running containers) wrangler delete --name "$WORKER_NAME" --force || echo "Worker may not exist" - name: Delete GitHub Deployment uses: actions/github-script@v7 with: script: | const environment = `preview-pr-${{ github.event.pull_request.number }}`; const description = context.payload.action === 'closed' ? 'PR closed - preview deleted' : 'preview-cf label removed - preview deleted'; try { // Get deployments for this environment const { data: deployments } = await github.rest.repos.listDeployments({ owner: context.repo.owner, repo: context.repo.repo, environment: environment }); // Mark all deployments as inactive for (const deployment of deployments) { await github.rest.repos.createDeploymentStatus({ owner: context.repo.owner, repo: context.repo.repo, deployment_id: deployment.id, state: 'inactive', description }); } console.log(`Marked ${deployments.length} deployments as inactive`); } catch (error) { console.log('No deployments to cleanup or error:', error.message); } cleanup-expired: name: Cleanup expired previews if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout code uses: actions/checkout@v5 - name: Setup Node.js uses: actions/setup-node@v5 with: node-version: "24" - name: Install Wrangler run: npm install -g wrangler - name: Cleanup expired previews env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_INPUT: ${{ inputs.pr_number }} run: | # If specific PR number provided, only cleanup that one if [ -n "$PR_INPUT" ]; then if [[ "$PR_INPUT" =~ ^[1-9][0-9]*$ ]]; then PR_NUM="$PR_INPUT" WORKER_NAME="sure-preview-$PR_NUM" echo "Manually deleting Worker: $WORKER_NAME" wrangler delete --name "$WORKER_NAME" --force || echo "Worker may not exist" # Cleanup GitHub deployment for this PR echo "Cleaning up GitHub deployment for PR #$PR_NUM" gh api \ -X GET "/repos/${{ github.repository }}/deployments?environment=preview-pr-$PR_NUM" \ --jq '.[].id' 2>/dev/null | while read -r DEPLOY_ID; do if [ -n "$DEPLOY_ID" ]; then gh api \ -X POST "/repos/${{ github.repository }}/deployments/$DEPLOY_ID/statuses" \ -f state=inactive \ -f description="Preview manually deleted" || true fi done || echo "No deployments to cleanup or error occurred" else echo "Invalid PR number input '$PR_INPUT'; skipping manual cleanup" fi exit 0 fi # Get list of all preview workers echo "Fetching list of preview workers..." # Use Cloudflare API to list workers and read modified_on from the list response. # The per-script endpoint returns raw script content, not JSON metadata. WORKERS_RESPONSE=$(curl -fsS -X GET \ "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts" \ -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ -H "Content-Type: application/json") || { echo "Failed to fetch preview worker list from Cloudflare" exit 1 } if ! echo "$WORKERS_RESPONSE" | jq -e '.success == true and (.result | type == "array")' >/dev/null 2>&1; then echo "Cloudflare API returned an invalid worker list response" echo "$WORKERS_RESPONSE" | jq -c '.errors // .' exit 1 fi WORKERS=$(echo "$WORKERS_RESPONSE" | jq -r ' .result[] | select(.id | startswith("sure-preview-")) | [.id, (.modified_on // "")] | @tsv ') if [ -z "$WORKERS" ]; then echo "No preview workers found" exit 0 fi echo "Found preview workers:" echo "$WORKERS" | cut -f1 # Check each worker's deployment time CUTOFF_TIME=$(date -d '24 hours ago' +%s) while IFS=$'\t' read -r WORKER MODIFIED_ON; do [ -n "$WORKER" ] || continue echo "Checking $WORKER..." if [ -z "$MODIFIED_ON" ]; then echo "No modified_on timestamp for $WORKER; skipping" continue fi if ! MODIFIED_TS=$(date -d "$MODIFIED_ON" +%s 2>/dev/null); then echo "Invalid modified_on timestamp for $WORKER ($MODIFIED_ON); skipping" continue fi if [ "$MODIFIED_TS" -lt "$CUTOFF_TIME" ]; then echo "Worker $WORKER is older than 24 hours, deleting..." if wrangler delete --name "$WORKER" --force; then # Extract PR number and cleanup GitHub deployment PR_NUM=$(echo "$WORKER" | sed 's/sure-preview-//') if [[ "$PR_NUM" =~ ^[1-9][0-9]*$ ]]; then echo "Cleaning up GitHub deployment for PR #$PR_NUM" gh api \ -X GET "/repos/${{ github.repository }}/deployments?environment=preview-pr-$PR_NUM" \ --jq '.[].id' 2>/dev/null | while read -r DEPLOY_ID; do gh api \ -X POST "/repos/${{ github.repository }}/deployments/$DEPLOY_ID/statuses" \ -f state=inactive \ -f description="Preview expired after 24 hours" || true done || echo "No deployments to cleanup or error occurred" else echo "Could not extract a valid PR number from $WORKER; skipping deployment cleanup" fi else echo "Failed to delete $WORKER; skipping deployment status update" fi else echo "Worker $WORKER is still within 24-hour window, keeping..." fi done <<< "$WORKERS" echo "Cleanup complete"