Stages
Every stage takes the root directories and the binary.
| Stage | What it does | Forge permissions | Outputs |
|---|---|---|---|
tf-check |
format, validate, and with choudoufu a live check that needs no cloud credentials; with policy set, the policy’s own tests |
read the repository, with no forge token; when a branch’s check fails, a separate fmt job pushes the formatting commit (respond.fmt: commit, the default; in a Terragrunt repo it runs terragrunt hcl fmt too; not with gitlab.token: protected) |
pass or fail, the log, and the check report |
tf-plan |
plans the affected roots in one run and posts the grouped summary; with policy set, fails a denied root |
read the repository, comment on pull requests | the grouped summary, and a plan digest per root |
tf-apply |
applies one job per wave; a wave the gate policy holds (under the default on-destructive, one that destroys or replaces) waits for an approval; with policy set, refuses a denied wave |
read the repository, write the chant/lifecycle branch (read only under gate: never), post statuses and pull request comments; with waves.jobs, push the run’s shared lock tag |
per wave, whether it waited and its approval command |
tf-drift |
plans every root -refresh-only on a schedule and groups what changed outside Terraform |
read the repository, write issues | the plan report, and one drift issue |
tf-publish |
publishes each changed module as an OCI artifact or a git tag | read the repository, push tags or to the registry | the new version and its digest |
tf-rollout |
opens one pull request per wave, moving the pin for that wave’s roots | open pull requests, in every project of the wave | per wave, its pull requests and their state |
Exit codes
Section titled “Exit codes”Stages exit with the CLI’s codes. Two belong to waves:
| Code | Means |
|---|---|
| 3 | a wave waits for approval |
| 4 | a wave’s plans changed after an approval no run applied, so nothing applied |
tf-check runs on every push and fork pull request, once per root. A failing root does not stop the next, so one run shows every diagnostic. Warnings print and fail nothing. Under modules.require: attested, a root whose module pins do not verify fails before validate, and tf-plan fails it before init.
| Order | Command | A difference |
|---|---|---|
| 1 | terraform fmt -check |
stops the job |
| 2 | terraform init -backend=false |
fails the root |
| 3 | terraform validate -json |
fails the root |
| Order | Command | A difference |
|---|---|---|
| 1 | tofu fmt -check |
stops the job |
| 2 | tofu init -backend=false |
fails the root |
| 3 | tofu validate -json |
fails the root |
| Order | Command |
|---|---|
| 1 | the binary’s fmt -check -recursive -diff . |
| 2 | terragrunt hcl fmt --check |
| 3 | terragrunt hcl validate --inputs |
| Order | Command | A difference |
|---|---|---|
| 1 | fmt -check |
stops the job |
| 2 | init -backend=false |
fails the root |
| 3 | validate -json |
fails the root |
| 4 | choudoufu live-check -json, no cloud calls |
each refusal prints its rule, reason, resource types and sites; a non-zero exit fails the check |
A failed validate prints the file, line and error:
error: envs/dev/orders/main.tf:12:3-12:8: Unsupported argument
An argument named "bogus" is not expected here.
FAILED envs/dev/orders: validate found 1 error(s)With generate set, the check job also runs terragucci generate --check before the roots’ init, and fails on a generated file that differs from what terragucci generate writes:
refused: envs/prod/app/backend.tf differs from what terragucci generate writes from terragucci.yml:
- bucket = "hand-edited"
+ bucket = "acme-prod-state"
FAILED generate: 1 generated file out of line with terragucci.yml; run terragucci generate and commit what it writesWhen policy is set, the last step runs conftest verify or opa test, with the tests and key read from the default branch or the pull request’s target. The log is kept as terragucci-check/report.md.
A cron schedule in drift adds a drift job that runs on it.
drift: "0 6 * * *"| Drift job | What it does |
|---|---|
| Plans | each root with -refresh-only, so merged but unapplied code is not drift; a Terragrunt repo runs one refresh-only terragrunt run --all per wave. A choudoufu root under live resource markers plans in full, and what its plan would change is the drift (Live roots) |
| Identity | with oidc, the plan job’s read-only role; it never applies |
| Fails | when a root cannot be refreshed; drift alone does not fail it |
| Report | stage tf-drift (the report), kept as an artifact |
| Issue | at most one open per project, titled terragucci: drift found, below |
| By hand | npx terragucci stage tf-drift; with no forge token it writes the report and issue.md only, and --forge tells GitHub from Forgejo |
| This run finds | The issue |
|---|---|
| drift, and none is open | opens, with each drifted root and what moved in it |
| drift, and one is open | updates in place, so the issue always shows the latest run |
| no drift in any root | closes, with a comment naming the commit |
| no drift, but a root could not be planned | stays as it is, since that root is unknown |
| Forge | Issue token | Schedule |
|---|---|---|
| GitHub, Forgejo | the job’s own, issues: write |
the workflow, plus workflow_dispatch with no pr |
| GitLab | token_env, api scope |
CI/CD > Schedules, by hand |
Overdue drift checks
Section titled “Overdue drift checks”A schedule can stop with no error: GitHub turns a scheduled workflow off after 60 days with no activity in the repo, and a GitLab project runs the drift job only once someone adds a schedule. With drift set, each tf-plan checks:
-
The plan job asks the forge when the drift job last ran (scheduled or by hand).
-
It counts how often the cron has come round since then (or since the pipeline file was added, if the job never ran).
-
At two or more, the job log and plan note warn that the drift checks are overdue and say what to do.
| Forge | What the plan job reads | The note adds |
|---|---|---|
| GitHub | the workflow’s scheduled and manual runs, and its state; the plan jobs get actions: read |
the workflow is off, or how to turn it on again |
| GitLab | the scheduled pipelines and the active schedules, with token_env |
the project has no schedule, and the cron to add |
| Forgejo | the repo’s scheduled and manual runs | to check that Actions is on and a runner is up |
A forge that does not answer leaves a line in the log, and the plan goes on.
Gated waves
Section titled “Gated waves”A wave the gate policy holds waits for an approval bound to its set digest, and a changed plan stops it. See Approve a waiting wave.
A waiting wave prints its set digest and approval command.
State migrations
Section titled “State migrations”A file in migrations/ moves resources between roots’ states. stage tf-plan proves the first migration not yet applied (a later one plans against the states it writes): every affected root must plan with no change against its new state, or the job fails. Wave 1 of stage tf-apply proves it again before planning its roots and waits on the gate tf-migrate <name>. An approval lets it write the new states under each state’s lock and record each version before and after.
| Exit code | Means |
|---|---|
| 3 | the migration waits on its gate |
| 4 | a state moved since the approval |
| 1 | the migration cannot be proved or written |
See Move resources between roots and Migration files.
Rolling out a module version
Section titled “Rolling out a module version”How a root takes a module decides how a new version reaches it.
| The root’s module source | Roots a change reaches | How it goes out |
|---|---|---|
a local path, ../modules/x |
every root that includes it | one change set, applied in gated waves |
an exact pin: an oci:// tag or digest, a registry version, a git tag |
only those whose pin moved | tf-rollout, one pull request per wave |
a range such as ~> 1.4 |
cannot be told from the diff | neither; terragucci refuses it and tips you to pin it |
a provider version, in .terraform.lock.hcl |
everything using the provider | tf-rollout, moving the lock file a wave at a time |
terragucci rollout modules/network # dry run: the newest published version
terragucci rollout modules/network 1.4.0 --mode apply
terragucci rollout --provider hashicorp/aws 6.68.0 --mode applyNo version means the newest published tag; --from picks between disagreeing pins. Each run takes one step; --mode apply opens pull requests with token_env; it never applies or merges and never writes a default branch. With rollouts set, a scheduled job takes the next step for you.
| Exit code | Meaning |
|---|---|
| 0 | a step was taken, or the rollout is at its end |
| 3 | a pull request waits for a merge or an apply |
| 1 | the rollout stopped, after a closed pull request or a failed apply |
Roll out a new module version walks through a rollout.
A pin the rollout cannot move is reported with its reason and a tip:
| The pin | Tip |
|---|---|
a range such as ~> 1.4 |
rollout-floating-pin: pin one version |
| set from a variable or a local | rollout-literal-pin: write a literal, since a variable’s value is not in the diff |
| absent from the source | rollout-pin: add a ?ref=, ?tag= or version |
| two versions of the module in one directory | rollout-one-pin: pin every call at one version |
a provider with no .terraform.lock.hcl |
rollout-lock-file: commit the lock file |
A provider rollout rewrites each lock file, and any exact version constraint, for that provider alone; any other change stops the wave.
Reading module pins needs npm i -D @cdktn/hcl2json.
Publishing modules
Section titled “Publishing modules”modules.path names the modules; modules.publish takes an oci:// address, git-tags, or both.
| Binary | Roots pin |
|---|---|
| OpenTofu | oci:// sources |
| Terraform | git tags such as modules/network/v1.4.0 |
terragucci publish --dry-run
terragucci publishThe publish job runs after apply on the default branch and alone gets the registry credentials. With modules.attest it also signs each release and records it in the release ledger on chant/lifecycle. See Publish your modules.
Version bump rules
Section titled “Version bump rules”A changed module is published at the version these rules give, which the version-bump response also uses.
| Commit or file | Bump |
|---|---|
feat |
minor |
feat!: or BREAKING CHANGE: |
major |
| any other conventional commit | patch |
no conventional commit, with respond.version-bump: suggest |
the typed-decision service picks; patch below its threshold or without it |
a version file in the module |
overrides all of the above |
| no earlier release | 0.1.0 |
The grouped summary
Section titled “The grouped summary”The plan and drift stages group identical changes and name each destroy, replacement or refusal. See the plan report.
Gate policy
Section titled “Gate policy”tf-apply takes one of three policies for each wave.
| Policy | Waits for an approval when |
|---|---|
always |
the wave has at least one change; a wave whose plans change nothing never waits |
on-destructive |
the wave’s plans destroy or replace something |
never |
never; only pull request review and default-branch protection stand before the apply |
on-destroy is an older name for on-destructive, and terragucci.yml and --gate still accept it, so a pipeline rendered with it keeps working. SQL Yodeler and chant’s Op waves call the same policy on-destructive.
Run the approval command a waiting wave prints and run the stage again.
These docs count page views and clicks with PostHog. They set no cookies, store nothing in your browser, and send nothing when your browser asks not to be tracked.