From b75da182b75f5e910fe2c7e6a01c3b5298832257 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 25 Jul 2026 06:09:06 -0700 Subject: [PATCH] ops(ci): sweep closed-PR preview environments daily ui-preview.yml / ui-preview-deploy.yml (via the shared deploy-ui-preview composite) create a "preview/pr-" GitHub Environment via createDeployment the first time a PR gets a preview build -- transient_environment: true is only a display hint, it never triggers deletion, and nothing else in the repo ever calls the delete-environment API. Confirmed live: 1254 of 1260 environments were preview/pr-* entries for already-closed PRs (spanning PR #4140-#8617), none carrying protection rules -- cleaned up directly via the API as a one-time sweep before adding this workflow. Mirrors cache-cleanup.yml's existing fix for the identical class of problem (GHA caches instead of environments): batched on a daily schedule, not a pull_request:[closed] trigger, so PR-close bursts never queue a runner per event and compete with real CI. No storage-budget urgency here unlike the 10GB cache cap, so daily is enough. Needs a new ENVIRONMENT_ADMIN_TOKEN secret -- deleting an environment requires repo scope (classic PAT) or Administration:write (fine-grained PAT); GITHUB_TOKEN can never be granted this via a permissions: block, confirmed against GitHub's own docs. --- .github/workflows/preview-env-cleanup.yml | 88 +++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 .github/workflows/preview-env-cleanup.yml diff --git a/.github/workflows/preview-env-cleanup.yml b/.github/workflows/preview-env-cleanup.yml new file mode 100644 index 000000000..e9d09d993 --- /dev/null +++ b/.github/workflows/preview-env-cleanup.yml @@ -0,0 +1,88 @@ +name: Clean up closed-PR preview environments + +# Every UI preview deploy (ui-preview.yml / ui-preview-deploy.yml, via the shared +# .github/actions/deploy-ui-preview composite) records a GitHub Deployment against a +# `preview/pr-` environment. GitHub auto-creates that Environment object the first time a +# deployment targets a name that doesn't exist yet -- `transient_environment: true` on those calls +# is only a display hint for the Deployments UI, it does NOT trigger any automatic deletion. Nothing +# else in this repo ever calls the "delete an environment" API, so every PR that ever got a preview +# deploy leaves a permanent, empty Environment behind once it closes. Confirmed live before adding +# this workflow: 1254 of this repo's 1260 environments were `preview/pr-*` entries for already-closed +# PRs (spanning PR #4140 through #8617), none carrying any protection rules -- pure disposable +# deploy-tracking metadata with no reason to persist. +# +# BATCHED, not per-close-event (same fix cache-cleanup.yml already applied to the identical class of +# problem for GHA caches): a `pull_request: [closed]` trigger would queue one runner per closed PR, +# competing with real CI for the org's concurrent-runner cap during exactly the bursts when CI is +# busiest. Environments carry no storage-budget pressure the way the 10GB cache cap did, so there is +# no urgency case for anything faster than once a day -- one flat daily sweep, independent of PR +# volume, keeps the environments list clean without ever fighting CI for runners. +# +# Requires the ENVIRONMENT_ADMIN_TOKEN secret: deleting an environment needs a token with `repo`-scope +# (classic PAT) or "Administration: write" (fine-grained PAT) -- confirmed the default GITHUB_TOKEN +# can NEVER be granted this via a `permissions:` block, regardless of what's declared here, so a +# dedicated PAT is unavoidable (unlike cache deletion, which GITHUB_TOKEN's own `actions: write` +# already covers). Scope a fine-grained PAT to THIS REPO ONLY with "Administration: write" -- nothing +# else in this workflow needs write access to anything. + +on: + schedule: + - cron: "20 7 * * *" + workflow_dispatch: + +permissions: + pull-requests: read + +concurrency: + group: preview-env-cleanup + cancel-in-progress: false + +jobs: + cleanup: + name: Delete closed PRs' preview environments + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check admin token + id: cfg + env: + ENVIRONMENT_ADMIN_TOKEN: ${{ secrets.ENVIRONMENT_ADMIN_TOKEN }} + run: | + if [ -n "$ENVIRONMENT_ADMIN_TOKEN" ]; then + echo "ready=true" >> "$GITHUB_OUTPUT" + else + echo "ready=false" >> "$GITHUB_OUTPUT" + echo "::notice::ENVIRONMENT_ADMIN_TOKEN not set -- skipping preview environment cleanup." + fi + + # PR state still checked per unique PR number (never inferred from anything else) so an idle- + # but-open PR never loses its active preview environment. Every `preview/pr-*` environment is + # equally disposable regardless of which of the three call sites created it. + - name: Sweep preview environments scoped to closed PRs + if: steps.cfg.outputs.ready == 'true' + env: + GH_TOKEN: ${{ secrets.ENVIRONMENT_ADMIN_TOKEN }} + run: | + set -euo pipefail + gh api "repos/${{ github.repository }}/environments?per_page=100" --paginate \ + --jq '.environments[].name | select(test("^preview/pr-[0-9]+$"))' > envs.txt || true + if [ ! -s envs.txt ]; then + echo "No preview/pr-* environments found." + exit 0 + fi + deleted=0 + skipped=0 + while read -r env_name; do + pr="${env_name#preview/pr-}" + state=$(gh api "repos/${{ github.repository }}/pulls/$pr" --jq .state 2>/dev/null || echo "unknown") + if [ "$state" != "closed" ]; then + skipped=$((skipped + 1)) + continue + fi + echo "Deleting environment $env_name (PR #$pr closed)" + encoded="${env_name//\//%2F}" + gh api -X DELETE "repos/${{ github.repository }}/environments/$encoded" \ + || echo "::warning::Failed to delete $env_name (may already be gone)" + deleted=$((deleted + 1)) + done < envs.txt + echo "Swept $deleted preview environment(s); $skipped still-open PR(s) left untouched."