Ops
Show the whole prompt
Read https://intentius.io/chant/lexicons/terraform/ops/.
Declare the `TerraformApplyOp` I describe for the root I name, in `ops/<name>.op.ts`, with a gate that is not `"never"`.
Run `npx chant build` and `npx chant lint`, then run the root's plan with `terraform plan` (or `tofu plan`) and the credentials I give you, and put the plan summary in the pull request description.
Open a pull request with the Op. Print the `chant run` command for a person to run; do not run the Op.
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/lexicons/terraform/ops/.Declare the `TerraformApplyOp` I describe for the root I name, in `ops/<name>.op.ts`, with a gate that is not `"never"`.Run `npx chant build` and `npx chant lint`, then run the root's plan with `terraform plan` (or `tofu plan`) and the credentials I give you, and put the plan summary in the pull request description.Open a pull request with the Op. Print the `chant run` command for a person to run; do not run the Op.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`.Nine activities ship with the lexicon, resolvable by name via loadActivities(["terraform"]), plus four composite Ops built from them. Three act on one root: a gated apply, a scheduled watch, and a gated adoption. The fourth rolls a module version out across many roots as pin-bump pull requests.
The activities
Section titled “The activities”Every call resolves the named root out of terraform.roots (walking up from cwd to the nearest chant.config.*, the same way chant build does), sets TF_IN_AUTOMATION=1, and passes -input=false everywhere the CLI accepts it, so a missing variable fails the step instead of blocking on a prompt nobody answers.
| Activity | Runs | Notes |
|---|---|---|
terraformInit | terraform init | -backend-config flags come from the root’s backendConfig. upgrade adds -upgrade; reconfigure adds -reconfigure. |
terraformPlan | terraform plan -detailed-exitcode -out=<planFile> | Exit 0 means no changes, exit 2 means changes — both are success, everything else throws with the CLI’s own stderr attached. Returns the plan’s add/change/destroy counts and both a -json and a -no-color render, so a gate or a report downstream never re-plans. Also returns planDigest and changeSet, the root’s part of a change-set document. |
terraformApply | terraform apply <planFile> | A saved plan, on either kind of root: there is no bare-apply mode here, and a missing planFile is refused before the CLI ever runs, naming the Plan step’s own output as the fix. On a live root (see “choudoufu” below) that file is the approval artifact, and an apply that disagrees with it comes back as a named refusal rather than a thrown error. |
terraformShow | terraform show | Over a saved plan (planFile given) or over current state (omitted). terraform show takes no -input flag, so this one carries TF_IN_AUTOMATION alone. |
The builders
Section titled “The builders”terraformInit, terraformPlan, terraformApply, terraformShow also exist as typed step-builder wrappers (import { terraformPlan } from "@intentius/chant-lexicon-terraform"), the fully-typed twins of the activities above: opts is the activity’s own args interface, minus the positional root, so a field rename there is a compile error at the call site rather than a silent drop. root is positional because every terraform step needs one.
import { terraformInit, terraformPlan, terraformApply, phase } from "@intentius/chant-lexicon-terraform";
const plan = terraformPlan("app", { id: "plan" }); // an id is what makes `.out` legalGive a Plan step an id when a later step applies its plan: plan.out.planFile builds a reference to that step’s own saved output, instead of a literal path a rename could silently orphan. TF101 requires that reference; see Lint Rules.
Default profiles: terraformInit/terraformPlan/terraformApply run on longInfra (they call providers or download them); terraformShow runs on fastIdempotent (it reads an artifact and calls no provider).
Change sets
Section titled “Change sets”terraformPlan returns changeSet, the root’s part of a change-set document. The root’s name is the member, and the member’s plan digest is the step’s own planDigest. A run over several roots, or over roots and chant members, hands its parts to core’s composeChangeSet and binds one gate to the document’s digest.
Two adapters live in the @intentius/chant-lexicon-terraform/change-set subpath. terraformChangeSetPart reads a show -json plan from terraform, tofu or choudoufu. choudoufuSetPlanParts reads choudoufu live-plan-set -json and returns one part per root, with choudoufu’s root digest as nativeDigest. The subpath imports no lint rules and no TypeScript, so it bundles on its own.
Roots as components, in gated waves
Section titled “Roots as components, in gated waves”The lexicon contributes one component verb, terraform-apply. A component that deploys a root names it:
import type { Component } from "@intentius/chant/components";
export const app: Component = { name: "app", dependsOn: ["network"], deploy: [ { phase: "Apply", steps: [ { kind: "terraform-apply", root: "app", vars: { vpc_id: { stackOutput: { stack: "network", name: "vpc_id" } } }, noRollback: "an apply is undone by applying the previous configuration", }, ], }, ],};root is a key into terraform.roots, so it works for terraform, tofu and choudoufu alike. vars are passed as -var after the root’s var files, so a value another root’s apply produced reaches this root’s plan. The step returns the root’s terraform output -json as outputs, which is what a dependent’s stackOutput() reads.
Roots written this way run as a chant components fan-out. With --wave-gate <name>, each wave of roots is planned once the wave before it has applied, and its gate binds the wave’s set digest. The member is the root’s name and its plan digest is the step’s planDigest, so a wave’s digest is the change-set digest of its roots. run applies the saved plan the wave approved. terraform and tofu refuse a saved plan whose state moved, and choudoufu refuses one whose live plan changed, so either way the approved plan is the one applied or nothing is.
A live choudoufu root in a gated wave goes through choudoufu’s own set commands, which need choudoufu v0.22.0 or later. plan runs choudoufu live-plan-set -json over the root, and the wave’s gate binds the plan that document holds. run passes the document and choudoufu’s set digest to choudoufu live-wave-apply, which plans the root again and applies nothing when the plan moved. The fan-out then exits 3, as it does when choudoufu’s apply refuses a wave’s plan file. choudoufu’s resume file is kept in the fan-out’s --resume record, so the next attempt hands it back to choudoufu. The files live under .chant/choudoufu-waves/<root>/. A choudoufu root whose step passes vars, or whose entry in terraform.roots has var files or backend config, keeps the plan -out and apply path, since live-plan-set takes none of them. An older choudoufu is refused on the wave path only; plain terraform and tofu roots do not need it.
TerraformApplyOp
Section titled “TerraformApplyOp”The composite that phases the four activities into Init, Plan, an optional approval Gate, then Apply:
import { TerraformApplyOp } from "@intentius/chant-lexicon-terraform";
export const { op } = TerraformApplyOp({ name: "app-apply", root: "app",});| Option | Default | Notes |
|---|---|---|
name | — required | Op name (kebab-case). gateName defaults to approve-<name>. |
root | — required | Key into terraform.roots. |
planFile | chant.tfplan | Written by Plan, consumed by Apply — relative to the root dir. |
gate | "on-destroy" | "on-destroy" and "always" build the identical four-phase shape (GateStep carries no condition to branch a plan’s destroy count at build time — see below); "never" drops the Gate phase entirely. |
gateName | approve-<name> | The gate’s name. signalName is the deprecated spelling, accepted through 0.59.0 and removed in 0.60.0. |
gateTimeout | core’s own (48h) | How long a recorded pending gate stays valid, as a duration string. |
gateDescription | a generated description naming the root and gate mode | Override text shown to the approver. |
upgrade | false | -upgrade on the Init step. |
cwd | the process’s cwd | Directory each step starts its chant.config.* search from. |
compensate | unset | Saga-style rollback on a failed apply — see below. |
The gate
Section titled “The gate”gate: "never" drops the Gate phase, so chant run walks straight from Plan to Apply. Every other mode emits a Gate phase that re-renders the saved plan with a terraformShow step first (over the plan file, so it calls no provider) and reports its destroys count as a Destroys search attribute. A gate is a fact on the gate ledger: a run that reaches one nobody has approved records that it is waiting, ends with status gated and exits 3, chant approve <op> <gate> writes the resolution, and the next run reads it and walks through. "on-destroy" and "always" emitting the identical shape is deliberate: GateStep carries a signal name, a timeout and a description, nothing conditional, so "on-destroy" cannot itself decide at build time whether a given run’s plan destroys anything. So both modes always emit the gate, and the approver sees the Destroys count.
Compensation
Section titled “Compensation”Terraform has no automatic rollback. Undoing a partial apply means planning and applying the inverse, a decision about the estate rather than something chant can synthesize. So compensate: true with no command throws at build time, naming the Op, rather than surfacing as a runtime failure once an apply has already half-run:
export const { op } = TerraformApplyOp({ name: "app-apply-gated", root: "app", gate: "always", compensate: { command: "terraform destroy -auto-approve" },});examples/apply-gated/ is the worked version of this shape.
choudoufu
Section titled “choudoufu”terraform.binary also accepts "choudoufu", an OpenTofu fork that carries ownership as two AWS tags on the resource itself rather than in a state file. A root only runs live under choudoufu when it also declares an estate, either form:
# in-block, inside the root's terraform blockterraform { live { estate = "prod-networking" }}# estate.chdf.hcl, a sidecar beside the root's .tf files, the leading form,# since it keeps every .tf file free of syntax stock tooling does not knowestate = "prod-networking"Declaring binary: "choudoufu" with no estate anywhere runs the root stock, exactly as "terraform"/"tofu" do; declaring an estate under any other binary parses fine but is inert, and chant dev check-lexicon/buildRoots() warns about it.
Four more activities exist for a live root, alongside the four above:
| Activity | Runs | Notes |
|---|---|---|
choudoufuLivePlan | live-plan -detailed-exitcode -json, plus a second live-plan without -json | Exit 0 means no drift, exit 2 means drift, and both are success. Returns GitHub issue #788’s JSON document captured whole (.out.documentPath names where it was written, under the root dir), the human-readable plan, and drift/unowned/adoptable counts projected from the document. estate is auto-detected from the root’s own live block or sidecar when omitted, and a root that declares one runs with no -estate at all, which is the only form choudoufu admits there. Also returns the adoption ledger over that document (.out.ledger), the adoptable and contested candidate lists, and .out.finding, the plan text with the ledger under it. adoptionOnly makes the second run print choudoufu’s own -adoption-only ledger, whose paste-ready tagging commands ride back on each candidate. |
choudoufuLiveLs | live-ls -estate=<estate> -json [-consistent] | Lists every resource the account holds under the estate, straight off the Resource Groups Tagging API. No configuration, state or record store is read. |
choudoufuLiveCheck | live-check -json | Whether the root can move under live resource markers, and what stops it if not. Makes no cloud calls and needs no estate at all; refused: true on a non-zero exit carries the answer back rather than throwing. |
choudoufuAdopt | one tagging call per adoptable match | Writes the tofu-estate and tofu-address markers that claim the live resources an adoption ledger found. Takes the ledger’s own adoptions and contested lists; a contested address is reported and never written. See Reconcile. |
terraformInit, terraformPlan, terraformApply and terraformShow are reused as-is on a live root. Every choudoufu invocation still gets TF_IN_AUTOMATION=1. choudoufu version is checked once per activity-module load and refuses a binary older than v0.15.0. v0.12.0 brought live-plan -json, live-ls and live-check -json; v0.13.0 brought the approval artifact (choudoufu #878); v0.14.0 made the -json document reachable on a configuration that names its own estate (choudoufu #894), which is the shape every chant live root has; v0.15.0 put the estate-wide sweep’s content matches in that document as an adoptable section, with the swept type list beside it (choudoufu #962), which is what the adopt path reads. So v0.15.0 is the floor describeResources() and all three document-reading Ops need.
The adoption ledger. choudoufuLivePlan with adoptionOnly reads two sections of the document into one ledger. unowned[] is a live resource found at an identity the configuration itself declares, and adoptable[] is one the estate-wide sweep matched to a declaration by comparing arguments, for a declaration that names no identity at all (a VPC’s id is assigned by EC2, so nothing in the block determines it). The second section is populated only when the run asks the account-bounded question, so adoptionOnly puts TOFU_LIVE_COLLECT_UNCLAIMED=1 on the -json invocation and no other run pays for the sweep. Each adoptable[] row carries its own tagging command; an unowned[] row does not, and its command is still read out of the human -adoption-only render, which is the one thing on this path that still parses text.
Which acceptance suites have actually run. All four of this lexicon’s acceptance blocks skip rather than fail when their binary or emulator is absent, so a green test run says nothing about whether any of them has ever passed. lexicons/terraform/src/__fixtures__/ACCEPTANCE.md records the answer per block: what it proves, what gates it, the command to run it, and the last binary and emulator it passed against with the date. All four passed on 2026-09-08 against the choudoufu v0.15.0 release binary with the pinned floci emulator up, which is the first pass the adopt block (composites/terraform-adopt-op.acceptance.test.ts) has ever had: until v0.15.0 the content match that finds the fixture’s unmarked aws_vpc was printed only in the human -adoption-only render, which choudoufu refuses alongside -json, and the document reported the instance under omissions as NEEDS_DISCOVERY with unowned empty. That was filed upstream as choudoufu #962 and fixed by its PR 963. There is no TerraformWatchOp acceptance suite; the watch Op’s live evidence is the live-plan block it shares with the adopt Op.
choudoufuLivePlan, choudoufuLiveLs, choudoufuLiveCheck and choudoufuAdopt exist as typed step builders too, the same shape as the four above (root positional, opts the activity’s own args minus root). choudoufuLivePlan/choudoufuLiveLs default to the longInfra/fastIdempotent profiles respectively the same way terraformPlan/terraformShow do; choudoufuLiveCheck defaults to fastIdempotent, no cloud calls, no state; choudoufuAdopt defaults to longInfra, one tagging round trip per resource.
TF001 (no remote backend) does not fire on a live root, since a backend block is exactly what choudoufu refuses there. TF024, TF025 and TF026 are the live-root equivalents; see Lint Rules.
TerraformApplyOp on a live root
Section titled “TerraformApplyOp on a live root”TerraformApplyOp builds the same four phases on a live root as on a stock one, and the same plan file crosses the gate: Init, plan -out=<planFile>, Gate on show over that file, then apply <planFile>. It ran a different shape for one release, when choudoufu refused -out and apply <planfile> outright. choudoufu v0.13.0 admits both under a live block (choudoufu #878), so the plan-file discipline is back.
What the approval covers. apply <planFile> on a live root does not replay the file. Prior state there is a projection rebuilt from the live system every run, so the apply re-plans against the live system the way every live-markers run does, and then compares its own fresh plan against the file’s: the same addresses, the same actions, the same live objects, and the same planned values down to the attribute. Agreeing, it applies without re-prompting, exactly as stock’s apply <planfile> does. Disagreeing, it refuses before anything changes, with one of two named refusals:
The approved plan no longer matches the live system, when the change set or the planned values moved, andThe approved plan belongs to a different estate, when the file was produced from a configuration naming another estate.
Both carry exit status 3, which is neither 1 (any ordinary failure, which a pipeline must not route back to a reviewer) nor -detailed-exitcode’s 2 on the plan half of the same pipeline. terraformApply maps that status to a result rather than a thrown error: refused: "approval-mismatch" | "wrong-estate", with choudoufu’s own message in refusal and applied: false. A workflow reading the Apply step’s output can send the run back to review instead of treating it as a broken step. Exit 3 is read this way only on a live root; on a stock root nothing produces it and every non-zero exit stays a failure.
The approvalArtifact seam #2106 left named and unused on the apply args is gone, because the thing it was a seam for has shipped in the stock form: the artifact is the plan file, and planFile is how it is passed.
TF101 (plan-before-apply) fires on a live root’s terraformApply call again, and reads no project config at all. The pairing matters more here than on a stock root, not less: the apply compares its fresh plan against the file it was handed, so a plan file spelled out as a literal path is an approval for a run nobody can trace back to a plan step.
Delete modes. chant’s delete: "never" | "owned-only" | "gated" (terraform.roots.<name>.delete) maps onto choudoufu’s policy block: "owned-only" is choudoufu’s own default verb for the undeclared_tagged quadrant (an orphaned marked resource) and needs nothing; "gated" needs nothing beyond the approval gate above; "never" requires the root’s policy block to set undeclared_tagged to "keep", "untag" or "report", and TF026 enforces that at build time, naming the setting to add. Separately, and regardless of delete, TerraformApplyOp refuses outright to build against a root whose policy sets undeclared_untagged = "delete" (account-scoped reconciliation, which needs a scope block): chant never proposes deleting a resource it does not own, and neither does an Op it generates.
TerraformWatchOp
Section titled “TerraformWatchOp”A second composite, TerraformWatchOp, polls a root’s state on a schedule rather than driving an apply. See the lexicon’s “Live Observation” doc page for how a scheduled watch reads a Terraform root’s state file as its ownership answer.
Pass live: true and the Plan phase becomes choudoufuLivePlan instead of terraformPlan:
export const { op } = TerraformWatchOp({ name: "estate-watch", root: "estate", live: true, schedule: "0 6 * * *", findingMode: "issue",});A stock plan answers one question, so a stock watch reports one thing: whether the root drifted. A live plan reads the live system against the markers, so it answers three, and the Plan step publishes all three off that single read: Drift from the -detailed-exitcode exit, Unowned for live resources sitting at a declared identity with no marker, and Adoptable for the subset an exact content match makes claimable. planFile is refused with live: true (live-plan writes no saved plan, and -out is still refused on its own -estate surface) and estate is refused without it.
In issue, comment and pull-request mode the body is the -no-color plan text followed by the adoption ledger: one line per adoptable match with its address, its live identity and the two tag values that claim it, then any address more than one live resource sits at, listed and not offered. Only that text reaches the body. The -json document stays in the Op, the same rule the stock mode holds for terraform show -json and for the same reason: it carries live identities and attribute values for everything the run touched.
Plan on the pull request, apply on the push
Section titled “Plan on the pull request, apply on the push”The Terraform CI shape is two Ops over one root, each with its own CI-native trigger (#2084): a TerraformWatchOp that plans and reports, triggered by a pull request, and a TerraformApplyOp that plans, gates and applies, triggered by the push that follows the merge. Neither Op carries a cron. The trigger lives on the ScheduledOpSpec handed to generateOpsPipeline, the same place a cron would:
import { generateOpsPipeline } from "@intentius/chant/op";
const assumeRole = (roleVariable: string) => [ { uses: "aws-actions/configure-aws-credentials@v6", with: { "role-to-assume": `\${{ vars.${roleVariable} }}`, "aws-region": "eu-west-1" }, },];
const { files } = await generateOpsPipeline( [ { name: "app-plan", trigger: { kind: "pull_request", branches: ["main"] }, findingMode: "comment", setup: assumeRole("AWS_PLAN_ROLE_ARN"), permissions: { "id-token": "write" }, }, { name: "app-apply", trigger: { kind: "push", branches: ["main"] }, setup: assumeRole("AWS_APPLY_ROLE_ARN"), permissions: { "id-token": "write" }, environment: { name: "production" }, }, ], "github", { beforeScript: [installTerraform] },);Two specs produce two workflow files, because a GitHub trigger is workflow-scoped rather than job-scoped. app-plan.yml carries on: pull_request with the branches filter and no workflow_dispatch, since a pull request needs no manual escape hatch. app-apply.yml carries on: push filtered to the same branch; omitting branches there defaults to main. Each file also carries its own concurrency group, which is what stops two applies racing for the same state lock:
name: app-apply
on: push: branches: - main
concurrency: group: app-apply cancel-in-progress: false
permissions: contents: read id-token: write
jobs: app-apply: runs-on: ubuntu-latest container: node:22 environment: name: production outputs: gated: '${{ steps.chant-run.outputs.gated }}' op: '${{ steps.chant-run.outputs.op }}' gate: '${{ steps.chant-run.outputs.gate }}' approve: '${{ steps.chant-run.outputs.approve }}' steps: - uses: actions/checkout@v7 - uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: '${{ vars.AWS_APPLY_ROLE_ARN }}' aws-region: eu-west-1 - run: curl -fsSL https://releases.hashicorp.com/terraform/1.13.3/terraform_1.13.3_linux_amd64.zip -o /tmp/terraform.zip && unzip -q -o /tmp/terraform.zip -d /usr/local/bin && terraform version - id: chant-run run: | set +e json="${RUNNER_TEMP:-/tmp}/chant-run-app-apply.json" status="${RUNNER_TEMP:-/tmp}/chant-run-app-apply.status" { chant run app-apply --gated-exit 0 --json; echo "$?" >"$status"; } | tee "$json" code=$(cat "$status") [ "$code" -eq 0 ] || exit "$code" node -e '...set gated/op/gate/approve on $GITHUB_OUTPUT when the record says gated...' "$json" env: GITHUB_TOKEN: '${{ github.token }}' app-apply-gate-notice: needs: app-apply if: needs.app-apply.outputs.gated == 'true' runs-on: ubuntu-latest container: node:22 permissions: contents: read issues: write pull-requests: write steps: - name: Report the pending gate env: GITHUB_TOKEN: '${{ github.token }}' CHANT_OP: '${{ needs.app-apply.outputs.op }}' CHANT_GATE: '${{ needs.app-apply.outputs.gate }}' CHANT_APPROVE: '${{ needs.app-apply.outputs.approve }}' run: | ...npm install @intentius/chant@<this release>, then a node script through @intentius/chant/pr-forge: ...find the merged PR for the commit, post or edit the marker comment, or keep a marker issue...The runner has no terraform on it, so the install is a beforeScript line pinned to a release rather than taken from a package repo: the version that plans is then the version the file names, which matters for a root with a required_version floor. beforeScript lines are emitted as plain run: steps ahead of the invocation, so the Op’s first step is never a missing binary. A setup entry could carry hashicorp/setup-terraform instead; a curl and an unzip need no action to express.
Permissions are per workflow, from the trigger and the finding mode, plus whatever the spec adds by name. The apply half is contents: read and nothing else from the mode, since it talks to the provider and the state backend rather than to the forge. The plan half is contents: read and pull-requests: write, which is the whole cost of a comment on the triggering pull request: findingMode: "comment" opens no issue, so no issues: write, and pushes no branch, so no contents: write. The other modes are wider on this trigger, since the trigger adds pull-requests: write to any of them: "issue" also carries issues: write, and "pull-request" widens contents to write.
AWS auth is OIDC, and the two options above are what make it expressible (#2242). aws-actions/configure-aws-credentials is a uses: step, so it rides the spec’s setup list, emitted between the checkout and the beforeScript install; it needs id-token: write, which no finding mode grants, so it rides the spec’s additive permissions. The run mints an OIDC token, the action exchanges it for credentials that expire with the job, and the repository stores no long-lived key. role-to-assume reads a repository variable because a role ARN is an account number and a role name rather than a secret. The two halves name two variables on purpose: the plan role can be read-only, and the push half is the only thing that should hold a role that can write, which is why setup is per Op rather than per generate call.
Static credentials still work, as variables on the generate call, which becomes the workflow’s top-level env: (AWS_ACCESS_KEY_ID: "${{ secrets.AWS_ACCESS_KEY_ID }}"). Reach for that when the runner cannot reach GitHub’s OIDC provider or the account has no OIDC federation to GitHub. Otherwise OIDC is the better trade: nothing durable is stored, a leaked credential expires within the hour, and the role’s trust policy names the repository and ref allowed to assume it.
The additive permissions map can only add. A scope the mode already granted is refused by name at build time, at any value, so this never downgrades or restates least privilege; so are write-all, a scope name GitHub does not define, and pull-requests: write on a trigger with no pull request. setup refuses an action reference that is unpinned or pinned to the action repository’s own default branch, since a generated workflow is committed once and re-run unattended over a cloud role.
Only the human plan is posted, on either half. terraform show -json over a plan carries every resource attribute value the plan touched, provider credentials among them, so the Report step’s body is one reference to the Plan step’s -no-color render and the JSON one stays inside the Op (#2081). There is no option to post the other, and examples.test.ts asserts no reference to it reaches the finding step.
The plan lands as a comment on the pull request that triggered the run. That is findingMode: "comment" (#2231), the fourth mode on reconcilePr and the one this shape was missing. It writes a hidden marker as the comment’s first line and finds the comment again by that marker on the next run, so a pull request pushed to five times carries one comment holding the current plan rather than five stale ones. The pull request comes out of the run’s own event payload (GITHUB_EVENT_PATH, falling back to GITHUB_REF), which is why the mode constrains the trigger rather than only the permission set: the github generator refuses it by name on a cron or push trigger, and a run reaching the Report step with no pull request fails there instead of posting the plan somewhere else.
On GitLab the same mode writes a merge-request note (#2256), by the same recipe: the marker is the note’s first line, the next pipeline finds the note by it and edits it in place. The merge request comes out of CI_MERGE_REQUEST_IID and the project out of CI_MERGE_REQUEST_PROJECT_ID, so the gitlab generator constrains the trigger exactly as the github one does and refuses the mode by name on a cron or push job. On Forgejo it posts the same comment GitHub gets, through the same forge client over Forgejo’s /api/v1 (#3757), so the forgejo generator emits the mode as the github one does.
The push run stops at the apply’s gate. It reaches the gate, finds no resolution on the ledger, records the pending fact and ends there instead of holding a runner open. chant approve app-apply approve-app-apply --approver you records the answer and re-running the workflow applies. A root whose merges should apply unattended sets gate: "never".
A gated merge is a green run with a visible pending state (#2243). Actions has no neutral conclusion for a run: step, so exit 3 used to mark main as broken on every merge until someone approved. The push job now runs chant run app-apply --gated-exit 0 --json: chant run maps that one outcome, so a run that fails for any other reason still exits 1 and the job is still red. The gate, the chant approve line and the _gates/app-apply.jsonl ledger path go to GITHUB_STEP_SUMMARY, which is where the run page shows them.
The pending state also leaves the Actions log. The app-apply-gate-notice job above needs: the apply and runs when its gated output is set. A push event carries no pull request, so it asks the forge for the one the pushed commit belongs to (repos/{repo}/commits/{sha}/pulls on GitHub, the commit’s own associated-pull-request endpoint, exact rather than a search index) and posts there through the same hidden-marker recipe findingMode: "comment" uses, editing one comment rather than stacking one per merge. A commit with no pull request, such as a direct push to the branch, gets an issue instead, kept by the same marker, which is why the job carries issues: write alongside pull-requests: write. Those permissions are the job’s own and replace the workflow’s for it alone, so the apply beside it stays contents: read.
The job runs in the Op’s own image, installs the chant release the workflow was generated with, and posts through @intentius/chant/pr-forge (#3773), so it needs no gh and no checkout. That is also why Forgejo takes all three: the same job, with Forgejo’s commits/{sha}/pull lookup and its /api/v1 base read off the job’s environment. GitLab takes neither, since its Op generator refuses a push trigger outright.
The apply also runs in a GitHub environment, which is a second gate in front of the first (#2257). environment: { name: "production" } on the apply spec emits environment: production on that job, and a production environment configured in the repository with required reviewers then holds the job before its first step. The two gates stop different things. The reviewer stops the job from starting at all, so nothing is checked out and no role is assumed until somebody releases it; chant’s gate stops the apply inside a run that already started, on a plan that already exists, and is cleared by a chant approve commit rather than by a button. Wanting both is the common case, and neither is required by the other: the reviewer alone pairs with gate: "never" on the root, and chant’s gate alone is what a Forgejo estate gets, since Forgejo Actions has no environments and its generator drops the key with a header comment saying so. The plan half names no environment, because it deploys nothing and a reviewer prompt on every pull request is the fastest way to teach people to click through one.
The environment costs no permission. Protection rules live on the environment object in repository settings, not on GITHUB_TOKEN, so the apply job’s permissions: are the same contents: read and id-token: write they were without it. The app-apply-gate-notice job stays outside the environment on purpose: it exists to say a chant gate is pending, and behind the same reviewer it would only be readable after somebody had already acted.
examples/plan-on-pr/ is the worked version of this shape, and examples/scheduled-watch/ is the cron one.
Reconcile: adoption, not regeneration
Section titled “Reconcile: adoption, not regeneration”Cloud-to-code is the position on the lifecycle dial chant #2089 left open for this lexicon, because stock Terraform has no typed path from a live resource back to HCL. On choudoufu, ownership is two tags, live-plan already names every live resource that exactly matches a declared block and carries no marker, and it prints the two values that would claim it. So reconcile here is “claim what the configuration already describes”, which is a tag write.
TerraformAdoptOp is that, in five phases:
import { TerraformAdoptOp } from "@intentius/chant-lexicon-terraform";
export const { op } = TerraformAdoptOp({ name: "estate-adopt", root: "estate" });| Phase | Step | What it settles |
|---|---|---|
| Init | terraformInit | Downloads the provider schema the Ledger step needs. A checkout that has never run init has no .terraform directory to read one from, which is every fresh CI checkout. |
| Check | choudoufuLiveCheck | Whether this root can move under markers at all. No cloud calls, so it is the cheapest place to find out that the ledger below would not be authoritative. |
| Ledger | choudoufuLivePlan with adoptionOnly | The adoption ledger, which is what the gate approves, plus the machine-readable document the Adopt phase acts on. |
| Gate | always | Adoption writes tags onto resources this estate does not yet own. There is no gate: "never" here, so every run stops at the gate until chant approve <op> <gate> records the resolution. |
| Adopt | choudoufuAdopt | The marker writes themselves, and a named refusal for everything it declined. |
| Option | Default | Notes |
|---|---|---|
name | — required | Op name (kebab-case). gateName defaults to approve-<name>. |
root | — required | Key into terraform.roots. Must be a live root. |
estate | auto-detected | The estate whose markers to look for; normally the root’s own live block or sidecar answers this. |
gateName | approve-<name> | The gate’s name. signalName is the deprecated spelling, accepted through 0.59.0 and removed in 0.60.0. |
gateTimeout | core’s own (48h) | How long a recorded pending gate stays valid. |
gateDescription | a generated description naming the ledger | Override text shown to the approver. |
upgrade | false | -upgrade on the Init step. |
cwd | the process’s cwd | Directory each step starts its chant.config.* search from. |
compensate | unset | Refused without a command. See below. |
The adopt mechanism
Section titled “The adopt mechanism”The write is the two tags, run through the paste-ready command choudoufu itself printed in the ledger. That command comes from choudoufu’s own provider configuration, so it carries the region and endpoint the plan just read rather than wherever an operator’s AWS CLI profile points, and it uses the right tagging verb per service rather than a guess. A type whose service has its own tagging call choudoufu does not spell out (IAM, Route53, S3) is refused with both marker values named, not adopted with an invented command.
choudoufu’s policy block has an adopt verb for exactly this situation, and its migration guide names policy { declared_untagged = "adopt" } as the bulk path. That would have been the smaller write, and it is not reachable from outside the configuration: a live block in a terraform override file is dropped in silence (choudoufu’s Module.mergeFile has no case for one), a second primary file carrying one is refused as a duplicate, and a sidecar beside an in-block form is refused as two sources of truth. What is left is editing the root’s own checked-in live configuration around an apply, which leaves an estate declaring “adopt everything declared and unmarked” if the run dies between the two edits. So chant writes the tags instead, which is what choudoufu’s own guide calls the whole contract.
Ambiguity, and compensation
Section titled “Ambiguity, and compensation”Two live resources at one declared identity are two unowned entries with the same address, and no single tag write claims that address. Those never enter the adoptable set. The Adopt step is handed them separately, so the Op’s result names what it declined and why beside what it wrote, and the Ambiguous search attribute on the Ledger phase counts them.
compensate is refused without a command, as on TerraformApplyOp. Undoing an adoption is untag, removing this estate’s marker from a live resource, which is a decision about the estate rather than something chant can synthesize; it is also not always the right undo, since a resource that already carried the markers is not one the run claimed.
live-import comes first, and is not an Op
Section titled “live-import comes first, and is not an Op”For an estate that still has its terraform.tfstate, do not start with the ledger loop above. choudoufu live-import -state=terraform.tfstate -estate=<name> reads that state file once, verifies each entry against the live system, and stamps markers on everything that verifies, which lands the whole estate in the “already marked” group with nothing left to adopt by hand. It is also the only path that answers the count/for_each blind spot: content matching never offers an indexed or keyed address, while live-import reads every instance, index and key included, straight out of the state. Two runs, the same two flags: the first prints a ratification report and writes nothing, the second stamps what the report showed as VERIFIED or DRIFTED.
chant does not wrap it in an Op, on purpose. It reads a state file, once, and chant does not host one: the file is the migration’s input rather than part of the estate, and an Op that took a path to somebody’s terraform.tfstate as an argument would be pretending otherwise. Run it by hand in the root directory, keep the state file until it has run, then let TerraformWatchOp and TerraformAdoptOp cover what is left. choudoufu’s “Migrate an existing estate” guide has the full sequence.
TerraformPinRolloutOp
Section titled “TerraformPinRolloutOp”terraformPinRollout runs one step of a pin-bump rollout (#3189). It names no terraform.roots entry. It finds the roots that call a module, in .tf files and terragrunt.hcl, splits them into waves, and opens at most the next wave’s pull request. TerraformPinRolloutOp wraps it in one Rollout phase, whose run outcome is the rollout’s status. chant terraform pin-rollout runs the same step from the command line.
import { TerraformPinRolloutOp } from "@intentius/chant-lexicon-terraform";
export const { op } = TerraformPinRolloutOp({ name: "vpc-1-4", module: "oci://registry.example.com/modules/vpc", from: "1.3.0", to: "1.4.0", canaries: ["live/dev/vpc"], mode: "pull-request", schedule: "*/30 * * * *",});A wave’s PR opens only on a run after the previous wave’s PR merged and each of its roots reported its apply check as passed on the merge commit. A failed root fails the run and names the root. The Pinned Module Rollout guide covers pinning, wave planning, and the exit codes.