Generated CI for Pull Requests
chant can generate a pull-request pipeline for a project whose components deploy Terraform roots, or anything else a capability can plan. It does four things:
- On a pull request, it plans the components the change touches and the components that depend on them, and nothing else.
- It posts that plan on the pull request as one note, with a commit status.
- A reviewer approves the plan’s digest.
- On merge, it plans the same components again and applies them only if the digest is the one the reviewer approved. If the plan moved, it refuses with both digests named and applies nothing.
The pipeline runs two commands, chant components pr-plan and pr-apply. This page sets it up on each forge.
A project to run it on
Section titled “A project to run it on”Each Terraform root is a component with one terraform-apply step. A root that reads another root’s output names that component in dependsOn and reads the output through stackOutput().
{ "lexicons": ["terraform"], "terraform": { "binary": "tofu", "roots": { "net": { "dir": "roots/net" }, "a": { "dir": "roots/a" }, "app": { "dir": "roots/app" } } }}import { phase, stackOutput, type Component } from "@intentius/chant/components";
export const net: Component = { name: "net", dependsOn: [], deploy: [phase("Apply", [{ kind: "terraform-apply", root: "net" }])],};
export const a: Component = { name: "a", dependsOn: ["net"], deploy: [phase("Apply", [{ kind: "terraform-apply", root: "a", vars: { cidr: stackOutput("net", "cidr") } }])],};
export const app: Component = { name: "app", dependsOn: ["a"], deploy: [phase("Apply", [{ kind: "terraform-apply", root: "app", vars: { subnet: stackOutput("a", "id") } }])],};A root is changed when a changed file sits in its directory, in a local module it calls, or is one of its var files. A pull request that edits roots/a plans a and app. It does not plan net.
Generate the pipeline
Section titled “Generate the pipeline”# GitHubchant build --components --generate github --pr-loop --env prod --output .github/workflows/chant-pr.yml# Forgejochant build --components --generate forgejo --pr-loop --env prod --output .forgejo/workflows/chant-pr.yml# GitLabchant build --components --generate gitlab --pr-loop --env prod --output .gitlab-ci.yml--gate <name> renames the gate from pr-apply. --branch <name> names the branch pull requests target, main by default on GitHub and Forgejo and the project’s default branch on GitLab.
Both commands measure the change with git, so the pipeline checks out the full history. The default image is node:22-slim, which has no git and no Terraform. Pick an image that has git, chant and your Terraform binary, or install them in the generator’s beforeScript.
GitHub
Section titled “GitHub”The workflow runs on pull_request and on push to the target branch.
- The
planjob measures fromgithub.event.pull_request.base.sha. It may comment and set statuses, and it cannot push, since it runs the pull request’s code. - The
applyjob measures fromgithub.event.before, the commit the push replaced. That works for merge commits, squash merges and rebase merges. It may push, to record a pending approval onchant/lifecycle. One apply runs at a time per environment, throughconcurrency.
Both use the job’s own github.token. On a pull request from a fork that token is read-only, so the note and the status are not posted. The run says so and its outcome stands.
Forgejo
Section titled “Forgejo”The workflow is the GitHub one with the Forgejo dialect applied: the runner label, action references by URL, and no permissions block, which the Forgejo runner ignores. Its commands pass --forge forgejo, and Forgejo Actions supplies GITHUB_SERVER_URL, GITHUB_API_URL and GITHUB_TOKEN.
GitLab
Section titled “GitLab”The pipeline runs plan in each merge request pipeline, measured from CI_MERGE_REQUEST_DIFF_BASE_SHA. It runs apply on each push to the target branch, measured from CI_COMMIT_BEFORE_SHA, one at a time per environment through resource_group. GIT_DEPTH is 0.
A CI job token cannot write merge request notes. Create a project access token with the api scope and set it as a masked CI/CD variable named CHANT_FORGE_TOKEN.
Approve a plan
Section titled “Approve a plan”The note on the pull request lists each member with its creates, updates, replaces and deletes. The plan digest and the grouped plan summary follow, with every destroy named. The last block is the command that approves this plan:
chant approve pr-12 pr-apply --plan jcs1-sha256:3e43... --approver github:alice --signchant approve records it on chant/lifecycle under the op pr-12, so it answers this pull request and no other. It names one digest. A push that changes the plan changes the digest, and the note shows the new one.
By default the apply also asks the forge who approved the pull request. It counts a ledger record only when the person it names has a standing approving review there, named the way the workspace names a forge identity: github:<login>, gitlab:<login>, or forgejo@<host>:<login>. The generator option requireReview: false drops the check.
To count only signed approvals, list the gate in the workspace declaration. An approval then has to be sealed with --sign by a key the signers file at base lists for its approver:
"identity": { "gates": { "pr-apply": { "class": "human" } }}When the plan changed after review
Section titled “When the plan changed after review”The apply plans every member before it applies any. When the digest is the approved one, it applies each member’s plan in dependency order and updates the note and the chant/apply status. When an approval stands for another digest, something moved between review and merge. A later commit, a merge to the target branch that touched the same root, or a change in the live state each does it. Then the apply stops with exit 3:
The plan changed after review, so nothing was applied. approved: jcs1-sha256:d181...; planned now: jcs1-sha256:cef9.... Read the new plan, and if it is right, approve it: chant approve pr-2 pr-apply --plan jcs1-sha256:cef9... --approver <you> --sign; then run the apply again.The note and the report carry the same message. Read the new plan in .chant/pr/pr-apply.json, which the job keeps as an artifact. If it is right, approve it and re-run the job.
What each run keeps
Section titled “What each run keeps”Both jobs keep .chant/pr as an artifact:
| File | Holds |
|---|---|
pr-plan.json, pr-apply.json | The report: the selection, every member’s plan digest and counts, the digest the gate binds, where the approval stands and how the run ended. It follows the pr-report schema in the workspace read contract. |
change-set.json | The change-set document over every member. chant change-set summary prints its grouped summary. |
pr-note.md | The note as posted. |
Limits
Section titled “Limits”A dependent is planned against the outputs its upstream has now. When the change moves an output a dependent reads, the dependent applies the plan it was approved with, which carries the old value. The apply marks it inputsMoved, and the next run plans it against the new value. To carry a moved output through every dependent in one rollout, use gated waves, which plan each wave after the one before it applied.
An apply that fails partway leaves the members it applied in place. The generated apply job runs pr-apply with --resume .chant/pr-resume/pr-apply.json and keeps that record in the CI cache, saved even when the job fails. Re-run the failed apply job and it restores the record, skips the members that applied and applies the rest under the approval it already has. The record is not part of the report artifact, since it holds the outputs the members read.
The cache key names the environment, the member and the pushed commit: on GitHub and Forgejo chant-apply-<env>-<commit>-<run>-<attempt>, restored by the prefix up to the commit, and on GitLab chant-apply-<env>-<commit>. In a workspace member the key starts chant-apply.<member>.<env> instead. A push of another commit never restores it. The pull request and the gate digest are not in the key, because the job learns them only once it runs; pr-apply checks them against the record and refuses a record made for another pull request, gate, commit or digest, or one whose approval no longer stands. A refused record changes nothing: the apply plans every member, and the new digest needs a fresh approval.
The approved digest covers the change set’s entries as well as each member’s plan, so a record whose entries, holes or side effects are not the ones that were approved is refused like one for another digest. A member left to apply is checked against entries the approval binds, not against whatever the cache holds.
The record survives a re-run when the cache does:
- On GitHub, the Actions cache is per repository, and a run on the target branch reads only caches written from that branch or the default branch, so a pull request cannot write the record the apply reads.
- On GitLab, the cache is kept on the runner unless the runners share a distributed cache, as GitLab.com’s hosted runners do. A retry that lands on another runner without one plans every member again. GitLab keeps protected branches’ caches apart from the others by default; keep it that way.
- On Forgejo, the cache server runs with each runner unless the runners share one, with the same consequence. Run the apply on a runner the pull requests’ jobs do not use, so no other job writes into the cache it reads.
A record that comes back from an older attempt of the same run still resumes correctly: what it lists as applied did apply, and a member that applied since plans no change, which the approved plan covers.
Inside a workspace member
Section titled “Inside a workspace member”Run the same command in a member of a workspace and the pipeline belongs to that member. Each member that wants the loop generates its own, and each runs on the same pull request without touching the others:
cd infra/networkchant build --components --generate github --pr-loop --env prod# wrote .github/workflows/chant-pr.network.prod.yml for member networkThe jobs run in the member’s directory, so .chant/pr and every path in chant.config.ts read from there. The report artifact is kept from <member dir>/.chant/pr. On GitLab the file is .gitlab/ci/chant-pr.<member>.<env>.gitlab-ci.yml, its jobs are <member>-plan and <member>-apply, and each script starts with cd <member dir>.
A member’s apply is serialized per environment by the concurrency group (GitLab: resource group) chant-apply.<member>.<env>. A member name has no ., so no two members’ groups meet: member a in environment b-c and member a-b in environment c apply side by side.
The jobs pass --member <name> to pr-plan and pr-apply. The member’s gate is recorded under pr-<number>-<member>, its note carries the member’s name, and its statuses are chant/plan/<member> and chant/apply/<member>. A reviewer approves each member’s plan on its own:
chant approve pr-42-network pr-apply --plan <digest> --approver github:<login> --signThe gate name stays pr-apply, so one identity.gates entry covers every member.
The pipeline has no path filter. The plan measures the whole change, not only the files under the member, so a change outside the member that reaches one of its units selects it. A Terraform root that calls ../../modules/vpc, a module another member keeps, is planned when that module changes. A member the change does not reach reports nothing and applies nothing.