ADR-032: PR Preview Environments
Date: 2026-05-28 (amended 2026-07-01, verified live 2026-07-01)
Status: Accepted — built + live, proven end-to-end (asanexample/alpha-shop#14); see
Implementation Status
Implementation Status
Section titled “Implementation Status”Live and proven. A real PR against alpha-shop (with spec.preview: true on its dev claim)
produced an isolated, auto-cleaned-up preview: image built + signed, ArgoCD generated the
Application, it synced Healthy, was reachable over HTTPS at the designed hostname, was provably
isolated from the stable deployment, and was pruned on PR close. platform/argocd/github-app’s
installation now carries pull_requests: read alongside its existing repo-creds scope.
Before this, most of this pipeline was already built, piece by piece, without anyone tracking that
the last piece — actual delivery — was still missing. This ADR originally described the v2
design: preview was opt-in per app via preview = true in teams.hcl (ADR-031), and the
argocd-apps module built a per-app PR-generator ApplicationSet from tenants / github_org
inputs. That entire surface (tenants, github_org, preview_appset, github_token_secret_name,
teams.hcl) was removed at the v3 cutover (ADR-067/069), which replaced per-tenant delivery
with registry-derived, Release-keyed per-Product ApplicationSets
(gitops/releases/<team>/<product>/*.yaml).
Issue #721 caught the resulting drift (docs describing a shipped feature that didn’t exist) and was closed after fixing the docs — but the actual re-implementation was never done until now. Issue #111 (migrate ArgoCD’s GitHub auth off a PAT) is also fully resolved by this work — both consumers it described (repo-creds and a PR-generator token) now run on the same GitHub App.
Already live on the v3 model, independent of this ADR:
preview.yml(generated by the New Product scaffolder for every product) builds, cosign-signs, and SLSA-attests an image on everypull_request, tagged bygithub.event.pull_request.head.sha.- OIDC trust for PR-triggered builds —
infra/modules/aws/github_oidcalready supports event-shaped subjects (repo:<org>/<repo>:pull_request) viaroles.<name>.events, already set on every per-Product ECR-push role. (This ADR originally asked for agithub_eventsvariable; that request was folded into theroles.*.eventsfield during an unrelated per-team-role refactor, and already does what was asked.) name-reference.yaml(also scaffolder-generated) — the kustomizenameReferenceconfig that rewritesHTTPRoutebackendRefs[].nameunder apr-<N>-namePrefix(issue #155, resolved).- The XEnvironment claim already has a
preview: booleanfield (defaultfalse) — the intended per-environment activation switch. - Kyverno’s
verify-images-product/verify-attestations-productalready carry apreview_subject_regexpslot for admitting PR-preview-built image signatures. - The Crossplane Composition’s
restrict-route-hostnames-<ns>policy already unconditionally allow-lists a wildcard<product>-<team>-<stage>-pr-*.<baseDomain>hostname pattern — PR-preview routes are already pre-approved by admission, no per-PR reconciliation needed. - ArgoCD already authenticates to GitHub via a GitHub App (
platform/argocd/github-app), not a PAT (TD2-02b) — used today for repo-clone credentials.
What closed the gap: the ArgoCD delivery mechanism itself — a pullRequest-generator
ApplicationSet in argocd-apps (infra/modules/argocd-apps/pr-preview.tf) that turns an open PR
into an ephemeral, auto-cleaned-up Application, gated per-product on spec.preview: true on that
product’s dev Environment claim, reusing the already-existing GitHub App credential (its
installation now carries Pull requests: Read-only alongside its existing repo-creds scope)
rather than a separate token.
One real bug found and fixed during live verification: kustomize’s commonLabels transformer
only auto-patches selector + pod-template paths for well-known built-in types (Deployment,
StatefulSet, etc.) — Rollout (an Argo Rollouts CRD, what every workload here actually is, per
ADR-056) only got its top-level metadata.labels patched. Since preview isolation depends on
commonLabels propagating app.kubernetes.io/instance: pr-<N> into both the Service selector
and the Rollout’s pod template, this left the Service with zero endpoints (“no healthy upstream”
at the Gateway) for any product’s first preview. Fixed in each app’s k8s/base/kustomizeconfig.yaml
(and the New Product scaffolder template, so new products don’t hit it) with a commonLabels
FieldSpec extension for Rollout, mirroring the pattern that file already uses for the replicas:
transformer on the same CRD. See the “Kustomization Requirement” section below.
Context
Section titled “Context”Development teams need a way to preview changes from pull requests before merging. A common pattern is to deploy each open PR as an ephemeral environment with its own URL, allowing reviewers to test the change in a running cluster without affecting the stable deployment.
The platform uses ArgoCD for GitOps (ADR-021) with the ApplicationSet controller, which supports a Pull Request generator that polls GitHub for open PRs and creates ephemeral Applications from a template.
The key challenges:
- Label selector collision. A preview Deployment and the stable Deployment must not share selector labels, or their Services/ReplicaSets cross-select each other’s pods.
- HTTPRoute hostname collision. Each preview needs a unique hostname; Gateway API
HTTPRoutebackendRefsare not automatically updated by kustomizenamePrefix. - OIDC trust for PR workflows. PR-triggered GitHub Actions use a different subject claim shape than branch-push workflows. — Already solved, see above.
- Image tagging. ECR tag immutability (ADR-028) requires a unique, non-
:latesttag per image; PR previews use the PR’s head commit SHA. — Already solved, see above.
Alternatives Considered
Section titled “Alternatives Considered”1. Manual preview deployments. Error-prone, namespace sprawl, easily forgotten. Rejected.
2. Argo Rollouts traffic splitting. Designed for progressive delivery of one revision, not concurrent multi-PR previews. Rejected.
3. External preview orchestrator (PullApprove, Uffizzi, etc.). Adds an operational dependency that doesn’t integrate with the existing ArgoCD + Gateway API stack for no clear benefit. Rejected.
4. ArgoCD ApplicationSet with PR generator (chosen). Native ArgoCD capability, no new infrastructure, and (on v3) almost every other piece of the pipeline needed for it already exists.
Decision
Section titled “Decision”Enable PR preview environments using an ArgoCD ApplicationSet with the Pull Request generator.
Preview is opt-in per Environment, via spec.preview: true on a product’s dev
XEnvironment claim (gitops/environments/<team>/<product>/dev.yaml) — not a new claim kind, and
not a re-provisioned environment per PR. A preview deploys into the existing dev namespace
(<team>-<product>-dev), isolated by kustomize namePrefix/commonLabels, because provisioning a
full Crossplane Environment (ResourceQuota, per-product Kyverno policies, Pod Identity, IAM) per PR
would be far too slow and heavy for something meant to appear on PR opened and vanish on
PR closed.
Architecture
Section titled “Architecture”Developer opens PR | vGitHub Actions (preview.yml — already live) - OIDC auth (pull_request event, already trusted) - Build + cosign-sign + SLSA-attest, push to ECR as team-<team>/<product>-<svc>:<head-sha> | vArgoCD ApplicationSet (pullRequest generator — THIS PR) - Auths via the existing platform/argocd/github-app GitHub App - Polls for open PRs on the product's repo - Filtered to Products whose dev Environment has spec.preview: true - Creates an ephemeral Application from a template | vKustomize inline overrides (rendered by the ApplicationSet template) - namePrefix: pr-<N>- - commonLabels: app.kubernetes.io/instance = pr-<N> - images: head-SHA-tagged image (no Release record, no digest promotion) - patches: HTTPRoute hostname -> <product>-<team>-dev-pr-<N>.<baseDomain> (already pre-approved by Kyverno's restrict-route-hostnames) | vKubernetes resources in the EXISTING <team>-<product>-dev namespace - pr-<N>-<deployment>, pr-<N>-<service>, pr-<N>-<httproute> - Isolated by label selectors from the stable dev deploymentLabel Selector Isolation
Section titled “Label Selector Isolation”commonLabels on the ApplicationSet template (app.kubernetes.io/instance: pr-<N>) injects the
label into Deployment spec.selector.matchLabels, spec.template.metadata.labels, and Service
spec.selector — guaranteeing the stable dev Service only selects stable pods, and each preview
Service only selects its own preview pods. Without this, namePrefix renames resources but does
not touch label selectors, and Services cross-select pods from different deployments.
HTTPRoute Patching
Section titled “HTTPRoute Patching”Preview kustomize transforms are injected by the platform’s ApplicationSet template
(infra/modules/argocd-apps/pr-preview.tf), not by files in the app repo. The template sets
namePrefix = "pr-{{.number}}-", commonLabels, an images override to the PR head SHA, and a
hostname patch to the already-Kyverno-allowed pattern:
patches: - target: { kind: HTTPRoute } patch: | - op: replace path: /spec/hostnames/0 value: <product>-<team>-dev-pr-<N>.<baseDomain>HTTPRoute backendRef rewriting (issue #155, already resolved). Kustomize’s built-in
nameReference transformer doesn’t know about Gateway API HTTPRoute, so namePrefix alone would
rename the Service but leave HTTPRoute.spec.rules[].backendRefs[].name pointing at the
un-prefixed (stable) Service — silently routing preview traffic to stable pods. The fix, already
generated for every product by the New Product scaffolder
(scaffolder/templates/new-product/skeleton/k8s/base/name-reference.yaml), teaches kustomize that
an HTTPRoute backendRef references a Service, so the same rename rewrites the backendRef too.
This lives in the app repo (not the ApplicationSet template) because ArgoCD’s source.kustomize
exposes no field to inject transformer configurations.
OIDC Trust Policy — already done
Section titled “OIDC Trust Policy — already done”infra/modules/aws/github_oidc supports event-shaped subject claims via roles.<name>.events;
events = ["pull_request"] is already set on every per-Product ECR-push role in the live platform
unit. No change needed here.
Preview Lifecycle
Section titled “Preview Lifecycle”| Event | Action |
|---|---|
| PR opened | ApplicationSet creates Application, ArgoCD syncs resources |
| PR updated (push) | preview.yml rebuilds image with new SHA, ArgoCD syncs the updated tag |
| PR closed/merged | ApplicationSet deletes Application, ArgoCD prunes all resources |
Cleanup is automatic — prune = true in the sync policy.
GitHub Actions Workflows — already scaffolded
Section titled “GitHub Actions Workflows — already scaffolded”Every product gets both deploy.yml (push to main → stable delivery via the Release/promote flow)
and preview.yml (pull_request → build+sign+push a head-SHA-tagged image, no manifest/Release
write) from the New Product scaffolder today.
GitHub Credential — reuse the existing App, don’t mint a new one
Section titled “GitHub Credential — reuse the existing App, don’t mint a new one”ArgoCD already authenticates to GitHub as a GitHub App (platform/argocd/github-app; App ID +
installation ID + private key in Secrets Manager, projected via External Secrets into
github-asanexample-app-creds), used today for repo-clone credentials
(credential_templates/repo-creds). The pullRequest generator needs the same kind of
credential — GitHub API read access to list open PRs — so it reuses this same App rather than a
separate token, via ArgoCD’s App-based SCM/generator auth (appSecretName, not tokenRef). This
requires the App’s installation to carry Pull requests: Read-only in addition to its existing
Contents/Metadata read scope — a manual, one-time permission addition on the existing App (not a
new App), verified/applied outside this PR.
Kustomization Requirement — already scaffolded
Section titled “Kustomization Requirement — already scaffolded”App repos need a kustomization.yaml (already scaffolded) listing all resources, and two
configurations: entries (both already scaffolded):
name-reference.yaml— rewritesHTTPRoutebackendRefs[].nameunder thepr-<N>-namePrefix(issue #155).kustomizeconfig.yaml’scommonLabelsFieldSpecs forRollout— extends thenamePrefixApplicationSet template’scommonLabels(app.kubernetes.io/instance: pr-<N>) intospec.selector.matchLabelsandspec.template.metadata.labels, which kustomize doesn’t do automatically for a CRD. Without it, the preview Service has zero endpoints — found live verifying the first real preview (asanexample/alpha-shop#14), fixed inasanexample/alpha-shop#15and the scaffolder template.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- PR previews are fully automated — developers only need to open a PR.
- Cleanup is automatic — no orphaned preview resources (native
prune). - Previews share the
devEnvironment’s existing namespace and resource quota — no namespace sprawl, no per-PR Crossplane provisioning latency. - The stable
devdeployment is protected via label selector isolation. - No new infrastructure, no new GitHub App, no new credential shape — every other piece of this pipeline (image build/sign, OIDC trust, hostname allow-listing, backendRef rewriting) was already built independently before this ADR was revisited.
Negative
Section titled “Negative”commonLabelson the stabledevApplication also gets anapp.kubernetes.io/instance: stablelabel — semantically correct, but a visible change from unlabeled raw manifests.- The PR generator polls the GitHub API on an interval; API-side latency, not a blocker.
- Preview routing correctness depends on the per-app
name-reference.yaml. Already scaffolded and CI-guarded (issue #155) for new products; a hand-rolled app repo that skips scaffolding and forgets it would silently route previews to the stable Service. - Fork PRs cannot push images. GitHub blocks
id-token: writeon fork PRs by default, sopreview.ymlcan’t authenticate to ECR from a fork. The ApplicationSet will still create an Application, which fails to sync (no image tag exists) — safe (no resources created) but may confuse external contributors. - PR image accumulation. Each PR push creates a new ECR tag; the ECR lifecycle policy (50 images, ADR-028) could evict older tags under high PR velocity.
- Resource exhaustion from concurrent PRs. Each preview adds pods against the
devEnvironment’s existing ResourceQuota — high PR volume on one product may need a quota bump. - Per-team ECR push scope is unaffected. Push access is still one OIDC role per
team/product (
github-actions-ecr-push-<team>-<product>), scoped to that product’steam-<team>/<product>-*repositories — a compromised PR workflow for one product still can’t push to another’s images.