Reap leaked preview apps on a schedule
The preview deploy action destroys a PR's Fly app on close, but it does so
with `|| true` and only from the pull_request close event. A failed or
skipped close run leaks the app with nothing to catch it.

Add a daily workflow that lists Fly apps matching the action's
pr-<number>-<owner>-<repo> naming and destroys any whose PR is merged,
closed, or gone. Open PRs untouched for 30 days are reaped too; a later
push redeploys them. workflow_dispatch exposes dry_run and stale_days.

A preflight repo read guards the 404 check, so a broken token aborts the
run instead of destroying every preview.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187Teo4EVeUyvAs9F7Zgjqk
change
commit ea9537eb42bad5b02f1c9339c2ead8ae758f48a8
author Claude <noreply@anthropic.com>
date
parent xulyxuxl
diff --git a/.github/workflows/reap-previews.yml b/.github/workflows/reap-previews.yml
new file mode 100644
index 0000000..bb9140f
--- /dev/null
+++ b/.github/workflows/reap-previews.yml
@@ -0,0 +1,146 @@
+name: Reap previews
+
+on:
+  schedule:
+    - cron: "17 8 * * *"
+  workflow_dispatch:
+    inputs:
+      dry_run:
+        description: Report what would be destroyed without destroying it
+        type: boolean
+        default: false
+      stale_days:
+        description: Also reap previews for open PRs untouched this many days (0 disables)
+        type: string
+        default: "30"
+
+permissions: {}
+
+concurrency:
+  group: ${{ github.workflow }}
+  cancel-in-progress: false
+
+jobs:
+  reap:
+    name: Reap previews
+    runs-on: ubuntu-latest
+    permissions:
+      pull-requests: read # read PR state to decide which previews are finished
+    steps:
+      - uses: superfly/flyctl-actions/setup-flyctl@63da3ecc5e2793b98a3f2519b3d75d4f4c11cec2 # master
+
+      - name: Destroy previews for finished pull requests
+        env:
+          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
+          FLY_ORG: personal
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          REPO: ${{ github.repository }}
+          OWNER: ${{ github.repository_owner }}
+          DRY_RUN: ${{ inputs.dry_run }}
+          STALE_DAYS: ${{ inputs.stale_days || '30' }}
+        run: |
+          set -euo pipefail
+
+          # Preview apps are named by superfly/fly-pr-review-apps as
+          # pr-<number>-<owner>-<repo>, with underscores turned into hyphens.
+          repo_name="${REPO#"${OWNER}/"}"
+          suffix="-$(printf '%s' "${OWNER}-${repo_name}" | tr '_' '-')"
+
+          # Prove the token can read this repo before trusting any 404 below to
+          # mean "that pull request is gone" rather than "the token is broken".
+          gh api "repos/${REPO}" --jq .full_name > /dev/null
+
+          cutoff=""
+          if [ "${STALE_DAYS}" != "0" ]; then
+            cutoff=$(date -u -d "${STALE_DAYS} days ago" +%s)
+          fi
+
+          destroyed=0
+          kept=0
+          {
+            echo "| Preview | Pull request | Outcome |"
+            echo "| --- | --- | --- |"
+          } >> "${GITHUB_STEP_SUMMARY}"
+
+          apps=$(flyctl apps list --org "${FLY_ORG}" --json | jq -r '.[] | (.Name // .name)')
+
+          while IFS= read -r app; do
+            [ -n "${app}" ] || continue
+            case "${app}" in
+              pr-*"${suffix}") ;;
+              *) continue ;;
+            esac
+
+            pr="${app#pr-}"
+            pr="${pr%"${suffix}"}"
+            case "${pr}" in
+              "" | *[!0-9]*) continue ;;
+            esac
+
+            if body=$(gh api "repos/${REPO}/pulls/${pr}" 2> "${RUNNER_TEMP}/gh-err"); then
+              state=$(printf '%s' "${body}" | jq -r .state)
+              merged=$(printf '%s' "${body}" | jq -r '.merged_at != null')
+              updated=$(printf '%s' "${body}" | jq -r .updated_at)
+            elif grep -q 'HTTP 404' "${RUNNER_TEMP}/gh-err"; then
+              state="missing"
+              merged="false"
+              updated=""
+            else
+              echo "::warning::Could not read PR #${pr}; leaving ${app} alone."
+              cat "${RUNNER_TEMP}/gh-err"
+              kept=$((kept + 1))
+              echo "| ${app} | #${pr} | kept (lookup failed) |" >> "${GITHUB_STEP_SUMMARY}"
+              continue
+            fi
+
+            reason=""
+            case "${state}" in
+              closed)
+                if [ "${merged}" = "true" ]; then
+                  reason="PR merged"
+                else
+                  reason="PR closed"
+                fi
+                ;;
+              missing)
+                reason="PR no longer exists"
+                ;;
+              open)
+                if [ -n "${cutoff}" ] && [ "$(date -u -d "${updated}" +%s)" -lt "${cutoff}" ]; then
+                  reason="PR open but untouched since ${updated%T*}"
+                fi
+                ;;
+            esac
+
+            if [ -z "${reason}" ]; then
+              kept=$((kept + 1))
+              echo "| ${app} | #${pr} | kept (${state}) |" >> "${GITHUB_STEP_SUMMARY}"
+              continue
+            fi
+
+            # Same guard the deploy action uses: never destroy an app whose name
+            # does not carry the PR number we looked up.
+            case "${app}" in
+              *"${pr}"*) ;;
+              *)
+                echo "::error::Refusing to destroy ${app}: name does not contain PR #${pr}."
+                exit 1
+                ;;
+            esac
+
+            if [ "${DRY_RUN}" = "true" ]; then
+              echo "Would destroy ${app} (${reason})."
+              echo "| ${app} | #${pr} | would destroy (${reason}) |" >> "${GITHUB_STEP_SUMMARY}"
+              continue
+            fi
+
+            echo "Destroying ${app} (${reason})."
+            flyctl apps destroy "${app}" --yes
+            destroyed=$((destroyed + 1))
+            echo "| ${app} | #${pr} | destroyed (${reason}) |" >> "${GITHUB_STEP_SUMMARY}"
+          done <<EOF
+          ${apps}
+          EOF
+
+          echo "" >> "${GITHUB_STEP_SUMMARY}"
+          echo "Destroyed ${destroyed}, kept ${kept}." >> "${GITHUB_STEP_SUMMARY}"