name: Preview Teardown

# Tears down the per-PR preview environment (M16-4, #172) that preview.yml stands up, so nothing
# lingers or accrues cost after the PR is done. Runs when a PR to main closes - merged or not - and
# on manual dispatch with a PR number (for a stale preview whose close event was missed), in three
# independent jobs so a failure in one lane never leaves another lane's resources behind:
#
#   teardown-db    - delete the Neon branch database preview/pr-<number>.
#   teardown-api   - delete the Cloud Run service bloom-server-pr-<number> and the Artifact
#     Registry image tag bloom-server:pr-<number> (the TS server preview, #805).
#   teardown-pages - delete the Cloudflare Pages pr-<number> preview deployment(s), across both the
#     dashboard and the docs Pages projects (a matrix job).
#
# Idempotent by construction: every delete is guarded by an existence check first, so re-running
# the workflow - or closing a PR that never had a preview (e.g. preview.yml failed or never ran) -
# is a clean no-op. The guards deliberately check-then-delete rather than swallowing delete errors
# wholesale (`|| true`), so a real failure (auth, quota) still fails the job instead of silently
# orphaning resources. The Neon lane resolves the branch id then deletes it via the Neon REST API
# directly (not neondatabase/delete-branch-action, which needs a root-writable global npm prefix).
#
# The Cloudflare Pages pr-<n> preview deployment(s) are torn down too (teardown-pages). Although
# they are inert static uploads with no compute cost, leaving them accretes stale deployments
# whose pr-<n> alias keeps resolving - a hollow frontend pointing at an already-deleted API, which
# reads as "live" but is not. Cloudflare exposes no bulk/branch delete, so the job lists the
# project's deployments and removes those on the pr-<n> branch via per-deployment REST calls
# (force=true, required to drop an aliased deployment).
#
# Same concurrency group as preview.yml on purpose: a teardown triggered while a preview run is
# still provisioning queues behind it instead of racing it (deleting a service mid-deploy would
# leave the deploy to recreate it, orphaned).
#
# Secrets are the ones preview.yml already uses (NEON_API_KEY, NEON_PROJECT_ID, GCP_SA_KEY,
# GCP_PROJECT_ID, GCP_REGION, GCP_AR_REPO); teardown never needs the DB connection URL.
# See docs/preview-environments.md.

on:
  pull_request:
    branches: [main]
    types: [closed]
  workflow_dispatch:
    inputs:
      pr:
        description: "PR number to tear the preview environment down for"
        required: true
        type: number

concurrency:
  group: preview-${{ github.event.pull_request.number || inputs.pr }}
  cancel-in-progress: false

permissions:
  contents: read

jobs:
  # --- Neon branch database (created by preview.yml's provision job) ---
  teardown-db:
    runs-on: bloom-arc
    timeout-minutes: 10
    steps:
      - name: Compute the branch name
        id: branch
        run: echo "name=preview/pr-${{ github.event.pull_request.number || inputs.pr }}" >> "$GITHUB_OUTPUT"

      # Resolve the branch by name to its id in one read-only list. This both guards the delete (an
      # absent branch yields an empty id and skips the delete as a clean no-op) and supplies the id
      # the REST delete needs - the Neon delete endpoint is keyed by id, not name. If THIS call
      # fails (bad key, wrong project) the job fails loudly rather than silently orphaning. The
      # response holds no connection strings or passwords.
      - name: Check whether the preview branch exists
        id: check
        env:
          NEON_API_KEY: ${{ secrets.NEON_API_KEY }}
          NEON_PROJECT_ID: ${{ secrets.NEON_PROJECT_ID }}
          BRANCH_NAME: ${{ steps.branch.outputs.name }}
        run: |
          set -euo pipefail
          branch_id="$(curl -fsS -H "Authorization: Bearer $NEON_API_KEY" \
            "https://console.neon.tech/api/v2/projects/${NEON_PROJECT_ID}/branches" \
            | jq -r --arg name "$BRANCH_NAME" 'first(.branches[] | select(.name == $name) | .id) // ""')"
          if [ -n "$branch_id" ]; then
            echo "exists=true" >> "$GITHUB_OUTPUT"
            echo "branch_id=$branch_id" >> "$GITHUB_OUTPUT"
          else
            echo "exists=false" >> "$GITHUB_OUTPUT"
            echo "Neon branch '$BRANCH_NAME' does not exist - nothing to tear down"
          fi

      # Delete via the Neon REST API directly (DELETE /branches/{id}), mirroring the check above.
      # We deliberately do NOT use neondatabase/delete-branch-action: it `npm i -g neonctl` into the
      # global prefix, which fails EACCES on our non-root self-hosted/ARC runners (the runner user
      # cannot write /usr/lib/node_modules). REST is root-free and drops the dependency. (#533)
      - name: Delete the Neon preview branch
        if: steps.check.outputs.exists == 'true'
        env:
          NEON_API_KEY: ${{ secrets.NEON_API_KEY }}
          NEON_PROJECT_ID: ${{ secrets.NEON_PROJECT_ID }}
          BRANCH_ID: ${{ steps.check.outputs.branch_id }}
        run: |
          set -euo pipefail
          curl -fsS -X DELETE -H "Authorization: Bearer $NEON_API_KEY" \
            "https://console.neon.tech/api/v2/projects/${NEON_PROJECT_ID}/branches/${BRANCH_ID}" \
            -o /dev/null
          echo "Deleted Neon branch id '$BRANCH_ID'"

  # --- Cloud Run service + Artifact Registry image (created by preview.yml's deploy-server job) ---
  teardown-api:
    runs-on: bloom-arc
    timeout-minutes: 15
    steps:
      # Same GITHUB_ENV hand-off as preview.yml's deploy-server: the image path embeds values stored
      # as repo secrets (project id, region), which log masking covers but job outputs would not.
      - name: Compute the per-PR service name and image reference
        env:
          GCP_PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }}
          GCP_REGION: ${{ secrets.GCP_REGION }}
          GCP_AR_REPO: ${{ secrets.GCP_AR_REPO }}
        run: |
          set -euo pipefail
          pr="${{ github.event.pull_request.number || inputs.pr }}"
          {
            echo "SERVICE_NAME=bloom-server-pr-$pr"
            echo "PREVIEW_IMAGE=${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/${GCP_AR_REPO}/bloom-server:pr-$pr"
          } >> "$GITHUB_ENV"

      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v2
        with:
          credentials_json: ${{ secrets.GCP_SA_KEY }}

      - name: Set up gcloud
        uses: google-github-actions/setup-gcloud@v2

      # Describe-then-delete: a service that is already gone (or never existed) is a clean no-op,
      # while any OTHER describe failure - auth, API disabled, quota - still fails the job, which
      # a bare `delete || true` would swallow.
      - name: Delete the Cloud Run service
        env:
          GCP_REGION: ${{ secrets.GCP_REGION }}
        run: |
          set -euo pipefail
          if out="$(gcloud run services describe "$SERVICE_NAME" --region "$GCP_REGION" 2>&1)"; then
            gcloud run services delete "$SERVICE_NAME" --region "$GCP_REGION" --quiet
            echo "Deleted Cloud Run service $SERVICE_NAME"
          elif grep -qiE 'not ?found|cannot find' <<<"$out"; then
            echo "Cloud Run service $SERVICE_NAME does not exist - nothing to tear down"
          else
            printf '%s\n' "$out" >&2
            exit 1
          fi

      # Same describe-then-delete guard. --delete-tags removes the pr-<n> tag along with the
      # image version it points at, so the preview repo does not accumulate closed PRs' images.
      - name: Delete the Artifact Registry image tag
        run: |
          set -euo pipefail
          if out="$(gcloud artifacts docker images describe "$PREVIEW_IMAGE" 2>&1)"; then
            gcloud artifacts docker images delete "$PREVIEW_IMAGE" --delete-tags --quiet
            echo "Deleted image tag pr-${{ github.event.pull_request.number || inputs.pr }}"
          elif grep -qiE 'not ?found|cannot find' <<<"$out"; then
            echo "Image tag pr-${{ github.event.pull_request.number || inputs.pr }} does not exist - nothing to tear down"
          else
            printf '%s\n' "$out" >&2
            exit 1
          fi

  # --- Cloudflare Pages preview deployment(s) (created by preview.yml's deploy-web + deploy-docs) ---
  # Guarded on CLOUDFLARE_ACCOUNT_ID like the deploy lanes, so a fork/config without Cloudflare is a
  # clean skip rather than a failure. Reuses the same secret/vars preview.yml publishes with. The
  # matrix covers BOTH Pages projects a preview stands up - the dashboard (deploy-web) and the docs
  # site (deploy-docs) - so neither leaks stale pr-<n> deployments; fail-fast is off so one project's
  # failure never leaves the other's deployments behind.
  teardown-pages:
    runs-on: bloom-arc
    timeout-minutes: 10
    if: ${{ vars.CLOUDFLARE_ACCOUNT_ID != '' }}
    strategy:
      fail-fast: false
      matrix:
        project:
          - ${{ vars.CLOUDFLARE_PAGES_PROJECT || 'bloom-dashboard' }}
          - ${{ vars.CLOUDFLARE_DOCS_PAGES_PROJECT || 'bloom-docs' }}
    steps:
      # Collect-then-delete: the paginated list is read-only, so gathering every matching id first
      # keeps enumeration stable (deleting mid-scan would shift later pages). A list failure (bad
      # token, wrong project) fails the job loudly rather than a bare delete||true masking it; an
      # empty match set is a clean no-op (PR never had a preview, or already torn down).
      - name: Delete the Cloudflare Pages preview deployment(s)
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CF_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
          CF_PROJECT: ${{ matrix.project }}
          BRANCH: pr-${{ github.event.pull_request.number || inputs.pr }}
        run: |
          set -euo pipefail
          api="https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/pages/projects/${CF_PROJECT}/deployments"
          ids=""
          page=1
          while : ; do
            resp="$(curl -fsS -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" "${api}?page=${page}&per_page=25")"
            match="$(jq -r --arg b "$BRANCH" \
              '.result[] | select((.deployment_trigger.metadata.branch // "") == $b and .environment == "preview") | .id' <<<"$resp")"
            [ -n "$match" ] && ids="${ids}${match}"$'\n'
            count="$(jq '.result | length' <<<"$resp")"
            [ "$count" -lt 25 ] && break
            page=$((page + 1))
            [ "$page" -gt 40 ] && break
          done
          deleted=0
          for id in $ids; do
            [ -z "$id" ] && continue
            # force=true is required to delete an aliased (pr-<n>) deployment.
            curl -fsS -X DELETE -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" "${api}/${id}?force=true" >/dev/null
            echo "Deleted Pages preview deployment $id ($BRANCH)"
            deleted=$((deleted + 1))
          done
          if [ "$deleted" -eq 0 ]; then
            echo "No Cloudflare Pages preview deployments for $BRANCH - nothing to tear down"
          else
            echo "Removed $deleted Pages preview deployment(s) for $BRANCH"
          fi
