Give every pull request its own Zuplo environment. Reviewers can test changes
against a live API, and environments clean up automatically when PRs close.
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).
.github/workflows/pr-workflow.yaml
name: PR Workflowon: 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 environmentconcurrency: group: zuplo-preview-${{ github.head_ref }} cancel-in-progress: truejobs: 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: 20 - 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: 20 - 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:
On PR open/update: Deploys to an environment named after the branch, runs
tests, and comments the URL on the PR
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 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).