How Per-PR Preview Environments Compress QA Cycles
An engineering playbook for deterministic preview infrastructure with concurrency guards, namespace isolation, cleanup policies, and measurable lead-time impact.
How Preview Environments Actually Reduce Lead Time
Per-PR previews are valuable only if they are deterministic, isolated, and disposable. The contract is simple: every pull request gets a reproducible environment URL tied to commit SHA.
This removes review ambiguity across QA, design, and product, and catches integration defects before merge rather than after deployment.
Platform contract
- Namespace: one namespace per PR (e.g., preview-pr-124).
- Image: built from exact commit SHA under review.
- Config: branch-specific overrides but production-like integrations where safe.
- Lifecycle: auto-cleanup on PR close + TTL cleanup for orphaned resources.
GitHub Actions Workflow With Concurrency Control
Concurrency guards prevent stale commits from racing newer deploys. Cancel in-progress runs for the same PR number.
name: preview-environment
on:
pull_request:
types: [opened, synchronize, reopened, closed]
concurrency:
group: preview-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
deploy:
if: github.event.action != 'closed'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: echo "NAMESPACE=preview-pr-${{ github.event.number }}" >> $GITHUB_ENV
- run: echo "IMAGE_TAG=sha-${{ github.sha }}" >> $GITHUB_ENV
- run: ./scripts/deploy-preview.sh "$NAMESPACE" "$IMAGE_TAG"
cleanup:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- run: ./scripts/delete-preview.sh "preview-pr-${{ github.event.number }}"Deployment Script: Reproducibility and Secrets
Keep deploy scripts idempotent. Namespace creation should be safe on repeated execution; secret propagation should be explicit and auditable.
#!/usr/bin/env bash
set -euo pipefail
NAMESPACE="$1"
IMAGE_TAG="$2"
kubectl create namespace "$NAMESPACE" --dry-run=client -o yaml | kubectl apply -f -
# Copy shared readonly secrets into the preview namespace.
for secret in app-config observability-token; do
kubectl get secret "$secret" -n platform -o yaml | sed "s/namespace: platform/namespace: $NAMESPACE/" | kubectl apply -f -
done
helm upgrade --install app ./helm/app --namespace "$NAMESPACE" --set image.tag="$IMAGE_TAG" --set ingress.host="${NAMESPACE}.preview.govindio.com" --set env.NODE_ENV="preview"Cost and Drift Control
Preview environments become expensive without cleanup guarantees. Add a daily janitor job that deletes stale namespaces by PR status and max age.
apiVersion: batch/v1
kind: CronJob
metadata:
name: preview-janitor
spec:
schedule: "15 2 * * *"
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: janitor
image: ghcr.io/org/preview-janitor:latest
args:
- "--prefix=preview-pr-"
- "--max-age-hours=96"
- "--delete-if-pr-closed=true"Delivery metric
Track cycle time from PR open to merge. The point of previews is measurable lead-time reduction, not just nicer demos in pull request comments.