# GitHub Actions: PR Preview Environments

Give every pull request its own Zuplo environment. Reviewers can test changes
against a live API, and environments clean up automatically when PRs close.

:::caution{title="Pass --environment on pull_request events"}

Workflows triggered by `pull_request` check out the pull request **merge ref**
(`refs/pull/<number>/merge`), not your branch. Without `--environment`, the
Zuplo CLI derives the environment name from that ref and creates an environment
named `pull/<number>/merge` instead of one named after your branch. If anything
else deploys the same branch — a `push`-triggered workflow, the GitHub
integration, or a local `zuplo deploy` — you end up with two environments and
two different URLs for the same branch. Always pass `--environment` with the
source branch name (`github.head_ref`).

:::

```yaml title=".github/workflows/pr-workflow.yaml"
name: PR Workflow

on:
  pull_request:
    types: [opened, synchronize, reopened, closed]

# Runs for the same branch share one queue, so rapid pushes don't race
# concurrent deploys into the same environment
concurrency:
  group: zuplo-preview-${{ github.head_ref }}
  cancel-in-progress: true

jobs:
  deploy-and-test:
    # Run on PR open/update, not on close
    if: github.event.action != 'closed'
    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: Deploy to Zuplo
        id: deploy
        shell: bash
        env:
          # The PR's source branch — not the pull/<number>/merge ref that
          # pull_request events check out
          ENVIRONMENT: ${{ github.head_ref }}
        run: |
          OUTPUT=$(npx zuplo deploy --api-key "$ZUPLO_API_KEY" --environment "$ENVIRONMENT" 2>&1)
          echo "$OUTPUT"
          DEPLOYMENT_URL=$(echo "$OUTPUT" | grep -oP 'Deployed to \K(https://[^ ]+)')
          echo "url=$DEPLOYMENT_URL" >> $GITHUB_OUTPUT

      - name: Run tests
        run: npx zuplo test --endpoint "${{ steps.deploy.outputs.url }}"

      - name: Comment PR with deployment URL
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `🚀 Deployed to: ${{ steps.deploy.outputs.url }}`
            })

  cleanup:
    # Only run when PR is closed (merged or not)
    if: github.event.action == 'closed'
    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: Get deployment URL
        id: get-url
        uses: actions/github-script@v7
        with:
          script: |
            const comments = await github.rest.issues.listComments({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
            });
            const match = comments.data
              .map((c) => c.body.match(/Deployed to: (https:\/\/\S+)/))
              .find(Boolean);
            core.setOutput("url", match ? match[1] : "");

      - name: Delete environment
        if: steps.get-url.outputs.url != ''
        run: |
          npx zuplo delete \
            --url "${{ steps.get-url.outputs.url }}" \
            --api-key "$ZUPLO_API_KEY" \
            --wait
```

This workflow:

1. **On PR open/update**: Deploys to an environment named after the branch, runs
   tests, and comments the URL on the PR
2. **On PR close**: Deletes the preview environment

## How It Works

- The deploy step passes `--environment` with the PR's source branch name
  (`github.head_ref`), so the environment is named after the branch — the same
  name the [GitHub integration](../source-control-setup-github.mdx) would use
- Each push to the PR updates the same environment, so the environment URL stays
  stable for the life of the branch
- Closing the PR (merge or abandon) triggers cleanup
- The PR comment lets reviewers quickly access the preview

A stable environment URL matters whenever an external system references it
exactly — an OIDC token audience, a webhook registration, or an IP/URL
allowlist. Capture the URL from the deploy output rather than constructing it
from the branch name: the URL hostname uses a normalized, truncated form of the
environment name plus a unique identifier (see
[Branch-Based Deployments](../branch-based-deployments.mdx)).

## Next Steps

- Add [automatic cleanup on branch delete](./cleanup-on-branch-delete.mdx) as a
  backup
- Implement [multi-stage deployment](./multi-stage-deployment.mdx) for
  production releases
