# GitHub Actions: Automatic Cleanup

Delete Zuplo environments automatically when branches are deleted. This keeps
your environment list clean and avoids accumulating unused preview environments.

```yaml title=".github/workflows/cleanup-on-branch-delete.yaml"
name: Cleanup on Branch Delete

on:
  delete:

jobs:
  cleanup:
    # Only run for branch deletions, not tag deletions
    if: github.event.ref_type == 'branch'
    runs-on: ubuntu-latest
    env:
      ZUPLO_API_KEY: ${{ secrets.ZUPLO_API_KEY }}

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 24

      - name: Install dependencies
        run: npm install

      - name: Delete environment
        run: |
          # The deleted branch name
          BRANCH_NAME="${{ github.event.ref }}"
          # Deployment names use the format {project}-{branch}-{hash}, where
          # the branch segment is normalized (lowercase, special characters
          # replaced by hyphens) and truncated to 10 characters
          ENV_NAME=$(echo "$BRANCH_NAME" | tr '/_' '--' |
            tr '[:upper:]' '[:lower:]' | cut -c1-10 | sed 's/-*$//')

          # Find the deployment for the deleted branch and delete it by URL
          npx zuplo list --api-key "$ZUPLO_API_KEY" \
            --output json --show-details |
            jq -r --arg env "$ENV_NAME" \
              '.[] | select(.name | test("-" + $env + "-[a-z0-9]+$")) | .url' |
            while read -r URL; do
              echo "Deleting environment: $URL"
              npx zuplo delete --url "$URL" --api-key "$ZUPLO_API_KEY" --wait || true
            done
```

This workflow:

1. Triggers when any branch is deleted
2. Converts the branch name to the deployment-name branch segment format
3. Looks up the matching deployment and deletes it by URL
4. Continues without error if no matching environment exists

## Combining with PR Cleanup

Use this as a backup for
[PR preview environments](./pr-preview-environments.mdx). The PR workflow
handles cleanup when PRs close, but this catches cases where:

- Someone deletes a branch without closing the PR first
- A branch was pushed but never had a PR opened
- The PR cleanup job failed

## Scheduled Cleanup

For additional safety, run periodic cleanup to catch any orphaned environments:

```yaml title=".github/workflows/scheduled-cleanup.yaml"
name: Scheduled Cleanup

on:
  schedule:
    # Run daily at midnight UTC
    - cron: "0 0 * * *"

jobs:
  cleanup:
    runs-on: ubuntu-latest
    env:
      ZUPLO_API_KEY: ${{ secrets.ZUPLO_API_KEY }}

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0 # Fetch all branches

      - uses: actions/setup-node@v4
        with:
          node-version: 24

      - name: Install dependencies
        run: npm install

      - name: Cleanup stale environments
        run: |
          # Get all remote branches, converted to the deployment-name branch
          # segment format: lowercase, special characters replaced by hyphens,
          # truncated to 10 characters
          BRANCHES=$(git branch -r | sed 's|origin/||' | tr -d ' ' |
            tr '/_' '--' | tr '[:upper:]' '[:lower:]' |
            cut -c1-10 | sed 's/-*$//')

          # List deployed environments as JSON. Each entry has the shape
          # {"projectName": "...", "name": "...", "url": "..."}
          npx zuplo list --api-key "$ZUPLO_API_KEY" \
            --output json --show-details > environments.json

          # Deployment names follow the format {project}-{branch}-{hash}
          jq -r '.[] | "\(.name) \(.url)"' environments.json |
            while read -r NAME URL; do
              # Skip protected environments
              case "$NAME" in
                *-main-* | *-production-* | *-staging-*) continue ;;
              esac

              # Delete only if no branch matches the deployment name
              STALE=true
              for BRANCH in $BRANCHES; do
                if [[ "$NAME" == *"-$BRANCH-"* ]]; then
                  STALE=false
                  break
                fi
              done

              if [[ "$STALE" == "true" ]]; then
                echo "Deleting stale environment: $NAME"
                npx zuplo delete --url "$URL" --api-key "$ZUPLO_API_KEY" --wait || true
              fi
            done
```

## Next Steps

- Set up [PR preview environments](./pr-preview-environments.mdx) with built-in
  cleanup
- Implement [multi-stage deployment](./multi-stage-deployment.mdx) for
  production
