Skip to content

Per-PR Preview Environments

llms.txt lists every page for an agent
Optional: hand this page to your coding agentThe steps work by hand too.
Show the whole prompt
Read https://intentius.io/chant/tutorials/github-pr-preview/.
In `examples/github-pr-preview`, run `npm install` and `npm run build:ci`, and show me the generated `.github/workflows/preview.yml`.
On a local k3d cluster only (`k3d cluster create preview`), run `CHANT_ENV=pr-42 npx chant run preview-apply --no-record` and `CHANT_ENV=pr-43 npx chant run preview-apply --no-record`, then `npx chant lifecycle teardown pr-42 --yes`.
Show me `kubectl get ns preview-pr-42 preview-pr-43` after each step. Do not create the `PREVIEW_KUBECONFIG` secret.
Never deploy to a real cloud account or shared environment (with `chant run`, `chant components` or a platform's own CLI), approve (a pull request review, `chant approve` or `chant run approve`), override a policy denial (`chant approve policy-override`), or merge; never cut or publish a release (`chant release cut --yes` or `chant release publish`), push or delete a `ci/*` tag (`chant ci tick` pushes them), or change the value of an existing flag; never touch `.chant/allowed_signers` or `chant/lifecycle`.

The github-pr-preview example runs the whole life of a short-lived environment: 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 and 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.

PieceDeclared inBecomes
Workload (Namespace + WebApp)src/app/dist/manifests.yaml, applied per env
PR workflowsrc/ci/.github/workflows/preview.yml
Deploy verbops/preview-apply.op.tsbuild → 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.

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"],
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, with no flag passed along.

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: the cluster’s own labels are the record, with no state file or snapshot.

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:

src/app/config.ts
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,
};

src/ci/ declares the workflow with the github lexicon’s typed entities, and npm run build:ci emits .github/workflows/preview.yml, so 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-apply

preview-apply is an ApplyOp on the local runtime: build, live-diff plan, then a Kubernetes server-side apply as field manager chant:pr-preview, with 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" --yes

Teardown enumerates live resources carrying stack pr-preview + env pr-<n> and deletes exactly those, reporting every outcome (deleted / failed / not-prunable / skipped). 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 deploy job ends by posting the environment’s address 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 }}
GH_ENTERPRISE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.number }}
REPO: ${{ github.repository }}
MARKER: <!-- chant-pr-preview -->
run: |
body="$MARKER
..."
api_base="${GITHUB_API_URL%/}"
api_base="${api_base:-https://api.github.com}"
comment_id=$(gh api "$api_base/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 "$api_base/repos/$REPO/issues/comments/$comment_id" -f body="$body" > /dev/null
else
gh api -X POST "$api_base/repos/$REPO/issues/$PR_NUMBER/comments" -f body="$body" > /dev/null
fi

The 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) have nothing unpinned to flag.

Every gh api call targets $api_base, resolved from $GITHUB_API_URL rather than a bare relative path: gh resolves a bare path against /api/v3, which only guesses right for github.com and GitHub Enterprise Server. PrPlanReport’s own sticky-comment script fixed the identical bug for the same reason (#2305). The token is set twice for a similar reason. gh sends GH_TOKEN only to github.com and ghe.com hosts and reads GH_ENTERPRISE_TOKEN for any other. Without the second variable this step reaches GitHub Enterprise Server or Forgejo with no credential and gets a 401 (#2352).

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.

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):

Terminal window
cd examples/github-pr-preview
npm install
# the deploy job, for an imaginary PR 42
CHANT_ENV=pr-42 npx chant run preview-apply
kubectl get all -n preview-pr-42
# a second PR coexists with the first
CHANT_ENV=pr-43 npx chant run preview-apply
# the teardown job for PR 42 — PR 43 is untouched
npx chant lifecycle teardown pr-42 --yes
kubectl get ns preview-pr-42 preview-pr-43

The Forgejo runtime E2E (just forgejo-preview-e2e) tests the same two invocations against a real runner and a live emulator: deploy on a PR-opened event, sweep on a PR-closed event. It 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, because 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 ResourceQuota per preview namespace (the k8s lexicon’s NamespaceEnv composite 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. A preview that registers itself with webhooks, external DNS or paid SaaS integrations 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.

chant