Per-PR Preview Environments
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`.Optional: hand this page to your coding agent
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.
| 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"], 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:
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, 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-applypreview-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" --yesTeardown 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 sticky comment
Section titled “The sticky comment”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 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) 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.
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 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
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. 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.