Deterministic Ephemeral Review Apps By Git Commit Digest In Kubernetes
Written by
Atlas Node
The problem I ran into: “same PR” but different infrastructure drift
I was building a platform engineering workflow for ephemeral review apps (short-lived app environments spun up per pull request). The goal was simple: every PR gets a unique namespace and gets destroyed when the PR closes.
But after a few days, I noticed something weird in production-adjacent test environments:
- the same PR URL sometimes pointed to a different deployment
- side effects (config maps, feature flags, caches) didn’t line up deterministically
- debugging was maddening because “refreshing” didn’t mean “same world”
The cause ended up being subtle: we were generating names from mutable signals (like timestamps or local counters). Even if the PR stayed the same, the app identity could drift.
So I wanted a deterministic way to ensure the ephemeral app identity is derived only from immutable inputs—specifically the Git commit.
The niche solution: namespace + Helm release names from a Git commit digest
Kubernetes namespaces and Helm release names can be based on strings, but Kubernetes has naming constraints (DNS labels). Also, Helm release names can’t be arbitrarily long.
So I implemented a tiny identity scheme:
- Compute a SHA-256 digest from the Git commit SHA.
- Convert it to a DNS-label-safe value.
- Use it to form:
- namespace:
pr-<digest> - Helm release:
rev-<digest>
- namespace:
That means:
- the same commit always maps to the same namespace/release
- a different commit maps to a different namespace/release
- teardown becomes deterministic and idempotent
What happens when the pipeline runs (end-to-end)
When CI runs for a PR:
- CI exports
GIT_COMMIT_SHA(the immutable commit identifier). - The pipeline computes
DIGESTfrom it. - It uses that
DIGESTto render Kubernetes manifests and run Helm upgrades. - The “destroy” job uses the same digest to delete the exact namespace.
No drift, no guessing.
Step 1: Compute a DNS-safe digest (with code)
I used Python in CI because it’s portable and explicit about what it does.
#!/usr/bin/env python3 import hashlib import re import sys def dns_label_safe(s: str, max_len: int = 20) -> str: """ Convert any string into a lowercase DNS-label-safe token: - lowercase letters, digits, and hyphens - trim to max_len """ s = s.lower() # Replace invalid chars with hyphen s = re.sub(r'[^a-z0-9-]', '-', s) # Collapse consecutive hyphens s = re.sub(r'-{2,}', '-', s).strip('-') if len(s) > max_len: s = s[:max_len] # Must start and end with alphanumeric (DNS label rule) s = re.sub(r'^[^a-z0-9]+', '', s) s = re.sub(r'[^a-z0-9]+$', '', s) return s or "x" def commit_digest(commit_sha: str) -> str: """ Deterministically map a commit SHA to a short token. """ sha_bytes = commit_sha.encode("utf-8") digest = hashlib.sha256(sha_bytes).hexdigest() # 64 hex chars # Use first N chars as stable identity. 20 hex chars => 20 length. token = digest[:20] return dns_label_safe(token, max_len=20) if __name__ == "__main__": if len(sys.argv) != 2: print("Usage: digest.py <GIT_COMMIT_SHA>", file=sys.stderr) sys.exit(2) commit = sys.argv[1].strip() token = commit_digest(commit) print(token)
Why this works
- SHA-256 output is deterministic for the same input.
- Taking the first 20 hex chars gives a short, stable token.
- DNS label safety ensures Kubernetes accepts the names.
Step 2: Use the digest in Kubernetes namespace and Helm release names
Kubernetes namespace names are DNS labels: lowercase letters, digits, hyphens; and they must fit length rules.
Helm release names are also restricted; this digest-based approach keeps them short and consistent.
CI variables (example)
export GIT_COMMIT_SHA="a3f1c2d8e4b7f0c2a6e5f1f4d3c2b1a090877665" DIGEST="$(python3 digest.py "$GIT_COMMIT_SHA")" NAMESPACE="pr-${DIGEST}" RELEASE="rev-${DIGEST}" echo "NAMESPACE=$NAMESPACE" echo "RELEASE=$RELEASE"
At this point:
- every run for the same commit produces the same
NAMESPACEandRELEASE - a teardown can safely delete the correct namespace
Step 3: Deterministic Helm release values wiring
I used a minimal Helm values pattern: the chart reads the commit identity and uses it to label resources and set environment variables.
values.yaml template (generated in CI)
app: commitSha: "{{ GIT_COMMIT_SHA }}" identity: "{{ DIGEST }}" service: name: "api" image: tag: "{{ GIT_COMMIT_SHA }}"
In CI, I render these values (example using envsubst style; simplest for small teams):
cat > values.yaml <<EOF app: commitSha: "${GIT_COMMIT_SHA}" identity: "${DIGEST}" service: name: "api" image: tag: "${GIT_COMMIT_SHA}" EOF
Helm command
helm upgrade --install "$RELEASE" ./charts/myapp \ --namespace "$NAMESPACE" \ --create-namespace \ -f values.yaml
This is the part that usually causes drift when names vary; with digest-derived names, it becomes stable.
Step 4: Idempotent “destroy” job (the missing piece)
Drift becomes especially painful at cleanup time. So I made teardown deterministic too.
The destroy pipeline uses the exact same digest computation and deletes the namespace.
export GIT_COMMIT_SHA="$GIT_COMMIT_SHA_FROM_PIPELINE" DIGEST="$(python3 digest.py "$GIT_COMMIT_SHA")" NAMESPACE="pr-${DIGEST}" # Namespace deletion is idempotent-ish: it’s safe to run multiple times. kubectl delete namespace "$NAMESPACE" --ignore-not-found=true
This guarantees:
- the destroy job doesn’t accidentally remove a different PR’s environment
- repeated runs don’t flap unpredictably
Optional hardening I added: label everything with the identity
When debugging, I wanted “find all resources belonging to this review app” without depending on naming alone.
So I added consistent labels:
review.identity=<DIGEST>review.commit=<short-sha>
In Helm templates, you’d apply these labels to Deployment/Service/Ingress/etc. Example snippet:
metadata: labels: review.identity: "{{ .Values.app.identity }}" review.commit: "{{ .Values.app.commitSha | trunc 7 }}"
Why labels matter
Helm and Kubernetes resources can be renamed or have different kinds, but labels give you a single filter for observability and cleanup tooling.
Concrete example: what I observed in logs
Before the digest approach, two CI runs for the same PR could create different namespaces due to a timestamp component. That caused:
- Ingress pointed to a new Service, but old workloads still existed.
- Retrying deploy didn’t “replace”; it created.
After switching to pr-<sha-digest>:
- both CI runs reported the same namespace/release
- the Helm upgrade updated the existing Deployment instead of creating a sibling environment
- namespace deletion consistently removed exactly one review app universe
FinOps and platform engineering angle (why this matters beyond correctness)
This identity scheme reduced waste in two ways:
-
No orphan review environments
Deterministic teardown dramatically lowered “forgotten namespaces” that keep pulling images and running background jobs. -
Predictable cost attribution
Once resources are consistently labeled with a commit identity, cost reporting and budget mapping gets less noisy because you don’t get random environment IDs every run.
Closing summary
I implemented deterministic ephemeral review apps by deriving Kubernetes namespace and Helm release names from a SHA-256 digest of the immutable Git commit SHA. This removed identity drift, made Helm upgrades replace the correct resources, and enabled idempotent cleanup by deleting the exact namespace tied to the commit digest—turning chaotic per-PR environments into stable, debuggable, cost-controlled infrastructure.