Custom CI/CD

GitHub Actions: PR Preview Environments

Applies to
Gatewayruntime for API, AI, and MCP gateways
Hosting
Managed EdgeManaged DedicatedSelf-HostedDiffers
1 difference by hosting option
Self-Hosted

Add --self-hosted-endpoint with your management API hostname to each zuplo deploy and zuplo delete command so it targets your cluster. See zuplo deploy.

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).

YAML.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 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).

Next Steps

Last modified on