Per-PR Preview Environments
The github-pr-preview
example closes the loop on short-lived environments: PR opened → an
isolated copy of the stack deploys and a sticky comment on the PR says where
it is; PR updated → the same copy converges and the same comment updates
in place; PR closed → the copy is swept away by its ownership marker.
Nothing in the loop is a marketplace action or a shell script grown in the
workflow file. The workload is chant, the CI workflow is chant output, the
deploy is an Op, and the teardown is chant lifecycle teardown — the
stateless, marker-scoped sweep from Reconciling
Lifecycle.
| Piece | Declared in | Becomes |
|---|---|---|
| Workload (Namespace + WebApp) | src/app/ | dist/manifests.yaml, applied per env |
| PR workflow | src/ci/ | .github/workflows/preview.yml |
| Deploy verb | ops/preview-apply.op.ts | build → plan → apply on the local executor |
The github lexicon is the dialect shown here; the same shape works on the
forgejo lexicon unchanged (it emits the
same workflow to .forgejo/workflows/), and GitLab users get the equivalent
loop pre-packaged as the gitlab lexicon’s ReviewApp composite.
Three declarations carry the whole loop
Section titled “Three declarations carry the whole loop”Everything PR-specific reduces to one value: the environment name pr-<n>.
chant.config.ts wires it in three places:
export default { lexicons: ["k8s", "github", "temporal"], sourceDir: "src", environments: ["local", "pr-*"], ownership: { stack: "pr-preview", env: { param: "env" } }, buildParams: { env: { type: "string", default: "local", env: "CHANT_ENV" }, },} satisfies ChantConfig;environments: ["local", "pr-*"]. PR numbers are unbounded, so no static
list can name every environment. A glob entry (chant #1221) declares the whole
family: pr-42 is as legal as local for build, lifecycle, and
teardown, and a typo like pr42 is still rejected. Nothing is edited per
PR.
buildParams.env with the CHANT_ENV fallback. The env name enters the
build as a build-time parameter. The
workflow exports CHANT_ENV=pr-<n> once at workflow level; every chant
invocation in both jobs — the Op’s inner npm run build, the teardown —
resolves the same validated value. No flag threading.
ownership.env: { param: "env" }. The ownership marker follows the
parameter (chant #1396). A build with env=pr-42 stamps every resource
chant.intentius.io/stack: pr-preview + chant.intentius.io/env: pr-42.
That marker is the entire teardown contract — no state file, no snapshot, the
cluster’s own labels are the record.
The workload interpolates the same parameter into every physical name
(preview-pr-42, web-pr-42), which is what lets two open PRs coexist —
see Resource Naming
and lint rule COR021, which flags a name that forgot:
import { params } from "@intentius/chant/params";
const env = params.env as string;
export const config = { env, namespace: `preview-${env}`, appName: `web-${env}`, appImage: "nginxinc/nginx-unprivileged:1.27-alpine", appPort: 8080,};The workflow is chant output
Section titled “The workflow is chant output”src/ci/ declares the workflow with the github lexicon’s typed entities, and
npm run build:ci emits .github/workflows/preview.yml — the workflow that
builds the project is itself built from the project. One workflow, two jobs,
gated on the PR action:
export const preview = new Workflow({ name: "pr-preview", on: { pull_request: { types: ["opened", "synchronize", "reopened", "closed"] } }, permissions: new Permissions({ contents: "read" }), concurrency: new Concurrency({ group: "pr-preview-${{ github.event.number }}", "cancel-in-progress": false, }), env: { CHANT_ENV: "pr-${{ github.event.number }}" },});The concurrency group serializes runs per PR, and cancel-in-progress: false matters more than it looks: a close event racing a still-running
deploy must queue behind it, not kill it halfway and leave orphans for the
teardown to miss.
The deploy job (if: github.event.action != 'closed') checks out, installs,
loads a kubeconfig from the preview deployment
environment’s
PREVIEW_KUBECONFIG secret, and runs one verb:
- name: Deploy preview environment run: npx chant run preview-applypreview-apply is an ApplyOp on the local executor
— build, live-diff plan, then a Kubernetes server-side apply as field manager
chant:pr-preview, deletes scoped to owned resources only. The op reads the
same params.env, so nothing about it is PR-specific:
const apply = ApplyOp({ name: "preview-apply", env: params.env as string, target: "kubectl", path: ".", output: "dist/manifests.yaml", delete: "owned-only",});The teardown job (if: github.event.action == 'closed') runs for every
close, merged or abandoned:
- name: Tear down preview environment run: npx chant lifecycle teardown "$CHANT_ENV" --yesTeardown enumerates live resources carrying stack pr-preview + env pr-<n>
and deletes exactly those, reporting every outcome (deleted / failed /
not-prunable / skipped — never silence). It works even when the PR rewrote
the workflow or the workload sources, because it never consults the build:
the marker lives on the resources. The prod guard does not fire for pr-*
names, so --yes is enough to run non-interactively; a name like prod
would additionally demand --confirm-prod.
The sticky comment
Section titled “The sticky comment”A preview no one can find may as well not exist. The deploy job ends by posting the environment’s coordinates to the PR — one comment, updated in place across pushes, keyed on a hidden HTML marker in the body:
- name: Sticky PR comment env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.number }} REPO: ${{ github.repository }} MARKER: <!-- chant-pr-preview --> run: | body="$MARKER ..." comment_id=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" --paginate \ --jq "map(select(.body | startswith(\"$MARKER\"))) | .[0].id // empty") if [ -n "$comment_id" ]; then gh api -X PATCH "repos/$REPO/issues/comments/$comment_id" -f body="$body" > /dev/null else gh api -X POST "repos/$REPO/issues/$PR_NUMBER/comments" -f body="$body" > /dev/null fiThe marker is invisible in rendered Markdown but survives in the raw body, so
the find-and-update is stable across any number of pushes. This is a scripted
gh api step on purpose: gh ships on GitHub’s hosted runners, so there is
no marketplace action to vet, pin to a SHA, and re-audit on every bump — the
github lexicon’s own pinned-action lints (GHA019 and friends) stay quiet
because there is nothing unpinned to flag.
The job needs pull-requests: write, granted at job level, not
workflow-wide. Note the fork boundary: on pull_request from a fork, GitHub
hands the job a read-only token and no secrets, so the comment step (and the
deploy itself, which needs the kubeconfig secret) runs only for same-repo
PRs. Previews for forked PRs mean pull_request_target and a very different
threat model — do not go there casually.
Run the loop by hand
Section titled “Run the loop by hand”The workflow’s two jobs are one command each, so you can play CI locally
against any cluster your kubeconfig points at (k3d is the cheapest:
k3d cluster create preview):
cd examples/github-pr-previewnpm install
# the deploy job, for an imaginary PR 42CHANT_ENV=pr-42 npx chant run preview-applykubectl get all -n preview-pr-42
# a second PR coexists with the firstCHANT_ENV=pr-43 npx chant run preview-apply
# the teardown job for PR 42 — PR 43 is untouchednpx chant lifecycle teardown pr-42 --yeskubectl get ns preview-pr-42 preview-pr-43The same two invocations are proven end to end — deploy on a PR-opened
event, sweep on a PR-closed event, against a real runner and a live emulator
— by the Forgejo runtime E2E (just forgejo-preview-e2e), which drives the
chant-emitted workflow through act with stubbed PR events.
What a preview costs, and what not to clone
Section titled “What a preview costs, and what not to clone”Per-PR copies are real resources. The pattern is cheap exactly when the stack is cheap, and this example is built to stay cheap: one namespace, one single-replica Deployment (previews need availability for one reviewer, not HA), one ClusterIP Service, requests of 50m CPU / 64Mi memory. Ten open PRs cost ten small pods on a cluster you already run, plus about a minute of CI per push.
Costs that scale with open PRs, not with merges:
- Compute and storage per copy. Every open PR holds its copy alive. Long-lived PRs are the expensive ones — the teardown fires on close, not on staleness. If your team parks PRs for weeks, add a scheduled workflow that tears down environments whose PR has gone quiet.
- CI minutes per push. Deploy runs on every synchronize. The concurrency group keeps it to one run at a time per PR, but a busy PR still redeploys on each push.
- Shared-cluster headroom. Previews share the cluster’s capacity. A
ResourceQuotaper preview namespace (the k8s lexicon’sNamespaceEnvcomposite bundles one) turns a runaway PR into a scheduling failure instead of a cluster incident.
Some resource kinds do not belong in a per-PR copy at all:
- Stateful stores with real data. A database per PR is fine when it is small, empty, and seeded by the deploy. Cloning production data into PR copies multiplies your compliance surface by the number of open PRs; point previews at fixtures instead.
- Slow or quota-bound infrastructure. Anything that takes tens of minutes to provision (managed clusters, cloud databases, VPCs) or draws from a hard account quota (elastic IPs, load balancers, certificates) makes open × provision-time your PR feedback loop. Keep those in a long-lived environment and clone only the app layer — namespaces, workloads, config.
- Globally named resources. Anything whose name is a global singleton (DNS records at a fixed hostname, storage buckets in a global namespace) needs the env interpolated into the name or it cannot be cloned; that is what COR021 enforces for the kinds chant builds.
- Third-party side effects. Webhooks, external DNS, paid SaaS integrations — a preview that registers itself with external systems needs a teardown story for each of them. The marker-scoped sweep covers what chant deployed, nothing else.
The k8s target keeps the marginal cost of this example near zero. The same
loop works with target: "cloudformation" or any other ApplyOp target —
reread this section first, because the marginal cost of a stack of cloud
resources is not near zero.