diff --git a/.github/workflows/pr-preview-reaper.yml b/.github/workflows/pr-preview-reaper.yml new file mode 100644 index 00000000..b96ddbb7 --- /dev/null +++ b/.github/workflows/pr-preview-reaper.yml @@ -0,0 +1,169 @@ +name: Reap stale PR previews + +on: + schedule: + - cron: "17 3 * * *" + workflow_dispatch: + inputs: + dry_run: + description: Report stale previews without changing gh-pages + required: false + default: false + type: boolean + +permissions: + contents: write + pull-requests: read + +concurrency: + group: pr-preview-reaper + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + reap: + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + DRY_RUN: ${{ inputs.dry_run || false }} + steps: + - name: Check out gh-pages + uses: actions/checkout@v7 + with: + ref: gh-pages + fetch-depth: 1 + token: ${{ github.token }} + + - name: Remove previews for closed or missing pull requests + run: | + set -Eeuo pipefail + + preview_root='pr-preview' + if [[ ! -d "$preview_root" ]]; then + echo "No $preview_root/ directory exists; would delete: none." + exit 0 + fi + if [[ -L "$preview_root" ]]; then + echo '::error::Refusing to operate on a symlinked pr-preview/ directory.' + exit 1 + fi + + root_realpath="$(realpath -- "$preview_root")" + removals=() + shopt -s nullglob + + for preview_path in "$preview_root"/pr-*; do + [[ -d "$preview_path" ]] || continue + + preview_name="${preview_path##*/}" + if [[ ! "$preview_name" =~ ^pr-([0-9]+)$ ]]; then + echo "::warning::Ignoring unexpected preview path: $preview_path" + continue + fi + pr_number="${BASH_REMATCH[1]}" + + response_file="$(mktemp)" + api_exit=0 + gh api --include --silent "repos/$REPOSITORY/pulls/$pr_number" >"$response_file" || api_exit=$? + status_code="$(awk '/^HTTP\/[^ ]+ / { code=$2 } END { print code }' "$response_file")" + + if (( api_exit == 0 )); then + if [[ "$status_code" != '200' ]]; then + rm -f -- "$response_file" + echo "::error::GitHub API returned HTTP $status_code for PR #$pr_number" + exit 1 + fi + state="$(awk 'body || /^\{/ { body=1 } body' "$response_file" | jq -r '.state // empty')" + if [[ -z "$state" ]]; then + rm -f -- "$response_file" + echo "::error::GitHub API returned no pull request state for PR #$pr_number" + exit 1 + fi + elif [[ "$status_code" == '404' ]]; then + state='missing' + else + rm -f -- "$response_file" + echo "::error::Could not read PR #$pr_number from the GitHub API (HTTP ${status_code:-unknown})" + exit 1 + fi + rm -f -- "$response_file" + + case "$state" in + open) + echo "Keeping $preview_path because PR #$pr_number is open." + ;; + closed|missing) + echo "Stale preview: $preview_path (PR #$pr_number is $state)." + removals+=("$preview_path") + ;; + *) + echo "::error::Unexpected state '$state' for PR #$pr_number" + exit 1 + ;; + esac + done + + guard_preview_path() { + local candidate="$1" + local candidate_realpath + + if [[ ! "$candidate" =~ ^pr-preview/pr-[0-9]+$ ]]; then + echo "::error::Refusing to delete outside pr-preview/: $candidate" + return 1 + fi + if [[ -L "$candidate" ]]; then + echo "::error::Refusing to delete symlinked preview: $candidate" + return 1 + fi + + candidate_realpath="$(realpath -- "$candidate")" + case "$candidate_realpath" in + "$root_realpath"/pr-*) + ;; + *) + echo "::error::Refusing to delete outside pr-preview/: $candidate" + return 1 + ;; + esac + if [[ ! "$(basename -- "$candidate_realpath")" =~ ^pr-[0-9]+$ ]]; then + echo "::error::Refusing to delete outside pr-preview/: $candidate" + return 1 + fi + } + + if [[ "$DRY_RUN" == 'true' ]]; then + echo 'Dry run requested; no files or commits will be changed.' + if (( ${#removals[@]} == 0 )); then + echo 'WOULD DELETE: none' + else + for preview_path in "${removals[@]}"; do + guard_preview_path "$preview_path" + echo "WOULD DELETE $preview_path" + done + fi + exit 0 + fi + + if (( ${#removals[@]} == 0 )); then + echo 'No stale PR previews found; no commit created.' + exit 0 + fi + + for preview_path in "${removals[@]}"; do + guard_preview_path "$preview_path" + git rm -r -- "$preview_path" + done + + if git diff --cached --quiet; then + echo '::error::Stale previews were found, but no deletion was staged.' + exit 1 + fi + + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git commit -m 'Remove stale PR previews' + git push origin HEAD:gh-pages diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 72ae7774..74d4a43d 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -1,5 +1,9 @@ name: Deploy PR previews +# rossjrw/pr-preview-action documents cleanup on the normal pull_request +# closed event. Keep this workflow on pull_request because it builds PR code; +# pull_request_target would expose its write-capable token to that build. +# Deleted-head cleanup is handled by the trusted scheduled reaper. on: pull_request: branches: diff --git a/README.md b/README.md index 8bc5548a..d160cb03 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,15 @@ hugo --gc --minify # production build into ./public - **PR previews** — builds each pull request targeting `main` and publishes it at `https://isaaclins.com/pr-preview/pr-/`. The pull request preview workflow uses `rossjrw/pr-preview-action` and removes the preview when the - pull request closes. + pull request closes while its head branch is still available. +- **PR preview reaper** — `.github/workflows/pr-preview-reaper.yml` runs daily + and can also be started with `workflow_dispatch`. It checks every + `pr-preview/pr-/` directory against the pull request API and removes + previews for closed or missing pull requests. It checks out only `gh-pages`, + never PR code, and makes one cleanup commit only when there is something to + remove. The action documentation covers the normal `pull_request` close + event, not `pull_request_target`, so the reaper is the safe fallback for a + deleted head branch. - GitHub Pages must be set to **Deploy from a branch**, using `gh-pages` and the repository root. The site must keep [`static/CNAME`](static/CNAME) with the exact contents `isaaclins.com`; Hugo copies it to `public/CNAME` so branch