Project · 2026 · source is private

Stagehand

Preview environments for pull requests. Every PR to my team's game, Shello, World!, gets its own playable web build, so playtesters can try a change before it merges, on infrastructure that costs nothing to run.

Why I built it

Shello, World! is built in Godot by a five-person team. Playable builds only went out at the end of each development phase, and trying a branch before then meant cloning it and opening Godot, which only the developers could do. Feedback on a change took about a week to come back.

Stagehand puts a playable link on every pull request instead. Playtest turnaround dropped from about a week to three days, and testers no longer need anything installed.

How a pull request becomes a preview

How a pull request becomes a previewA pull request triggers Jenkins, which builds an ARM and x86 image and calls Stagehand. Stagehand applies a per-PR namespace to a k3s cluster on Oracle Cloud. Testers reach it through Cloudflare Tunnel and Access. GitHub webhooks and PR comments flow between GitHub and Stagehand.Pull requestShello, World! on GitHubJenkinsGodot web export → ARM + x86 imagePOST /deployStagehand · Gocreates or deletes one preview per PRwebhook · PR commentk3s on Oracle CloudAlways Free · ARMpr-12pr-15latesteach namespace: Deployment · Service · Ingressprovisioned with TerraformCloudflare Tunnel + Accesssign-in required, no open web portsPlaytesterandyjpeg.dev/shello-test/pr-12/
  1. A pull request opens or gets a new push. Jenkins picks it up and starts a pipeline run for that branch.
  2. Jenkins builds the game. It exports the Godot project for the web and packages it in a Docker image for both ARM and x86, then pushes the image to a registry.
  3. Stagehand deploys it. Jenkins calls the Go service's /deploy endpoint. Stagehand creates a namespace for the PR, like pr-12, with its own Deployment, Service and Ingress on the cluster.
  4. The link lands on the PR. Stagehand comments the preview link on the pull request and updates that same comment on later pushes. PRs from development into main also refresh a shared latest build.
  5. Testers play it. They sign in through Cloudflare Access at andyjpeg.dev/shello-test/pr-12/. Traffic reaches the cluster over a Cloudflare Tunnel, so the server opens no web ports to the internet.
  6. Merged or closed, it cleans up. Closing the PR sends a signed GitHub webhook; Stagehand verifies it and deletes the namespace. Previews nobody has pushed to in seven days are removed automatically.

The pieces

Stagehand service

A Go program that drives Kubernetes with client-go: it deploys and tears down previews, verifies GitHub webhooks and posts the PR comment.

Jenkins

A multibranch pipeline that runs on every PR: headless Godot web export, multi-architecture Docker build, then the deploy call.

Kubernetes (k3s)

A single-node k3s cluster. Each preview lives in its own namespace, and Traefik routes /shello-test/pr-N to it.

Terraform + Oracle Cloud

The server, network and tunnel are all defined in Terraform, on an Always Free Ampere ARM VM.

Cloudflare

A Tunnel carries traffic in, Access puts a login in front of the previews, and a Worker serves them under andyjpeg.dev.

Dashboard

A small web page, served by the Go service behind Access, that lists every preview with its PR, branch, commit and age, with a Delete button.

Decisions worth explaining

One namespace per pull request

Everything a preview needs lives in one namespace, so tearing it down is a single delete and nothing gets left behind.

Redeploys are safe to repeat

Deploys use server-side apply, so pushing to the same PR again updates the preview in place instead of failing because it already exists.

Built for ARM

Oracle's Always Free tier is ARM, which is what keeps the bill at zero. Jenkins builds every image for ARM and x86 so one image tag works on the ARM server and on x86 machines.

Nothing exposed by default

The cluster is only reachable through the Cloudflare Tunnel. Testers sign in with Access, and GitHub's webhooks are checked against an HMAC-SHA256 signature before Stagehand acts on them.

One comment per PR, kept current

Instead of a new comment on every push, Stagehand finds its own comment by a hidden marker and edits it, so the PR thread stays readable.

What's next

A Report bug button inside each preview that opens a GitHub issue with the PR number and commit already filled in, so a playtest goes straight from “that's broken” to a ticket the team can act on.

The repository is private, but I'm happy to walk through the code. Back to andyjpeg.dev