# terragucci > The whole Terraform lifecycle, handled: grouped plans on every pull request, applies in gated waves, drift reports, module publishing and rollouts, for Terraform, OpenTofu, choudoufu, Terragrunt, Atmos, Terramate and CDK Terrain on GitHub, GitLab or Forgejo. Every page is true as written: a command or key on this site works as the page says. A key that `terragucci config check` refuses is not part of terragucci. The validation page (https://intentius.io/terragucci/reference/validation/) lists the checks every generated pipeline passes. terragucci is built on chant (https://intentius.io/chant/), which keeps the approvals and records on `chant/lifecycle`. SQL Yodeler (https://intentius.io/sql-yodeler/) manages ClickHouse and Postgres schemas on the same approvals. Agents adopting terragucci in a repository: start with https://intentius.io/terragucci/getting-started/agents/. A page with a prompt carries it under "Optional: hand this page to your coding agent", and llms.txt lists it under the page. Every prompt forbids apply, approve and merge; those stay with the person. --- # Set up with a coding agent Source: https://intentius.io/terragucci/getting-started/agents/ ## Optional: hand this page to your coding agent ```text Set up terragucci in this repository. Read https://intentius.io/terragucci/llms.txt first, then https://intentius.io/terragucci/getting-started/agents/ and follow it. Open a pull request with the result. Never apply, approve (a pull request review or `terragucci approve`), override a policy denial (`terragucci override`), use `--mode apply`, or merge; never touch `.chant/allowed_signers` or `chant/lifecycle`. ``` Setup needs no agent. [Get your first plan note](/terragucci/getting-started/) gives every step by hand, and only four opt-in features [run a model](/terragucci/#opt-in-coding-agent). Give the prompt above to an agent working in a repository of Terraform or OpenTofu roots, including a Terragrunt, Atmos, Terramate or CDK Terrain repo. ## Agent inputs | File | Holds | |---|---| | [`llms.txt`](https://intentius.io/terragucci/llms.txt) | every page, with a one-line description | | [`llms-full.txt`](https://intentius.io/terragucci/llms-full.txt) | the text of every page in one file | | Copy page as Markdown, under each page's title | that page's text, with its prompt | A task page's own prompt sits under its title as "Optional: hand this page to your coding agent". Each one carries the line in step 5. A key that `terragucci config check` refuses is not part of terragucci. ## Steps for the agent 1. Install terragucci with `npm i -D @intentius/terragucci` and run `npx terragucci init --dry-run --json`. Show the user the findings with their reasons and the files it would write. 2. Check what it found. Pass `--binary` or `--forge` if either is wrong, or ask the user. 3. Write a `terragucci.yml`, as small as possible, only if the defaults are wrong; [terragucci.yml keys](/terragucci/reference/config/) lists every key. 4. Run `npx terragucci init` to write the pipeline, and show the user the file it wrote. 5. Open a pull request with the config and the files `init` wrote; check `git status` so the commit holds nothing else. Then stop. Approvals are records on [`chant/lifecycle`](/terragucci/concepts/glossary/#chantlifecycle), and they belong to the user. ```text Never apply, approve (a pull request review or `terragucci approve`), override a policy denial (`terragucci override`), use `--mode apply`, or merge; never touch `.chant/allowed_signers` or `chant/lifecycle`. ``` 6. Under `approval: sealed`, tell the user to add their key to `.chant/allowed_signers` ([Before your first approval](/terragucci/getting-started/#first-approval)). Do not add a key yourself. The file must be on the default branch before the first merge that destroys something, because the apply reads it from before the merge. ## Rules for the agent - Set terragucci up or change its config from the shell with `--json`, and parse the envelope ([JSON output](/terragucci/reference/cli-json/)). - Read what terragucci already wrote through `terragucci mcp`, when the user has added it ([Read the estate over MCP](/terragucci/guides/agent-read-over-mcp/)). - Run `npx terragucci config check --json` after writing a config; it lists every problem at once. - Approvals belong to people. Print the `terragucci approve` command for a waiting wave but never run it; an approval made over MCP or ACP is refused. - [Responses to pipeline events](/terragucci/reference/responses/) need no model. - Credentials stay in the forge's secrets. The config names environment variables (`token_env`) and never holds a value. ## MCP or `--json` | You want to | Use | Why | |---|---|---| | find the roots, binary and forge, write a config, check it, write the pipeline | the CLI with `--json` | these commands write files in the repo, and the envelope gives each finding and the exit code | | plan a root, or run a response in dry run | the CLI with `--json` | they run the binary in the checkout | | read the estate, a root's last apply, a run's report, the state versions, the audit trail or the DORA figures | `terragucci mcp` | it reads the reports bucket with the credentials in its own environment, and every tool only reads | | find a waiting wave and its digest | `terragucci mcp`'s `waiting` tool | it prints the `terragucci approve` command for a person to run | | approve, apply, override or merge | neither | these belong to a person at a shell; the server has no such tool, and an approval made over MCP is refused | ## Next After setup, see [read the estate over MCP](/terragucci/guides/agent-read-over-mcp/), [summarize a refused wave](/terragucci/guides/agent-refused-wave/) (comment-only token), [change a pull request](/terragucci/guides/agent-change-a-pull-request/), [fix drift](/terragucci/guides/agent-fix-drift/), and [review a pull request](/terragucci/guides/agent-review-a-pull-request/). --- # terragucci Source: https://intentius.io/terragucci/ With the [0.4.7 release](/terragucci/reference/whats-new/), every approval is one command, `terragucci approve`. 0.4.5 added SQL over the reports bucket, resuming a killed choudoufu wave from its records, and imports from HCP Terraform, Scalr, Spacelift and env zero. Watch the [launch party](/terragucci/launch-party/) from the Rome launch. ## Your forge and your bucket terragucci reads your roots and runs your binary. Code, state backend and provider pins stay as you have them. | Where it runs | What you set | Page | |---|---|---| | GitHub, GitLab or Forgejo CI | nothing: `init` reads the forge from the `origin` remote and writes its pipeline format | [Per forge](/terragucci/guides/add-to-a-repo/#per-forge) | | S3, GCS or Azure Blob, for your state and the reports | `oidc` for keyless cloud identities, `reports.bucket` for the reports | [Credentials](/terragucci/reference/pipeline/#credentials), [Keep reports in a bucket](/terragucci/guides/keep-reports-in-a-bucket/) | ## See every run as a trace Every plan, apply and drift run sends one OpenTelemetry trace and the pipeline's metrics over OTLP to your collector. | The trace shows | Page | |---|---| | The stage, then a span per wave and per root, with digests, change counts and status | [Send traces and metrics](/terragucci/guides/send-traces-and-metrics/) | | The binary's own spans inside each root: OpenTofu's resources, and with choudoufu its provider calls and state lock waits | [Traces and metrics](/terragucci/reference/observability/#spans-from-the-binary) | | Metrics and the Grafana dashboards `init` writes with `dashboards: true` | [The metrics](/terragucci/reference/observability/#the-metrics) | ## The lifecycle around your roots `init` writes the pipeline for these stages in your forge's own format. [Architecture](/terragucci/concepts/how-it-works/). ## Waves and approvals A change goes out a few roots at a time, starting with the [canary](/terragucci/concepts/glossary/#canary) roots. By default a wave that destroys or replaces something waits for an approval, which covers exactly the plans it was shown. The report lists each wave with the digest of its plans and links the approval record that let it go out. [Waves and approvals](/terragucci/concepts/waves-and-approvals/). ## One note for two hundred plans Roots taking the same change share one diff that shows each value before and after. Every destroy is named. Each root's whole plan sits collapsed below, and [the full report](/terragucci/reference/report/) links each root's plan. ## Module publishing and pinned rollouts | You get | Turn it on | Page | |---|---|---| | Each changed module released after the apply, as a git tag or an OCI artifact, and with attest signed with cosign | `modules.publish`, `modules.attest` | [Publish your modules](/terragucci/guides/publish-modules/) | | A new version rolled out to every repo that pins the module, one pull request per wave | `terragucci rollout`, or `rollouts:` for a scheduled job | [Roll out a new module version](/terragucci/guides/roll-out-a-module-version/) | ## With choudoufu choudoufu is the OpenTofu fork from the team behind terragucci. Set `binary: choudoufu` ([set it up](/terragucci/guides/use-a-binary/#choudoufu)) and the pipeline also gets: | You get | Page | |---|---| | A live check in `tf-check` on every push, before the apply waves, with no cloud credentials | [Check](/terragucci/reference/stages/#check) | | One record per resource in an S3 backend you own, each write conditional, with no lock table or database to run; the state file is a cache | [Record writes](/terragucci/concepts/locking-and-staleness/#record-writes) | | How long a wave waited for a state lock, and how many tries it took | [State lock waits](/terragucci/reference/observability/#state-lock-waits) | | Which provider calls were slow, and the resource each was for | [What the report lists](/terragucci/reference/observability/#report-contents) | | Timings summed by resource type, so a large estate's report stays readable | [What the report lists](/terragucci/reference/observability/#report-contents) | | Changes to different resources of one estate apply at the same time; an overlapping change waits, or is refused, before it reaches the cloud; a killed apply leaves nothing to release | [Locking with choudoufu](/terragucci/concepts/locking-and-staleness/#with-choudoufu) | | A role scoped to one estate by an IAM condition on the `tofu-estate` tag, refused on another estate's resources | [Live resource markers](/terragucci/concepts/locking-and-staleness/#live-resource-markers) | ## Pull request automation These are plain CI jobs in your pipeline. | On a pull request | How you start it | Forges | Page | |---|---|---|---| | One grouped plan note and a `terragucci/plan` status | open or push to the pull request | all three | [Get your first plan note](/terragucci/getting-started/) | | Tips and policy results on the plan | on by default with the plan; `tips: false` turns tips off, `policy` adds checks | all three | [Tips](/terragucci/reference/tips/), [Policy](/terragucci/reference/policy/) | | A re-plan | comment `/terragucci plan [root]` | all three; GitLab through the comments schedule | [Re-plan from a comment](/terragucci/guides/re-plan-from-a-comment/) | | Root locks by comment | comment `/terragucci lock` or `/terragucci unlock` | GitHub and Forgejo; GitLab with `apply.when: pull-request` and `comments:` set | [Locks](/terragucci/guides/apply-before-merge/#locks) | | Root locks from the first plan | `locks: plan` | GitHub and Forgejo | [Locks](/terragucci/guides/apply-before-merge/#locks) | | Apply before merge, then merge after the last wave | `apply.when: pull-request`, then comment `/terragucci apply [wave-]` | GitHub, Forgejo, and GitLab with `comments:` set | [Apply before merge](/terragucci/guides/apply-before-merge/) | | An approval for a waiting wave | `approval: ledger`, `pr-review` or `sealed`; run `terragucci approve` | all three | [Approve a waiting wave](/terragucci/guides/approve-a-wave/) | | A recorded policy override | a listed person runs `terragucci override` | all three | [Override a policy denial](/terragucci/guides/approvals-runbook/#override-a-policy-denial) | | Comment commands on GitLab | `comments:` sets the schedule that answers merge request notes | GitLab | [Re-plan from a comment](/terragucci/guides/re-plan-from-a-comment/) | ### Opt-in: coding agent Only these four features run a model. Each is off until you set it up and needs a model API key in your forge's secrets. | Feature | Turn it on | Forges | Page | |---|---|---|---| | Comment `/terragucci agent ` and an agent commits the change to the pull request | `agent.comment` in `terragucci.yml` | GitHub and Forgejo | [Have an agent change a pull request](/terragucci/guides/agent-change-a-pull-request/) | | When drift opens the drift issue, an agent changes the code to match what is live, in a pull request | `agent.drift` in `terragucci.yml` | GitHub and Forgejo | [Have an agent fix drift](/terragucci/guides/agent-fix-drift/) | | A note on each pull request comparing its description with its plan: risk, mismatches, questions | `review.agent` in `terragucci.yml` | all three; GitLab through the comments schedule | [Have a model review a pull request](/terragucci/guides/agent-review-a-pull-request/) | | A summary of what moved in a refused wave | add the explain-refusal job under `own_jobs` | all three | [Have an agent summarize a refused wave](/terragucci/guides/agent-refused-wave/) | A coding agent at your desk reads the estate (down to a root's last apply), the audit trail and the DORA figures through `terragucci mcp`, a read-only MCP server that runs no model ([Read the estate over MCP](/terragucci/guides/agent-read-over-mcp/)). ## Security | Guard | What it means | |---|---| | Plan and apply use separate roles | `oidc` takes one identity for plan and one for apply, and terragucci rejects the same role for both | | The plan job cannot apply | it runs the pull request's code with the read-only plan role and never gets the apply role; under `approval: sealed` an approval also needs a signer's key, which no job holds | | A changed plan is refused | a gated wave whose plans moved after the approval applies nothing and names both digests | [The threat model](/terragucci/reference/threat-model/) lists what each job can reach on each forge. ## Limits What terragucci leaves to your forge and your cloud, and what it does not support. Recorded checks show what is proven on each tool and forge ([validation](/terragucci/reference/validation/)). Apache-2.0. ## Hand the setup to your agent [Add terragucci to a repo](/terragucci/guides/add-to-a-repo/) has the same steps by hand. Or paste this into Claude Code, Codex or Cursor in your repository. [The agent page](/terragucci/getting-started/agents/) says what the agent does. It never applies and never approves. Then the [guides](/terragucci/guides/add-to-a-repo/) cover tasks and the [reference](/terragucci/reference/config/) the details; the [concepts](/terragucci/concepts/how-it-works/) and the [glossary](/terragucci/concepts/glossary/) explain the reasons and the terms. terragucci is built on [chant](https://intentius.io/chant/), and [SQL Yodeler](https://intentius.io/sql-yodeler/) brings ClickHouse and Postgres schemas through the same approvals. --- # Architecture Source: https://intentius.io/terragucci/concepts/how-it-works/ `npx terragucci init` writes a pipeline file for your forge; its CI runs everything in terragucci's image. It needs no server or hosted service and keeps state in your backend. The package is Apache-2.0. ## A change, start to finish Each step names its stage and the identity it runs with. All of them run on GitHub, GitLab and Forgejo. 1. The pull request: `tf-check` and `tf-plan`, with the read-only plan identity. `tf-check` formats and validates every root on the branch push (on a pull request only from a fork). `tf-plan` plans only the roots the change reaches, one [layer](/terragucci/concepts/glossary/#layer) at a time. 2. The plan note: `tf-plan` posts one grouped comment naming every destroy and replacement, and sets `terragucci/plan`. `/terragucci plan` re-plans from a comment; on GitLab it needs the [`comments`](/terragucci/reference/config/#keys) schedule. 3. The merge: any push to the default branch runs `tf-apply` with the apply identity, one push at a time. Under the default `on-destructive` gate only a wave that destroys or replaces waits. With [`apply.when: pull-request`](/terragucci/reference/config/#apply-before-merge), a writer's `/terragucci apply` comment applies the open head instead (on GitLab, with the `comments` schedule). The [guide](/terragucci/guides/apply-before-merge/) lists what it refuses. 4. The waves: `tf-apply` runs one job per [wave](/terragucci/concepts/glossary/#wave), `waves.canary` roots first, then dependency order. With [`waves.jobs`](/terragucci/concepts/waves-and-approvals/), a large wave gets a job that decides it and share jobs that apply it. Each wave plans after the previous one applied. 5. The approval: no stage runs. A waiting job exits 3 and prints the approve command for its [set digest](/terragucci/concepts/glossary/#set-digest). A person runs `terragucci approve` to record an approval on [`chant/lifecycle`](/terragucci/concepts/glossary/#chantlifecycle), using their push access to that branch; under `approval: pr-review` a review of the head also counts. With `approval: sealed` the approval also carries a seal that must verify against `.chant/allowed_signers` from the commit before the applied one. 6. The apply: `tf-apply`, with the apply identity. `terragucci approve` or a rerun of the job restarts the waiting wave. So does a push or a `/terragucci apply` comment (on GitLab, with the `comments` schedule). The wave re-plans and refuses on a changed digest, and never applies a root twice. A comment that fails a [check](/terragucci/reference/pipeline/#ignored-comments) runs nothing. 7. Drift: with `drift:` set to a schedule, `tf-drift` plans every root with `-refresh-only` under the plan identity and keeps one issue updated, closing it when drift is gone. It never applies. ## Components | Where | What | |---|---| | your machine | `npx terragucci init`, once and after a config change; `terragucci approve`, to approve a wave | | your forge's CI | every stage: check and plan on pull requests, apply on the default branch or the pull request, drift on schedule, comment jobs | | your repository | the pipeline file, an optional `terragucci.yml`, the `chant/lifecycle` branch, and under `approval: sealed` [`chant.workspace.json`](/terragucci/concepts/glossary/#chantworkspacejson) and the signers file | | your cloud | your state (with [choudoufu](/terragucci/concepts/glossary/#choudoufu), a tag on each resource in its place), and the plan and apply identities the jobs assume over OIDC | | your bucket, if you set one | the reports and their index, the resource inventory and change history, and with `terragucci estate` and `terragucci audit` the delivery metrics and the audit trail ([the layout](/terragucci/reference/reports-bucket/)) | The plan identity is read-only because pull request code runs with it. Applying before merge gives the apply identity to unmerged code, so it is opt-in. ## Next - [Get your first plan note](/terragucci/getting-started/) sets it up on a repository. - [The tutorial](/terragucci/tutorial/) runs each step on a 15-root example on your laptop. - [Waves and approvals](/terragucci/concepts/waves-and-approvals/) explains the waves and the gate. - [The approvals runbook](/terragucci/guides/approvals-runbook/) has the commands for signers, pending waves and refusals. - [The glossary](/terragucci/concepts/glossary/) defines terragucci's words and lists the ones that mean something else in Terraform. --- # Get your first plan note Source: https://intentius.io/terragucci/getting-started/ ## Optional: hand this page to your coding agent ```text Read https://intentius.io/terragucci/getting-started/ and https://intentius.io/terragucci/getting-started/agents/. Set up terragucci in this repository. Run `npx terragucci init --dry-run --json` and show me the findings before writing anything. Then run `npx terragucci init` and open a pull request with the files it wrote, package.json and package-lock.json only. Do not create secrets. Never apply, approve (a pull request review or `terragucci approve`), override a policy denial (`terragucci override`), use `--mode apply`, or merge; never touch `.chant/allowed_signers` or `chant/lifecycle`. ``` CI does this: everything runs as jobs in your CI and lands in your git and your bucket. No account, no sign-in, no platform. | You get | Where it is shown | |---|---| | No server to host | [What runs where](/terragucci/concepts/how-it-works/#components) | | GitHub, GitLab or Forgejo | [Per forge](/terragucci/guides/add-to-a-repo/#per-forge) | | Object storage on AWS, GCP or Azure, for state and reports | [Credentials](/terragucci/reference/pipeline/#credentials), [Keep reports in a bucket](/terragucci/guides/keep-reports-in-a-bucket/) | | Tracing and metrics | [Send traces and metrics](/terragucci/guides/send-traces-and-metrics/) | | Rich lifecycles | [Architecture](/terragucci/concepts/how-it-works/) | | Gated waves | [Waves and approvals](/terragucci/concepts/waves-and-approvals/) | | Aggregated plan output | [Plan grouping](/terragucci/concepts/why-plans-are-grouped/) | | Module publishing and pinned rollouts | [Publish your modules](/terragucci/guides/publish-modules/), [roll out a version](/terragucci/guides/roll-out-a-module-version/) | ## Result A pull request with one plan note that groups the roots a change reaches and names every destroy. ## Prerequisites | You need | Why | |---|---| | a repo of Terraform or OpenTofu roots, or a Terragrunt, Atmos, Terramate or CDK Terrain repo, on GitHub, GitLab or Forgejo | terragucci reads the roots and the forge from the repo | | Node.js 22 or later, on your machine only | `init` is an npm package; the pipeline runs in terragucci's CI image | | a way for CI to read your state and providers | the plan job runs `plan`; see [Environment variables and credentials](/terragucci/reference/environment/) | | push access to the repo | you commit the generated pipeline | ## Steps 1. Install terragucci from the root of the repo. ```bash npm i -D @intentius/terragucci ``` 2. Preview what it finds. This writes nothing. ```bash npx terragucci init --dry-run ``` ```text found 15 roots in 2 layers, tofu 1.13.1 (tofu on the path), forge github (the origin remote (github.com)) would write .github/workflows/terragucci.yml no terragucci.yml needed (defaults fit) dry run: nothing was written ``` | Found | Means | If wrong | |---|---|---| | [roots](/terragucci/concepts/glossary/#root) | directories with a backend, `cloud` block, [choudoufu](/terragucci/concepts/glossary/#choudoufu) `live` block or provider; in a Terragrunt repo, the units `terragrunt find` lists | `--json` says why | | layers | dependency order, the [waves](/terragucci/concepts/glossary/#wave) | | | [binary](/terragucci/guides/use-a-binary/) | `tofu` or `terraform` and its version; Terragrunt runs it underneath | `--binary` | | forge | the `origin` remote | `--forge` | If you have a `terragucci.yml`, run `npx terragucci config check` first. It names the approval mode and any unknown key, with the keys it accepts. The example's output: 3. Write the pipeline. ```bash npx terragucci init ``` **GitHub** ```text found 15 roots in 2 layers, tofu 1.13.1 (tofu on the path), forge github (the origin remote (github.com)) wrote .github/workflows/terragucci.yml no terragucci.yml needed (defaults fit) ``` **GitLab** ```text found 15 roots in 2 layers, tofu 1.13.1 (tofu on the path), forge gitlab (the origin remote (gitlab.com)) wrote .gitlab/terragucci.yml wrote .gitlab-ci.yml no terragucci.yml needed (defaults fit) ``` The jobs go in `.gitlab/terragucci.yml`. A `.gitlab-ci.yml` you already have keeps its jobs and gains an `include:` of that file; [add terragucci to a repo](/terragucci/guides/add-to-a-repo/) has the cases. **Forgejo** ```text found 15 roots in 2 layers, tofu 1.13.1 (tofu on the path), forge forgejo (the origin remote (codeberg.org)) wrote .forgejo/workflows/terragucci.yml no terragucci.yml needed (defaults fit) ``` The defaults need no `terragucci.yml`; [terragucci.yml keys](/terragucci/reference/config/) lists every key. 4. Give the pipeline a token. **GitHub** The jobs use the run's `github.token`, so there is nothing to add. **GitLab** Add `GITLAB_TOKEN` under Settings, CI/CD, Variables: a masked project access token with the `api` scope. | `terragucci.yml` | Plan job | Plan note | `GITLAB_TOKEN` | |---|---|---|---| | no `gitlab` key (the default) | holds the token, so a merge request's code can use it as the project's bot | posted by the plan job at once | a masked variable | | `gitlab: { token: protected }`, with `comments:` set | holds no token | posted by the comments schedule's job | a masked and protected variable, on a protected default branch | **Forgejo** Each run brings its own token, so no secret is needed. Cloud roles over OIDC and the required status are in [Add terragucci to a repo](/terragucci/guides/add-to-a-repo/). 5. Commit and open a pull request. ```bash git switch -c add-terragucci git status git add -A git commit -m "Add terragucci" git push -u origin add-terragucci ``` `git status` should list the pipeline files `init` wrote and the two package files. Put `node_modules` in `.gitignore`. Open the pull request, then change a line in one root, since a change that touches no root has nothing to plan. 6. Read the plan note. The plan job leaves one comment and a `terragucci/plan` status: **GitHub** A pull request comment, with the status in the checks box. **GitLab** A merge request note; the widget above it counts creates, updates and deletes. **Forgejo** [The plan report](/terragucci/reference/report/) explains each part. ## First approval With the default [gate](/terragucci/concepts/glossary/#gate), a wave waits for a person only when its plan destroys or replaces something. You approve it from your machine with [`terragucci approve`](/terragucci/reference/cli/#approve). | Approval mode | Set up once | |---|---| | `ledger`, the default | nothing | | `pr-review`, a pull request review counts | `approval: pr-review` in `terragucci.yml`; to hold unreviewed changes, require `terragucci/approval` on GitHub or Forgejo, or an approval rule on GitLab: [approve by review](/terragucci/guides/approve-a-wave/#approve-by-review-approval-pr-review) | | `sealed`, signed approvals | the same, and your ssh public key in `.chant/allowed_signers` on the default branch before the first change that destroys something: [set up the signers file](/terragucci/guides/approve-a-wave/#set-up-the-signers-file) | ## Next - [Approve a waiting wave](/terragucci/guides/approve-a-wave/) when a change destroys something. - [Turn on drift checks](/terragucci/guides/turn-on-drift-checks/) to find changes made outside Terraform. - [Govern many repos from one place](/terragucci/guides/govern-many-repos/) with a [control repo](/terragucci/concepts/control-repo/) once one repo works. - [Threat model](/terragucci/reference/threat-model/) for what the jobs can reach and the branch protection they rely on. --- # Boot the example Source: https://intentius.io/terragucci/tutorial/ ## Optional: hand this page to your coding agent ```text Read https://intentius.io/terragucci/tutorial/. Clone https://github.com/INTENTIUS/terragucci, run `npm ci` and `just example up` as the page says, and report the Forgejo URL and the boot time. If it fails, match the error to "Troubleshooting" and tell me the fix. `just example up` applies the example to floci, its local AWS stand-in. That is the one exception to the line below, and only against floci. Never apply, approve (a pull request review or `terragucci approve`), override a policy denial (`terragucci override`), use `--mode apply`, or merge; never touch `.chant/allowed_signers` or `chant/lifecycle`. ``` You need no cloud account to run terragucci against a small shop's estate on your machine. | Page | You see | Time | |---|---|---| | Boot the example (this page) | 15 roots applied by a local pipeline | about 10 min the first time | | [Your first pull request](/terragucci/tutorial/first-pull-request/) | the check stage passing, then failing on formatting | 5 min | | [One note for fifteen plans](/terragucci/tutorial/one-note/) | twelve plans grouped in one note | 5 min | | [Waves and approvals](/terragucci/tutorial/waves/) | a change applied a wave at a time | 10 min | | [A wave that changed](/terragucci/tutorial/changed-wave/) | an approved wave refusing to apply after one of its roots changed | 5 min | | [Drift](/terragucci/tutorial/drift/) | a drift report naming the root | 5 min | | [Publishing and pinning modules](/terragucci/tutorial/modules/) | a module version rolled out one pull request per wave | 10 min | | [Tips](/terragucci/tutorial/tips/) | a tip on a floating provider version | 5 min | | [See your runs](/terragucci/tutorial/see-your-runs/) | runs on terragucci's Grafana dashboards | 10 min | | [The same shop on Terragrunt](/terragucci/tutorial/terragrunt/) | the estate as 15 Terragrunt units | 15 min | | [Clean up, then your own repo](/terragucci/tutorial/your-repo/) | terragucci on a repository of your own | 10 min | ## Prerequisites | You need | Why | |---|---| | Docker, with 4 GB of memory free | the forge, its runner and the AWS stand-in run as containers | | [`just`](https://just.systems), `git` and `jq` | the commands below are `just` recipes | | Node.js 22 or later | `just example up` runs `npx tsx` and builds terragucci from the clone | | about ten minutes | the first boot pulls the images and builds the CI images | Clone terragucci and install its dependencies. ```bash git clone https://github.com/INTENTIUS/terragucci && cd terragucci npm ci ``` ## Boot it ```bash just example up ``` It starts Forgejo (a local forge) with its runner, and floci (an AWS stand-in). Then it pushes the example and the pipeline applies every root: The last line is the boot time. Later boots skip the image pulls and builds. ## Result Open the Forgejo link, at `localhost:3300` unless you changed the port. The repo is the shop's whole estate: | Path | What | |---|---| | `modules/service/` | one service: a bucket, a jobs queue, a records table | | `envs/dev/platform` | each environment's logs bucket | | `envs/dev/orders` | orders, payments, search and email, each calling `modules/service` | | `envs/staging/...`, `envs/prod/...` | the same five roots each | | `terragucci.yml` | the whole terragucci config | Three environments of five roots make fifteen. A change to `modules/service` reaches twelve roots. Prod payments differs because its jobs queue has a dead-letter queue. The platform roots hold the logs bucket, so terragucci applies them before the services. The pipeline run shows every root applied: `terragucci.yml` is four lines. Dev is the canary wave, and drift is checked every morning at six: ```yaml binary: tofu waves: canary: ["envs/dev/*"] drift: "0 6 * * *" ``` With Terraform, `binary: terraform` runs the same pipeline ([Choose your binary](/terragucci/guides/use-a-binary/)). Terragrunt users get the same shop as units on [its own page](/terragucci/tutorial/terragrunt/). ## Troubleshooting | You see | Try | |---|---| | `port is already allocated` | port 3300 or 4580 is taken. Set `TERRAGUCCI_FORGEJO_PORT` or `TERRAGUCCI_FLOCI_PORT` to a free one and run `just example up` again. | | the pipeline waits and never starts | the runner has not registered. `docker logs terragucci-forgejo-runner` shows why; `just example up` re-registers it. | | containers restart or Docker stops answering | Docker is short of memory. Give it 4 GB. | | a link to `http://forgejo:3000/...` does not open | that is Forgejo's address inside the stack. On your laptop the page is at `http://localhost:3300/...`: change the start of the link. | ## Reset and restart `just example reset` closes every pull request and puts the estate back the way it booted. `just example down` removes everything, and `just example up` brings it back from nothing. Go on to [your first pull request](/terragucci/tutorial/first-pull-request/). --- # Add terragucci to a repo Source: https://intentius.io/terragucci/guides/add-to-a-repo/ ## Optional: hand this page to your coding agent ```text Read https://intentius.io/terragucci/guides/add-to-a-repo/ and https://intentius.io/terragucci/getting-started/agents/. Set up terragucci in this repository for its forge. Run `npx terragucci init --dry-run --json` and show me the findings before writing anything. Then run `npx terragucci init` and open a pull request with the files it wrote, package.json and package-lock.json only. List the token, OIDC roles and required status I must set in the forge settings. Do not create secrets, variables or branch protection yourself. Never apply, approve (a pull request review or `terragucci approve`), override a policy denial (`terragucci override`), use `--mode apply`, or merge; never touch `.chant/allowed_signers` or `chant/lifecycle`. ``` ## Result A plan note and a `terragucci/plan` status on every pull request or merge request, and one apply job per wave on the default branch (with [`waves.jobs`](/terragucci/concepts/waves-and-approvals/#a-wide-wave-across-jobs), a deciding job and its share jobs). Everything runs as jobs in your own CI and lands in your git and your bucket; there is no account to create. Pick your forge in any tab and the other forge tabs on the site follow. ## Per forge | | GitHub | GitLab | Forgejo | |---|---|---|---| | Pipeline file | `.github/workflows/terragucci.yml` | `.gitlab/terragucci.yml`, included from `.gitlab-ci.yml` | `.forgejo/workflows/terragucci.yml` | | Hosts `init` knows | github.com | gitlab.com, `gitlab.*` | codeberg.org, `forgejo.*`, `gitea.*` | | Runner | Actions enabled | one that runs Docker images | Actions enabled, a runner labelled `docker` or the one [`runner`](#self-hosted-runners) names | | Token | the run's `github.token` | `GITLAB_TOKEN` variable, `api` scope | the run's own token | | Cloud roles over OIDC | GitHub's identity token | `id_tokens` | Forgejo 15 and Runner 12.5 or later | | Require | `terragucci/plan` in branch protection | "Pipelines must succeed" | `terragucci/plan` in branch protection | | Report | `terragucci-report` artifact of the run the note links | a job artifact the note links, plus the merge-request widget | `terragucci-report` artifact of the run the note links | | Two pushes' applies | side by side | side by side | side by side, each push to the default branch in a group of its own commit | Self-managed GitLab works the same. See [applies side by side](/terragucci/reference/pipeline/#applies-side-by-side). ## Self-hosted runners ```yaml runner: default: [self-hosted, linux] apply: [self-hosted, prod] ``` Every job then asks for a runner with both labels of its stage: `runs-on` on GitHub and Forgejo, `tags` on GitLab. Here the apply jobs run on runners labelled `prod`, such as ones inside the network the apply role reaches, and every other job on the rest. `runner: [self-hosted, linux]` alone puts every job on one set. On GitHub, `group: ` picks a runner group. Each job runs in terragucci's image, so the runner must start containers: Docker on a GitHub runner, the Docker executor on GitLab, a `docker://` label on Forgejo. Run `npx terragucci init` to write the labels in; [Runners](/terragucci/reference/config/#runners) has the rest. ## Agent features Setup and every pull request feature are plain CI jobs that need no agent; four opt-in features run a model. | CI jobs, no agent | Page | |---|---| | Setup with `init` (an agent can run it for you) | [Set up with a coding agent](/terragucci/getting-started/agents/) | | The plan note and `terragucci/plan` status | [Get your first plan note](/terragucci/getting-started/) | | Tips and policy checks | [Tips](/terragucci/reference/tips/), [Policy](/terragucci/reference/policy/) | | A `/terragucci plan` re-plan; on GitLab through the `comments:` schedule | [Re-plan from a comment](/terragucci/guides/re-plan-from-a-comment/) | | `/terragucci lock` and `/terragucci unlock` (GitHub, Forgejo, and GitLab with `comments:` and `apply.when: pull-request` set); `locks: plan` (GitHub and Forgejo) | [Locks](/terragucci/guides/apply-before-merge/#locks) | | Apply before merge and `/terragucci apply` (GitHub, Forgejo, and GitLab with `comments:` set) | [Apply before merge](/terragucci/guides/apply-before-merge/) | | Approvals in every mode, `terragucci approve` | [Approve a waiting wave](/terragucci/guides/approve-a-wave/) | | A recorded policy override | [Override a policy denial](/terragucci/guides/approvals-runbook/#override-a-policy-denial) | | Drift checks | [Turn on drift checks](/terragucci/guides/turn-on-drift-checks/) | | Module publishing and rollouts | [Publish your modules](/terragucci/guides/publish-modules/), [roll out a version](/terragucci/guides/roll-out-a-module-version/) | | Opt-in: coding agent | Off until | Page | |---|---|---| | The `/terragucci agent ` comment (GitHub and Forgejo) | `agent.comment` is set, with a model API key secret | [Have an agent change a pull request](/terragucci/guides/agent-change-a-pull-request/) | | A code change that matches what drift found, in a pull request (GitHub and Forgejo) | `agent.drift` is set, with a model API key secret | [Have an agent fix drift](/terragucci/guides/agent-fix-drift/) | | A note comparing a pull request's description with its plan (on GitLab through the `comments:` schedule) | `review.agent` is set, with a model API key secret | [Have a model review a pull request](/terragucci/guides/agent-review-a-pull-request/) | | A summary of a refused wave | you add its job by hand, with a model API key secret | [Have an agent summarize a refused wave](/terragucci/guides/agent-refused-wave/) | ## Prerequisites | You need | For | |---|---| | Terraform or OpenTofu roots, or a Terragrunt, Atmos, Terramate or CDK Terrain repo | what `init` finds | | Node.js 22 or later on your machine | running `init` | | admin on the repo or project | the token, the cloud roles and the required status | ## Steps 1. Install terragucci and run `init` from the root of the repo. ```bash npm i -D @intentius/terragucci npx terragucci init ``` **GitHub** ```text found 15 roots in 2 layers, tofu 1.13.1 (tofu on the path), forge github (the origin remote (github.com)) wrote .github/workflows/terragucci.yml no terragucci.yml needed (defaults fit) ``` **GitLab** ```text found 15 roots in 2 layers, tofu 1.13.1 (tofu on the path), forge gitlab (the origin remote (gitlab.com)) wrote .gitlab/terragucci.yml wrote .gitlab-ci.yml no terragucci.yml needed (defaults fit) ``` The jobs go in `.gitlab/terragucci.yml`, and `init` touches your `.gitlab-ci.yml` only like this: | Your `.gitlab-ci.yml` | `init` | |---|---| | none | writes one that includes `.gitlab/terragucci.yml` | | your own jobs | adds `- local: .gitlab/terragucci.yml` to its `include:` and keeps the rest (`updated .gitlab-ci.yml`) | | an `include:` that is one value, not a list | stops and names the entry to add | | its own `stages:` | stops unless the list has `.gitlab/terragucci.yml`'s stages (`check`, `plan`, `apply` and the rest) in that order, and names them | | a job named like one of terragucci's (`check`, `plan`, `apply-wave-1`) | stops: GitLab would merge the two jobs | With no `stages:` of your own, GitLab's default stages put your `build` and `test` jobs before terragucci's stages and `deploy` after the apply. Your top-level `variables:` and `default:` reach terragucci's jobs too. **Forgejo** ```text found 15 roots in 2 layers, tofu 1.13.1 (tofu on the path), forge forgejo (the origin remote (codeberg.org)) wrote .forgejo/workflows/terragucci.yml no terragucci.yml needed (defaults fit) ``` For a host on another scheme or port, set `url` in `terragucci.yml`. Pass `--forge` once for a host the table does not list; `init` records it in `terragucci.yml`. In a Terragrunt repo `init` finds the units and writes the same files. [Use Terragrunt](/terragucci/guides/use-terragrunt/) covers the version, excludes and roles. 2. Give the pipeline a token. **GitHub** The jobs use the run's `github.token`. The plan job adds `statuses: write` and `pull-requests: write` (and `actions: read` with a `drift` schedule) and runs only for pull requests from branches in the same repo, so a fork reaches no job with them. **GitLab** Add `GITLAB_TOKEN` under Settings, CI/CD, Variables: a masked project access token with the `api` scope. `token_env` in `terragucci.yml` names another variable. | `terragucci.yml` | Plan job | Plan note | `GITLAB_TOKEN` | |---|---|---|---| | no `gitlab` key (the default) | holds the token, so a merge request's code can use it as the project's bot | posted by the plan job at once | a masked variable | | `gitlab: { token: protected }`, with `comments:` set | holds no token | posted by the comments schedule's job | a masked and protected variable, on a protected default branch | [The threat model](/terragucci/reference/threat-model/) says what each choice leaves open. **Forgejo** Each run brings its own token; there is no secret to add. 3. Give the jobs cloud access. Set `oidc` in `terragucci.yml` and run `npx terragucci init` again: ```yaml oidc: plan_role: arn:aws:iam::111122223333:role/terragucci-plan apply_role: arn:aws:iam::111122223333:role/terragucci-apply ``` **GitHub** Each role's trust policy must accept your repo. **GitLab** Roles come through `id_tokens`. GitLab-managed Terraform state limits concurrent inits, so fewer Terragrunt units run at once. **Forgejo** OIDC needs Forgejo 15 and Forgejo Runner 12.5 or later; the jobs set `enable-openid-connect: true`. The issuer is your Forgejo URL plus `/api/actions`. Older versions serve no token; give their runner static credentials instead. [Environment variables and credentials](/terragucci/reference/environment/#cloud-roles-over-oidc) has the trust policies. 4. Commit, push and open a pull request. **GitHub** ```bash git switch -c add-terragucci git add .github package.json package-lock.json git commit -m "Add terragucci" git push -u origin add-terragucci ``` **GitLab** ```bash git switch -c add-terragucci git add .gitlab .gitlab-ci.yml package.json package-lock.json git commit -m "Add terragucci" git push -u origin add-terragucci ``` **Forgejo** ```bash git switch -c add-terragucci git add .forgejo package.json package-lock.json git commit -m "Add terragucci" git push -u origin add-terragucci ``` Then change a line in one root and push again, since a change that touches no root has nothing to plan. 5. Require the status so that a failed plan blocks the merge. With `binary: choudoufu` ([set it up](/terragucci/guides/use-a-binary/#choudoufu)), the check also runs a live check with no cloud credentials. **GitHub** Add `terragucci/plan` to the default branch's protection rule as a required status. When the check fails, so does the run; its log shows the diff `tofu fmt` would make: **GitLab** Turn on "Pipelines must succeed" under Settings, Merge requests. The pipeline fails with the check: **Forgejo** In the default branch's protection rule, enable status checks with `terragucci/plan` as the pattern. The failed check is a red cross on the commit, and the fmt job pushes the formatting after it: 6. Make approval possible. A wave that destroys something waits until a person runs `npx terragucci approve` from a checkout or, under [`approval: pr-review`](/terragucci/guides/approve-a-wave/#approval-modes), reviews the pull request. Signing is optional; only `approval: sealed` needs a signers file ([before your first approval](/terragucci/getting-started/#first-approval)). ## Next - [Approve a waiting wave](/terragucci/guides/approve-a-wave/) - [Keep reports in a bucket](/terragucci/guides/keep-reports-in-a-bucket/) - [The tutorial](/terragucci/tutorial/) runs all of this on a local Forgejo with a 15-root example. --- # Approve a waiting wave Source: https://intentius.io/terragucci/guides/approve-a-wave/ ## Optional: hand this page to your coding agent ```text Read https://intentius.io/terragucci/guides/approve-a-wave/. Find the waiting wave with `npx terragucci approve --dry-run`, summarize its report (destroys, replacements, roots) and print the command it gives for me. The `--dry-run` preview is the one form of `terragucci approve` you may run; never sign. Never apply, approve (a pull request review or `terragucci approve`), override a policy denial (`terragucci override`), use `--mode apply`, or merge; never touch `.chant/allowed_signers` or `chant/lifecycle`. ``` ## Result One wave applied, with an approval recorded in your repo that names the exact plans you read. ## Prerequisites | You need | Detail | |---|---| | A wave that ran | After a merge, or from `/terragucci apply` on an open pull request when [`apply.when`](/terragucci/reference/config/#apply-before-merge) is `pull-request`. | | A gate | `gate` set to `on-destructive` (the default) or `always`. With `never`, no wave waits ([Gate policy](/terragucci/reference/stages/#gate-policy)). | | terragucci | `npm i -D @intentius/terragucci`, where you approve. | | Write access | `terragucci approve` pushes a commit to `chant/lifecycle` with your own git credentials. | | Under `approval: sealed` only | An ssh key of yours in `.chant/allowed_signers` on the default branch ([setup](#set-up-the-signers-file), once per repo). | | To be a person | Nothing signs off for you. | ## Approval modes Each mode counts different approvals; `approval` in `terragucci.yml` picks one. Every mode binds the wave's set digest: an approval of other plans is refused as changed. | Mode | Counts | Setup | What it proves | |---|---|---|---| | `ledger` (default) | any approval of the digest on `chant/lifecycle`, signed or not | none | someone with push access to `chant/lifecycle` approved these exact plans. Anyone who can push there can write an approval in anyone's name, so it records the plans and does not prove who approved. | | `pr-review` | the merged pull request's approval of its head, by a reviewer other than its author with write access, when the wave plans what the review saw; and any `terragucci approve` of the digest, as under `ledger` | on GitHub or Forgejo, optionally require `terragucci/approval` in branch protection; on GitLab, an approval rule | a writer other than the author approved the head whose plans these are. An approval by the user the pipeline's token acts as never counts. A `terragucci approve` still proves only the plans. | | `sealed` | only an approval sealed with `--sign` by a key the signers file lists for its approver | a [signers file](#set-up-the-signers-file); `init` lists each wave under [`identity.gates`](/terragucci/concepts/glossary/#identitygates) | a listed person approved these exact plans | A wave reads the mode at base, like the signers file ([Rule commit](#rule-commit)). `terragucci config check` prints the mode in force and its source. | `terragucci.yml` | Mode in force | |---|---| | sets `approval` | that mode | | sets none, and [`chant.workspace.json`](/terragucci/concepts/glossary/#chantworkspacejson) lists gates under `identity.gates` | `sealed`; set `approval: sealed` to say so, or `approval: ledger` and run `terragucci init` to drop the gates | | sets none, and no gates are listed | `ledger` | ## Approve by review (`approval: pr-review`) Works on every forge. GitLab's merge request approvals name no commit, so there an approval counts only after the merge request's latest push. 1. Open the pull request's plan note. Its waves table gives each wave's change digest (what the apply compares against) and whether the gate will hold it. 2. Read the plans, then approve the pull request on its head. **GitHub** With write access, pick Files changed > Review changes > Approve. **GitLab** As a member with the Developer role or higher, approve the merge request after its last push. Once withdrawn, it no longer counts. **Forgejo** Under Files changed, choose Review > Approve. Only an official review counts; Forgejo makes a review official when its author can write to the repo. The author's own approval never counts. A review counts only on the commit it names, so a push after your review needs a fresh one; a latest review that asks for changes holds every wave back. 3. Keep unreviewed changes from merging. | Forge | Require | |---|---| | GitHub | `terragucci/approval` under Settings > Branches (or Rules) > the default branch > Require status checks. It is pending while a wave the gate will hold has no approving review of the head. | | Forgejo | `terragucci/approval` under Settings > Branches > the default branch's rule > Status check patterns, with the same meaning. | | GitLab | an approval rule under Settings > Merge requests (GitLab Premium and Ultimate); the pipeline posts no `terragucci/approval` there. | 4. Merge. After that, each gated wave plans the merge commit and checks its change digest against the one the note recorded for the reviewed head. | Digest at merge | What the wave does | |---|---| | same digest | records the approval on `chant/lifecycle` (`via: pr-review`, the pull request, its head and the reviewers) and applies | | another digest (the plans moved after the review) | applies nothing, exits 4 and prints the `terragucci approve` command for the new digest | | no row for the wave in the note, no approving review, or a direct push | waits for a `terragucci approve`, as under `ledger` | 5. A wave waiting for a review can still get one. On GitHub or Forgejo, approve the merged pull request on its head. GitLab takes no approval after the merge, so there the merge request needs it while still open. Then run the wave again ([step 4 below](#approve-from-a-checkout)): it finds the review and applies. With [`notify`](/terragucci/guides/notify-a-chat-channel/#approve-from-the-message-by-review) set, the waiting wave's chat message links the review page. ## Approve from a checkout 1. Find the waiting wave and its command. | Where | What it gives | |---|---| | the pull request's plan note, before the merge | each wave the gate will hold, with `terragucci approve wave- --plan `; after the merge the wave asks for that digest unless its plans moved | | `terragucci/apply` on the commit | the waiting wave's command, as the status's description: pending on GitHub and Forgejo, failed on GitLab, which cannot move a running status back to pending | | the job's log, or a comment's reply | the same command; the job exits with code 3 | The screenshots come from [the tutorial's example](/terragucci/tutorial/waves/), which runs `approval: sealed`, so its commands carry `--sign` and its approvals a seal. Under the default `ledger` they carry neither. **GitHub** The command is the last line of the job's log: **GitLab** The job fails with the command at the end of its log: **Forgejo** 2. Read what the wave will do. The wave waits because a plan destroys or replaces something, or because `gate` is `always`. Destroys and replacements are never grouped, so they sit at the top of the report, where you read each one. 3. Approve it from a checkout of the repo. **ledger (default) or pr-review** ```bash npx terragucci approve wave-2 --plan jcs1-sha256:9f2c... --actor github:alice ``` **sealed** ```bash npx terragucci approve wave-2 --plan jcs1-sha256:9f2c... --actor github:alice --sign ~/.ssh/id_ed25519 ``` `--actor` must be your principal in `.chant/allowed_signers`. Left out, `--sign` uses git's `user.signingkey` when `gpg.format` is `ssh`. | `terragucci approve` | Does | |---|---| | with no flag | fetches `chant/lifecycle`, finds the waiting wave, prints its roots and destroys, and records an approval of the digest that wave planned | | `wave-` | picks the wave when several wait | | `--plan ` | the digest you read: approves only a wave waiting for exactly it, and otherwise approves nothing and exits 1, naming the digest waiting. Use it whenever you read the plans somewhere other than this command's own output, such as a chat message or the plan note. | | `--actor` | the name the approval records; pass it on your own machine, where it would otherwise record `$USER` | | `--sign` | seals the approval; the default under `approval: sealed` | | `--dry-run` | prints what it would approve and records nothing | | `--no-resume` | records the approval and starts nothing | The approval is one line appended to `_gates/tf-apply.jsonl` on `chant/lifecycle` in its own commit. The example runs `approval: sealed`, so its line is sealed: 4. Let the wave run again. `terragucci approve` restarts it with your forge token once the approval is recorded, and says so when it has no token. | Forge | Token `terragucci approve` reads | |---|---| | GitHub | `GH_TOKEN`, or `gh auth login` | | GitLab | `GITLAB_TOKEN` | | Forgejo | `FORGEJO_TOKEN` | With [`apply.resume`](/terragucci/reference/pipeline/#resume-after-an-approval) set, the resume job applies the wave within that many minutes of any approval. Re-run the wave by hand when `apply.resume` is unset, and after a pull request review under `approval: pr-review`: | Situation | How to re-run | |---|---| | `apply.when: merge` (the default), GitHub or Forgejo | Comment `/terragucci apply` on the merged pull request (add `wave-2` to stop after that wave), or re-run the job. Needs write access. | | `apply.when: merge`, GitLab | With `comments:` set, comment `/terragucci apply` on the merged merge request; the comments job retries the first apply job that did not succeed on its next run. `wave-` is refused, since GitLab runs the waves after a retried one. Needs Developer or above. Without `comments:`, retry the job. | | `apply.when: pull-request` | Comment `/terragucci apply` on the open pull request again. The mode and the signers come from the default branch, so the pull request cannot relax them or add its own approver. The forge's review is also required. | The comment approves nothing. | The wave or comment | Outcome | |---|---| | a wave without an approval that counts in its mode | waits, with the command in the reply | | a wave whose plans changed after the approval, before they applied | refused | | a wave planning anew after its approved plans applied | waits for an approval of the new plans | | an approved wave | applies, resuming where it stopped | | a comment that fails a check | runs nothing; the reply names the check ([the checks](/terragucci/reference/pipeline/#ignored-comments)) | [Apply a merged pull request](/terragucci/guides/re-plan-from-a-comment/#apply-a-merged-pull-request) and [Apply before merge](/terragucci/reference/pipeline/#apply-before-merge) list what each refuses. In [the tutorial's example](/terragucci/tutorial/changed-wave/) (`approval: sealed`), the module bump's wave 4 was refused and its new plans approved. Then `/terragucci apply` on the merged pull request resumed the waves and wave 4 applied: The report then shows the wave as approved and links its record. ## Roots on HCP Terraform terragucci's gate reads neither HCP Terraform's run approvals nor its locks, so set workspaces to local execution; remote execution adds HCP's own approvals and locks. ## Set up the signers file Only `approval: sealed` reads it. With that key in `terragucci.yml`, `terragucci init` lists each wave under `identity.gates`, so `terragucci approve` seals by default. 1. In a reviewed pull request to the default branch, add `.chant/allowed_signers`, one line per approver, in ssh-keygen's allowed_signers format. `terragucci init --signer github:alice` writes the first line from your `git config user.signingkey` when the file does not exist yet: ```text github:alice ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... github:bob ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQ... ``` | Line | Counts? | |---|---| | `ssh-ed25519` | yes | | `sk-ssh-ed25519@openssh.com` | yes | | `ssh-rsa` | yes | | `namespaces="..."` | only if it lists `chant-gate` or `*` | | `valid-after`, `valid-before` | ignored | | `cert-authority` | ignored | | a pattern principal (`*`, `?`, `!`) | ignored | To move the file, set its path in `.chant/trust.json`, e.g. `{"schema": 1, "signers": "security/allowed_signers"}`. 2. List only keys that people hold; leave out agent and CI job keys. 3. Protect `chant/lifecycle` against force pushes and deletion (next section). ### Rule commit These rule files are read from base (the applied commit's first parent): - the `approval` key in `terragucci.yml` - the signers file - `.chant/trust.json` - `chant.workspace.json` A pull request's change to them governs only later merges. One that switches `sealed` to `ledger` is still judged sealed. | Merge method | Base is | Safe | |---|---|---| | Merge commit | the default branch before the merge | yes | | Squash | the default branch before the merge | yes | | Rebase of one commit | the default branch before the merge | yes | | Rebase of several commits | the pull request's own next-to-last commit | no: merge with a merge commit or a squash | | A change that turns sealing off for later merges | Applies when | |---|---| | `approval` set to `ledger` | always | | `chant.workspace.json` deleted, or its `identity.gates` emptied | `terragucci.yml` sets no `approval` | Give those changes the same review as the signers file; the [agent comment](/terragucci/reference/pipeline/#the-agent-comment) refuses both. ## Push access to `chant/lifecycle` | Who pushes | What | With | |---|---|---| | an approver | approval records, as fast-forwards | their own credentials | | the apply job | pending records | its token (`github.token`, or `GITLAB_TOKEN` on GitLab); on GitHub no rule can tell it from another workflow | | Mode | What a pushed line counts for | |---|---| | `sealed` | nothing, unless its seal verifies against the signers file at base | | `ledger` and `pr-review` | every line of the digest counts, so who can push here is who can approve | Protection keeps history from being rewritten. | Forge | Protect `chant/lifecycle` with | |---|---| | GitHub | a ruleset or protection rule blocking force pushes and deletion, with pull requests and status checks off. | | GitLab | a protected branch with push and merge allowed to a role your approvers and `GITLAB_TOKEN` both hold, force push off. | | Forgejo | a protected branch rule with push for everyone with write access and force push off. | ## Refused wave A root whose plan changed between your approval and the apply makes the wave apply nothing ([Fix a refused wave](/terragucci/guides/fix-a-refused-wave/)). After a wave has applied, the next change waits for a new approval. ## Next - [Approvals runbook](/terragucci/guides/approvals-runbook/) - [Waves and approvals](/terragucci/concepts/waves-and-approvals/) - [Approvals as records in your repo](/terragucci/concepts/approvals-as-records/) - [The audit trail](/terragucci/reference/audit-trail/): who approved which digest, and the apply it let through --- # Fix a refused wave Source: https://intentius.io/terragucci/guides/fix-a-refused-wave/ ## Optional: hand this page to your coding agent ```text Read https://intentius.io/terragucci/guides/fix-a-refused-wave/. Fetch both reports, run `npx terragucci respond wave-refused ... --json`, and tell me in five lines which roots moved and why. The decision is mine. Never apply, approve (a pull request review or `terragucci approve`), override a policy denial (`terragucci override`), use `--mode apply`, or merge; never touch `.chant/allowed_signers` or `chant/lifecycle`. ``` ## Result A decision on a refused wave: the new plan approved and applied, or the change that moved it removed. ## Prerequisites | You need | Why | |---|---| | A wave that stopped with a refusal | the job says the set digest no longer matches its approval, and nothing in the wave applied | | The apply job's log | unless `respond.wave-refused` is `off`, it prints the diff from step 3 | | The job's artifact, `terragucci-report-apply-wave-` | it holds both reports the diff compares | ## Steps 1. Understand why it refused. An approval binds a wave's set digest. The wave applies nothing if any root's plan changes between the approval and the apply. The usual cause is another merge touching a root in the wave, or a data source reading a new value. An approval the wave already applied under refuses nothing: the next plans wait. In [the tutorial's example](/terragucci/tutorial/changed-wave/), approve the waiting wave and then merge the module bump. Its job stops and names the roots that moved: 2. Get the two reports. The artifact `terragucci-report-apply-wave-` holds the refused job's `terragucci-report/`, with the approved report in `approved/report.json` and the job's own in `current/report.json`. Fetch it to a local `terragucci-report/` directory. **GitHub** ```bash gh run download -n terragucci-report-apply-wave-2 -D terragucci-report ``` **GitLab** The `apply-wave-2` job's page > Job artifacts > Download, then unzip it in the checkout; the archive holds `terragucci-report/`. **Forgejo** The run's page > Artifacts > `terragucci-report-apply-wave-2`, then unzip it into `terragucci-report/`. The approved report also stays on [`chant/lifecycle`](/terragucci/concepts/glossary/#chantlifecycle) at `_gates/tf-apply/wave-/.json` (`:` written as `_`): ```bash git fetch origin chant/lifecycle git show origin/chant/lifecycle:_gates/tf-apply/wave-2/jcs1-sha256_.json ``` 3. Print the diff. ```bash npx terragucci respond wave-refused --approved terragucci-report/approved --current terragucci-report/current --wave 2 ``` It lists each root whose plan digest moved and what changed inside it. `--json` prints one envelope. 4. Decide. | Choice | Command | Result | |---|---|---| | Keep the new plan | `npx terragucci approve wave-2 --plan ` from a checkout, with the new digest the job printed. It approves it, signs under `approval: sealed`, and restarts the wave ([Approve a waiting wave](/terragucci/guides/approve-a-wave/)) | The new plans apply. | | Drop it | Revert the change on the default branch. | The old approval does not come back: the refused run recorded a newer pending fact, so the wave waits for a fresh approval. | An agent may [summarize the diff](/terragucci/guides/agent-refused-wave/), but it does not re-approve. ## Next - [Approvals runbook](/terragucci/guides/approvals-runbook/) - [Responses to pipeline events](/terragucci/reference/responses/#wave-refused) - [Waves and approvals](/terragucci/concepts/waves-and-approvals/) --- # Approvals runbook Source: https://intentius.io/terragucci/guides/approvals-runbook/ ## Optional: hand this page to your coding agent ```text Read https://intentius.io/terragucci/guides/approvals-runbook/. Run the "List the waves waiting" command with a read-only clone and tell me each wave, digest and expiry. Do not revoke an approval, and do not run `terragucci override`. Never apply, approve (a pull request review or `terragucci approve`), override a policy denial (`terragucci override`), use `--mode apply`, or merge; never touch `.chant/allowed_signers` or `chant/lifecycle`. ``` ## Result The commands an approver or on-call engineer runs. [Approve a waiting wave](/terragucci/guides/approve-a-wave/) walks through a first sign-off. The ledger is `_gates/tf-apply.jsonl` on the [`chant/lifecycle`](/terragucci/concepts/glossary/#chantlifecycle) branch: a `"kind":"pending"` line is a wave asking, any other line an approval. Each waiting wave's report is at `_gates/tf-apply/wave-/.json`. A wave that applies under an approval first marks it used in `_gates/tf-apply/applied.jsonl`; a used approval refuses no later plan. ## Choose a mode | `approval` | An approval counts when | Setup | |---|---|---| | `ledger` (default) | it names the wave's digest. Anyone who can push to `chant/lifecycle` can write one in anyone's name; it records the plans and does not prove who approved. | none | | `pr-review` | the merged pull request's head was approved by a writer other than its author and the wave plans what the review saw, or, as under `ledger`, it names the digest | none; optionally require `terragucci/approval` (GitHub, Forgejo) or an approval rule (GitLab) | | `sealed` | it names the digest and its seal verifies against the signers file at base | [Set up signers](#set-up-signers) | `terragucci config check` prints the mode in force and where it comes from. A wave reads it at base, so a change to it governs the merges after it. | You have | You want | Do | |---|---|---| | `identity.gates` in `chant.workspace.json`, no `approval` key (sealed) | to keep signing | add `approval: sealed` to `terragucci.yml` | | the same | unsigned approvals | add `approval: ledger` and run `terragucci init`, which drops the wave gates; or `terragucci init --approval ledger` | | `ledger` | sealed approvals | add `approval: sealed`, run `terragucci init`, then set up signers | ## Set up signers Only under `approval: sealed`, once per repo, in a reviewed pull request to the default branch. 1. Add `.chant/allowed_signers`, one line per approver in ssh-keygen's allowed_signers format; `terragucci init --signer ` writes the first from `git config user.signingkey`. The first field is what the person passes as `--actor`: ```text github:alice ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... ``` 2. Set `approval: sealed` and run `terragucci init`. It lists each wave's gate under [`identity.gates`](/terragucci/concepts/glossary/#identitygates) in [`chant.workspace.json`](/terragucci/concepts/glossary/#chantworkspacejson); then an approval counts only when its seal verifies against the signers file at base ([why](/terragucci/guides/approve-a-wave/#rule-commit)). 3. List only people in the file; leave out agents' and CI jobs' keys. 4. Add or remove a person by pull request. The change first governs the apply of the next merge. ## Approve a wave A waiting wave exits with code 3; its command is in the job log, the `terragucci/apply` status and the pull request's plan note. Read the plans in the report, then, from a checkout: **ledger (default) or pr-review** ```bash npx terragucci approve wave-2 --plan --actor github:alice ``` **sealed** ```bash npx terragucci approve wave-2 --plan --actor github:alice --sign ~/.ssh/id_ed25519 ``` It records an approval of the planned digest and restarts the waiting wave (`--dry-run` prints what it would approve instead). If the restart fails, re-run the job. Commenting `/terragucci apply` on the pull request also works (an open one needs `apply.when: pull-request`). On GitLab the comment needs `comments:` set, and the comments job answers it on its next run. ## Approve by review Under `approval: pr-review`, approve the pull request on its head before it merges; each gated wave then applies when it plans what the review saw ([the steps](/terragucci/guides/approve-a-wave/#approve-by-review-approval-pr-review)). **GitHub** Files changed > Review changes > Approve, with write access. **GitLab** As a Developer or higher, approve the merge request after its last push. An approval before a push counts for nothing. **Forgejo** Files changed > Review > Approve, as an official reviewer. On GitHub and Forgejo, `terragucci/approval` on the head then turns success. A wave whose plans moved after the review exits 4 with the `terragucci approve` command for the new digest. A review's ledger line carries `"via": "pr-review"` beside the pull request and its head and reviewers. ## Override a policy denial This needs [`policy.override`](/terragucci/reference/policy/#overriding-a-denial) at base. 1. The denied wave exits 1. Its log gives the `terragucci override` command for each root the policy denied. 2. Read the root's plan and denial in the wave's report. 3. As a person the key lists, run the command in a checkout: **ledger (default) or pr-review** ```bash npx terragucci override envs/prod/app --rule main.deny_public_bucket \ --reason "the incident needs the bucket public until 18:00" --actor github:alice ``` **sealed** ```bash npx terragucci override envs/prod/app --rule main.deny_public_bucket \ --reason "the incident needs the bucket public until 18:00" --actor github:alice --sign ~/.ssh/id_ed25519 ``` `--dry-run` prints what it would override and records nothing. 4. Re-run the wave's job. | The wave then | Because | |---|---| | applies the root, and its report names the override | the override names this plan and these rules, and its author is listed at base | | exits 1 again, naming why the override does not count | its author is not listed, it gives no reason, or under `sealed` its seal does not verify | | exits 4 and applies nothing | the plan or its rules changed before any run applied the override; the next run records the new denial to override | | exits 1 with a new `terragucci override` command | a run applied the override, and the root's plan changed since: the new plan needs its own override | The ledger is `_gates/policy-override.jsonl`, one gate per root. Overrides that went out are listed in `_gates/policy-override/applied.jsonl`, so a changed plan of that root asks for a fresh override. Revoke an override like an approval, by deleting its line before the wave runs. ## List the waves waiting for an approval This prints each gate whose newest pending record has no approval of its digest after it: ```bash git fetch origin chant/lifecycle git show origin/chant/lifecycle:_gates/tf-apply.jsonl | jq -s -r ' . as $all | ($all | map(select(.kind == "pending")) | group_by(.gate) | map(max_by(.timestamp))[]) as $p | select(($all | map(select(.kind != "pending" and .gate == $p.gate and .planDigest == $p.planDigest and .timestamp >= $p.timestamp)) | length) == 0) | "\($p.gate)\t\($p.planDigest)\texpires \($p.expiresAt)\t\($p.description)"' ``` A line past its `expires` time was recorded more than 48 hours ago; the wave's next run records a fresh one. ## Revoke an approval before its wave runs Every approver can push to `chant/lifecycle`. Remove the approval's line in a normal commit; it works with force pushes blocked ([branch protection](/terragucci/guides/approve-a-wave/#push-access-to-chantlifecycle)). 1. Clone the branch: ```bash git clone --branch chant/lifecycle --single-branch lifecycle cd lifecycle ``` 2. Delete the approval's line from `_gates/tf-apply.jsonl`. 3. Push the commit: ```bash git commit -am "revoke the approval of wave-2" && git push origin chant/lifecycle ``` The wave waits again; the old line stays in history. ## Recover from a refusal A wave whose plans moved after an unapplied approval applies nothing and exits 4. The job names the moved roots; `terragucci respond wave-refused` prints what changed. Approve the new digest or revert. New plans after an applied approval wait for their own instead. A revert does not revive the old approval: the refused run recorded a newer pending fact, so the wave needs a fresh [`terragucci approve`](/terragucci/reference/cli/#approve). See [Fix a refused wave](/terragucci/guides/fix-a-refused-wave/). ## Expiry | Record | Expires | Stops counting when | |---|---|---| | Pending fact | 48 hours after the first run recorded it; the next run after that records a fresh one. | It is replaced by a newer pending fact for the gate. | | Approval | Never. | It is older than the gate's newest pending fact, or names a digest the wave no longer plans ([Fix a refused wave](/terragucci/guides/fix-a-refused-wave/)); or, under `sealed`, its approver leaves `.chant/allowed_signers`, for merges made after that. Once a run applied under it, it refuses nothing. | | Policy override | Never. | It is older than the root's newest recorded denial, or names another plan or other rules; or its author leaves `policy.override`, or under `sealed` the signers file, for merges made after that. | ## Next - [Waves and approvals](/terragucci/concepts/waves-and-approvals/) - [Gate policy](/terragucci/reference/stages/#gate-policy) - [Threat model](/terragucci/reference/threat-model/#approval-modes): what each approval mode stops --- # Re-plan a pull request from a comment Source: https://intentius.io/terragucci/guides/re-plan-from-a-comment/ ## Optional: hand this page to your coding agent ```text Read https://intentius.io/terragucci/guides/re-plan-from-a-comment/. Check that the default branch's pipeline has the comment trigger (on GitLab, the `comments` job and a pipeline schedule with TERRAGUCCI_SCHEDULE set to comments); if it does not, run `npx terragucci init` and open a pull request with the result. You may comment `/terragucci plan` on a pull request and report what the note says. Never comment `/terragucci apply`, `/terragucci lock` or `/terragucci unlock`. Never apply, approve (a pull request review or `terragucci approve`), override a policy denial (`terragucci override`), use `--mode apply`, or merge; never touch `.chant/allowed_signers` or `chant/lifecycle`. ``` ## Result `/terragucci plan` on a pull request plans it again and updates its plan note and `terragucci/plan` status. `/terragucci plan envs/dev/orders` plans that root alone. ## Prerequisites | You need | Why | |---|---| | The [pipeline](/terragucci/getting-started/) with the comment trigger | comment workflows run from the default branch; to add it, run `npx terragucci init` again and merge the result | | Write access to the repo, for whoever comments (on GitLab, the Developer role or above) | anyone else gets no answer and no plan | **GitHub** Each new pull request comment starts the `replan` job. **GitLab** GitLab starts no pipeline for a merge request note. A pipeline schedule polls for notes instead and answers each on its next run. | Step | What to do | |---|---| | Turn it on | add `comments: "*/5 * * * *"` to `terragucci.yml`, run `npx terragucci init`, and merge the result; the pipeline gets a `comments` job | | Add the schedule | under CI/CD, Schedules: the same cron, the default branch, and the variable `TERRAGUCCI_SCHEDULE` set to `comments` | | The token | `GITLAB_TOKEN` (or the `token_env` variable): a project access token with the `api` scope and the Developer role, in a masked variable | The `comments` job runs from the default branch and holds no cloud credentials. It reads notes and calls GitLab's API: `/terragucci plan` starts a new merge request pipeline, whose plan job updates the plan note. Each reply carries a marker, so a note is answered once. **Forgejo** As on GitHub. ## Steps 1. Comment on the pull request with this line alone: ```text /terragucci plan ``` The optional word after `plan` is a root path from the repository root. 2. Read the note. The `replan` job plans the head with the read-only role and no forge token, and the `replan-note` job updates the same note and status. **GitHub** **GitLab** When the pipeline the `comments` job started has planned, it edits the note: **Forgejo** The example's note, updated in place above the comment: A named root the change does not reach gets a "not affected" reply and no update; a name that is not a root is refused with the list of roots: ## Re-plans by dispatch A chat front end or a script can re-plan without a comment. On GitHub and Forgejo the workflow takes a `workflow_dispatch`: | Input | Value | |---|---| | `pr` | the pull request's number; left empty, the dispatch runs the drift job where `drift:` is set | | `root` | optional: one root path, read by the comment grammar | The re-plan runs the same checks as `/terragucci plan [root]` and updates the same note and status. **GitHub** ```sh gh workflow run terragucci.yml -f pr=42 -f root=envs/dev/orders ``` The decision step asks the API for the dispatcher's permission and plans nothing without write access. **GitLab** GitLab has no dispatch with inputs. Run a new merge request pipeline instead (Run pipeline on its Pipelines tab), or call the API: ```sh curl -X POST -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \ "$CI_API_V4_URL/projects//merge_requests//pipelines" ``` It needs the Developer role or above, and plans the whole merge request. **Forgejo** ```sh curl -X POST -H "Authorization: token $FORGEJO_TOKEN" -H 'content-type: application/json' \ -d '{"ref":"main","inputs":{"pr":"42"}}' \ "https://forgejo.example.com/api/v1/repos/acme/infra/actions/workflows/terragucci.yml/dispatches" ``` Forgejo answers 403 to a dispatcher without write access and starts no run. Its dispatch event carries no permission to read, so that refusal is the check. ## Comment forms The text is untrusted and never reaches a script. `terragucci comment` reads it from the event file, or on GitLab from the API. Only one line in one of these forms is accepted; anything else is refused with the reason. | Comment | Runs | Where | GitLab, with `comments:` set | |---|---|---|---| | `/terragucci plan` | a re-plan | every GitHub and Forgejo pipeline | a new merge request pipeline | | `/terragucci plan ` | a re-plan of that root, which must be exactly a root the pipeline knows | every GitHub and Forgejo pipeline | the root is checked, and the whole merge request is planned | | `/terragucci apply` | the approved waves | merged pull requests; open ones with `apply.when: pull-request` | merged merge requests: a retry of the merge commit's first apply job that did not succeed; with `apply.when: pull-request`, open ones: an `mr-apply` pipeline on the default branch | | `/terragucci apply wave-` | the approved waves up to wave n | as above | an open merge request with `apply.when: pull-request`; otherwise refused, since GitLab runs the waves after a retried job by itself | | `/terragucci lock` | locks on the roots (in a Terragrunt repo, the units) this pull request reaches, with no apply | `apply.when: pull-request` or `locks: plan` | `apply.when: pull-request`, through `mr-apply`; otherwise answered as unsupported | | `/terragucci unlock` | a release of this pull request's locks | `apply.when: pull-request` or `locks: plan` | as `lock` | | `atlantis plan [-d ]`, `atlantis apply` | the same as `/terragucci plan []` and `/terragucci apply`, with the same checks | `atlantis_comments: true` | the same, with `atlantis_comments: true` | | `/terragucci agent ` | a coding agent's change | `agent.comment` set; see [Have an agent change a pull request](/terragucci/guides/agent-change-a-pull-request/) | with `agent.comment` set, starts the agent's pipeline on the default branch; otherwise answered that it is off | No comment approves, and a re-plan cannot change what an approval binds. [When a comment runs nothing](/terragucci/reference/pipeline/#ignored-comments) lists every check each command must pass. A refusal is a reply that starts with `terragucci:`. | Case | Job | |---|---| | a comment that asks for nothing | ends cleanly | | a forge 403 or failure, or an unreadable event file | fails, with the cause in the log | ## Apply a merged pull request On a merged pull request, `/terragucci apply` re-runs `tf-apply` for the waves approved as [Approve a waiting wave](/terragucci/guides/approve-a-wave/) shows. | Part | What happens | |---|---| | Workflow | `apply-comment` runs the default branch's workflow at the merge commit, never the head | | Checks | `terragucci comment-apply` checks the comment before any credential, against the [merged-PR column](/terragucci/reference/pipeline/#ignored-comments) | | Credentials | the job assumes `oidc.apply_role`, beside any push's apply | | Another run's resources | with [choudoufu](/terragucci/concepts/glossary/#choudoufu), a wave that changes a resource another run is applying is refused; the reply names that run, and you comment again once it is done | | Waves | from wave 1 until one does not apply; `wave-` stops after wave n | | A waiting wave | waits as on a push | | A refused wave | prints its diff (`respond.wave-refused`) | | A failed wave | prints its triage (`respond.apply-failed`) | | [Terragrunt](/terragucci/guides/use-terragrunt/) | the same job and refusals; each wave runs `tf-apply --terragrunt` on its units from the merge commit | | On GitLab | | |---|---| | `/terragucci apply` | retries the waiting wave's job in the merge commit's pipeline | | The gate | `stage tf-apply` decides it again, so a wave with no approval waits again; the waves after it follow, each behind its own gate | | A merge commit a later apply superseded | refused, as on GitHub | ## Apply an open pull request With [`apply.when: pull-request`](/terragucci/reference/config/#apply-before-merge), `/terragucci apply` on an open pull request applies its head before merge. [Apply a pull request before it merges](/terragucci/guides/apply-before-merge/) has the steps. Anyone with write access can lock or unlock, and the reply names what was locked or released. [Locks](/terragucci/guides/apply-before-merge/#locks) lists everything that takes and releases one. | Comment | Does | Refused when | |---|---|---| | `/terragucci lock` | locks the roots the pull request reaches, applying nothing, so no other pull request applies them first; needs no approval or checks | another open pull request holds one of those roots; the reply names it | | `/terragucci unlock` | releases the pull request's locks so another can apply those roots; under [`locks: plan`](/terragucci/reference/config/#plan-locks) it locks again on its next push or `/terragucci plan` | | | `/terragucci plan`, under `locks: plan` | takes the locks again once a holder is gone | | ## Next - [Approve a waiting wave](/terragucci/guides/approve-a-wave/) is the one place an approval is given. - [Stages](/terragucci/reference/stages/) lists what `tf-plan` reads and writes. --- # Apply a pull request before it merges Source: https://intentius.io/terragucci/guides/apply-before-merge/ ## Optional: hand this page to your coding agent ```text Read https://intentius.io/terragucci/guides/apply-before-merge/. Add `apply.when: pull-request` to terragucci.yml, run `npx terragucci config check --json` and `npx terragucci init`, and open a pull request with the result. List the merge token and the branch protection I must set; do not create tokens or change branch protection yourself. Never comment `/terragucci apply`, `/terragucci lock` or `/terragucci unlock`. Never apply, approve (a pull request review or `terragucci approve`), override a policy denial (`terragucci override`), use `--mode apply`, or merge; never touch `.chant/allowed_signers` or `chant/lifecycle`. ``` ## Result An open change that applies from its head in the same waves and gates as a merge. It merges once every wave applied, and the push after the merge applies nothing. ## Prerequisites | You need | Why | |---|---| | Plain roots or a Terragrunt repo on GitHub, GitLab or Forgejo, set up as in [Get your first plan note](/terragucci/getting-started/) or [Use Terragrunt](/terragucci/guides/use-terragrunt/) | the pipeline `init` writes holds the jobs below | | On GitLab, [`comments:`](/terragucci/guides/re-plan-from-a-comment/) and its pipeline schedule | a merge request note starts no pipeline; the `comments` job reads `/terragucci apply` | | Branch protection on the default branch that requires a review | the review is the guard: the applying job runs the change's code with the apply role | | The [trade](/terragucci/reference/config/#apply-before-merge) accepted | [what the pull request's code can reach](/terragucci/reference/pipeline/#pull-request-code-access) with the apply role | ## Steps 1. Add the `apply` block to `terragucci.yml`: ```yaml apply: when: pull-request merge: auto # or manual, the default, to merge by hand merge_token_env: MERGE_TOKEN ``` Leave `waves.jobs` unset, since `config check` refuses it with `apply.when: pull-request`. With `merge: auto`, add that secret, holding a token of a user who may push to the default branch. Only `pr-merge`, which runs none of the change's code, gets it. **GitHub** The token is optional. Without it the merge uses the job's own token and starts no `confirm`; the next push confirms it. **GitLab** It is required, under `merge: manual` too. The `comments` job starts the apply pipeline on the default branch with it, and only a token allowed to merge there may. Add `forge: gitlab` and `comments:` beside the block. | Variable setting | Value | |---|---| | Key | the name `merge_token_env` gives, such as `TERRAGUCCI_MERGE_TOKEN` | | Value | a project access token with the `api` scope and a role the default branch's protection allows to merge | | Protected, Masked | on: only default-branch pipelines read it | | Environment scope | `terragucci-merge`: only `comments` and `pr-merge` name that environment, and neither runs the change's code | Under Settings, CI/CD, Variables, the minimum role allowed to use pipeline variables must include that token's role: the apply pipeline is started with variables naming the merge request. **Forgejo** It is required, since Forgejo refuses a merge made with the job's own token. 2. Run `npx terragucci init` and merge the result in its own pull request. Nothing changes until it lands; after that, default-branch commits run `confirm` instead of the apply waves. 3. Open the change as usual and get its head approved on the forge by a reviewer other than its author. A push after the review needs a fresh approval. 4. Comment `/terragucci apply`. The reply names any failed check from the [open-PR column](/terragucci/reference/pipeline/#ignored-comments). | Check (`apply.requires`) | Fails when | Fix | |---|---|---| | `approved` | no reviewer other than the author approved the head, or a reviewer's last review asks for changes | get the head approved; a push needs a fresh approval | | `undiverged` | the head is behind the default branch | merge or rebase the default branch in, then get a new plan and approval | | `mergeable` | the forge says it does not merge: conflicts with the default branch, or on GitHub a branch protection rule blocks it | resolve the conflicts, or meet the rule | | `checks` | a status or check on the head failed or has not passed yet | fix and push, or wait for it | [`apply.requires`](/terragucci/reference/config/#apply-before-merge) picks which of the four the comment needs; all four by default. Whatever it lists, the comment also needs: - `terragucci/plan` passing on the head - the pipeline file left unchanged - each root the change reaches free of another open pull request's lock 5. Approve a waiting wave. A wave the `gate` policy holds stops, and the reply gives the command that approves it ([Approve a waiting wave](/terragucci/guides/approve-a-wave/)). Then comment `/terragucci apply` again. 6. Merge. `merge: auto` merges after the last wave and releases the locks; under `manual` they hold until you merge. A run that stopped at a wave never merges. **GitHub** **GitLab** **Forgejo** After the merge, `confirm` plans every root (every unit in a Terragrunt repo). Its `terragucci/apply` status passes when nothing plans a change. ## On GitLab GitLab builds a merge request's pipeline from its own `.gitlab-ci.yml`, so nothing applies there; the apply runs in a default-branch pipeline. | Step | Job | What happens | |---|---|---| | 1 | `comments`, on its schedule | reads a Developer's `/terragucci apply`, `lock` or `unlock` note on an open merge request; for `apply`, checks `apply.requires` and refuses by name | | 2 | `comments` | starts a pipeline on the default branch with the merge token and `TERRAGUCCI_MR`, `TERRAGUCCI_NOTE` and `TERRAGUCCI_HEAD` | | 3 | `mr-apply`, from the default branch's pipeline file | reads the merge request and the note again from GitLab, refuses a head other than the merge request's, checks every requirement again and takes the locks | | 4 | `mr-apply` | applies the head wave by wave with the apply role, behind the same gates, and replies | | 5 | `pr-merge`, with `merge: auto` | merges once the reply says every wave applied | `approved` needs an approval given after the latest push by a Developer or above who is not the author. `mr-apply` posts no status on the head; its replies say what happened. A note is answered on the schedule's next run. ## Config source The apply of an open change takes its settings from the default branch. Edits to `terragucci.yml` in the change count only once it merges. | Setting | Read from | |---|---| | Waves, `binary`, `gate`, `canary`, the apply role | the default branch's pipeline | | `policy` and the policy directory | the default branch; if it has no `policy` key, a change adding one is checked against its own | | `approval`, the gate rule ([`identity.gates`](/terragucci/concepts/glossary/#identitygates) in [`chant.workspace.json`](/terragucci/concepts/glossary/#chantworkspacejson)) and the signers file | the default branch | | `reports`, `telemetry`, `parallelism` and every other key of `terragucci.yml` | the default branch | | `respond` | the default branch; if unreadable, no response | | The roots, units, modules and code that plan and apply | the change's head | An unreadable default-branch config fails the wave before anything applies. ## Locks Pull request locks hold roots. With `binary: choudoufu` ([set it up](/terragucci/guides/use-a-binary/#choudoufu)), a killed apply leaves no state lock behind ([locking with choudoufu](/terragucci/concepts/locking-and-staleness/#with-choudoufu)). | Event | Lock | |---|---| | The change applies | each root it reaches is locked | | `/terragucci lock` on the change, by anyone with write access (on GitLab, a Developer or above) | each root it reaches is locked, and nothing applies; the reply names the roots | | With [`locks: plan`](/terragucci/reference/config/#plan-locks), the change is opened, pushed to or re-planned with `/terragucci plan` | each root its head reaches is locked; roots it no longer reaches are released; `terragucci/lock` passes, naming them | | Another change reaches a locked root | refused; the reply names the root and the holder, and with `locks: plan` its `terragucci/lock` fails | | The holder merges or closes | released | | `/terragucci unlock` on the holder, by anyone with write access | released; the reply names what it released | A second pull request that reaches a locked root is refused, and `/terragucci unlock` on the holder releases it: A plan lock refusal is not queued: comment `/terragucci plan` on the refused change after the holder's locks are released. In a Terragrunt repo the locks are on units. terragucci works them out from git without running Terragrunt: | The change touches | Locked | |---|---| | a file in a unit's directory | that unit, and every unit whose `dependency` or `dependencies` block names it by a plain path, followed through | | a file in no unit's directory, such as `root.hcl` or a module | every unit; the reply says which file | | Markdown files only | nothing | A Terragrunt change applies its waves of units from the head, one dependency layer at a time, so a unit applies before the units that read its outputs. In an Atmos repo the locks are on instances, worked out from git without running Atmos: | The change touches | Locked | |---|---| | a file in a component's directory | the instances of that component, and every instance that depends on one | | a stack manifest, a catalog or `atmos.yaml` | every instance; the reply says which file | | Markdown files only | nothing | With `synth`, such as a CDK Terrain app, the locks are on the stacks the command writes. Git holds none of them, and the command is the pull request's own code, so terragucci does not run it to see which stacks a change reaches: | The change touches | Locked | |---|---| | any file but Markdown, such as `main.js` | every stack; the reply says which file | | Markdown files only | nothing | ## Next - [The generated pipeline](/terragucci/reference/pipeline/#apply-before-merge) lists the jobs, the checks and the credentials. - [Re-plan a pull request from a comment](/terragucci/guides/re-plan-from-a-comment/) has every comment command. --- # Roll out a new module version Source: https://intentius.io/terragucci/guides/roll-out-a-module-version/ ## Optional: hand this page to your coding agent ```text Read https://intentius.io/terragucci/guides/roll-out-a-module-version/. Run `npx terragucci rollout ` (the preview only), fix any pin it refuses in a pull request, and print the `--mode apply` command for me to run. Never apply, approve (a pull request review or `terragucci approve`), override a policy denial (`terragucci override`), use `--mode apply`, or merge; never touch `.chant/allowed_signers` or `chant/lifecycle`. ``` ## Result Every root that pins the module moved to the new version through one pull request per wave. ## Prerequisites | You need | Why | |---|---| | Roots that pin the module exactly by `oci://` tag or digest, registry `version` or git tag | a range cannot move ([Stages](/terragucci/reference/stages/#rolling-out-a-module-version) lists the cases) | | The new version published ([Publish your modules](/terragucci/guides/publish-modules/)) | the rollout pins roots to it | | The HCL parser beside terragucci: `npm i -D @cdktn/hcl2json` | the rollout edits each root's pin with it | | A forge token that can open pull requests, in the variable `token_env` names | the apply run and the rollout job open the rollout's pull requests with it | | Optional: `waves.canary` in `terragucci.yml` | it picks the roots that move first | | Optional: `rollouts` in `terragucci.yml` | a scheduled job opens each next wave, so nobody runs step 5 | ## Steps 1. Preview the rollout. ```bash npx terragucci rollout modules/network ``` Name the module by path or by source without the pin, and leave out the version to get the newest published. The dry run changes nothing. When pins disagree, `--from` picks which moves. For a module that [`modules.registry`](/terragucci/guides/publish-modules/#serve-a-module-registry) publishes, `modules/network` also names the calls whose `source` is its registry address, such as `modules.example.com/acme/network/generic`, and their `version` moves. With no version named, the newest the registry lists counts too. 2. Fix any pin it refuses. The preview lists each pin it cannot move, with 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 | Do that in its own pull request, then run the preview again. 3. Open the first wave. ```bash npx terragucci rollout modules/network 1.4.0 --mode apply ``` `--mode apply` writes to the forge and runs no `terraform apply`; roots apply after the merge. It opens one pull request for the canary wave, or the first in dependency order. Only that wave's files change, and each pin keeps its shape. 4. Review and merge it. The apply stage runs on the merge commit. 5. Let the next wave open. With `rollouts` set, `init` writes a job that runs `terragucci respond rollout --mode apply` on that schedule: ```yaml rollouts: "*/15 * * * *" token_env: ROLLOUT_TOKEN ``` Each run opens the next wave of every rollout whose last wave merged and applied, so a wave opens within one interval. A rollout with a pull request still open, or one closed unmerged, is left alone. | Forge | Job | Token | Schedule | |---|---|---|---| | GitHub | its own workflow, `terragucci-rollout.yml`, which you can also start by hand | the secret `token_env` names, else the job's own; GitHub runs no workflow for a pull request the job's own token opens, so name a secret holding a token that can push branches and open pull requests | the `rollouts` cron | | Forgejo | the same | the secret `token_env` names, else the job's own | the `rollouts` cron | | GitLab | the `rollout` job in the pipeline | the `token_env` variable | a pipeline schedule with that cron and the variable `TERRAGUCCI_SCHEDULE` set to `rollouts`; `init` prints the steps | `token_env` is the project's forge token variable, shared beyond the rollout: on GitLab every job that pushes or opens a merge request reads it, and on GitHub and Forgejo the fmt, tips, version-bump and drift jobs export their own token under that name. `respond.rollout: off` leaves the rollout job out. Without `rollouts`, run the rollout again after each merge. Each run takes at most one step: ```bash npx terragucci rollout modules/network 1.4.0 --mode apply ``` Either way, the next wave waits for the last to merge and apply. The apply status it checks is `apply/` per directory, else `terragucci/apply`. A pull request closed unmerged or a failed apply stops the rollout. It never merges or writes a default branch. | Exit code | Meaning | |---|---| | 0 | a step was taken, or the rollout is complete | | 3 | a pull request waits for a merge or an apply | | 1 | the rollout stopped | From a [control repo](/terragucci/concepts/control-repo/), wave 1 holds every project's canaries and later waves follow in config order. Each project gets its own pull request per wave. `rollouts` is a single repo's key, since a project's pipeline sees only its own roots; in a control repo, run `terragucci respond rollout --mode apply` on a schedule of your own. ## Provider upgrades The same flow moves a provider in the lock file: ```bash npx terragucci rollout --provider hashicorp/aws 6.68.0 --mode apply ``` Each lock file is rewritten for that provider alone, and an exact `version` constraint moves too. The wave stops unless every other provider stays where it was. ## Next - [Tips](/terragucci/reference/tips/) names setups that make rollouts hard. - [The JSON output of rollout](/terragucci/reference/cli-json/#rollout) lists each wave's state for scripts. --- # Publish your modules Source: https://intentius.io/terragucci/guides/publish-modules/ ## Optional: hand this page to your coding agent ```text Read https://intentius.io/terragucci/guides/publish-modules/. Add the `modules:` block, run `npx terragucci publish --dry-run`, show me what would be published, run `npx terragucci init`, and open a pull request. List the registry credentials I must add; do not add them. If I ask for attested releases, add `attest: true` and tell me to run `cosign generate-key-pair` myself; never create, read or commit a private key. Never apply, approve (a pull request review or `terragucci approve`), override a policy denial (`terragucci override`), use `--mode apply`, or merge; never touch `.chant/allowed_signers` or `chant/lifecycle`. ``` ## Result Every module that changed on the default branch released under a new number, each with a digest your roots can pin. Each level is opt-in: | Level | Config | What it adds | |---|---|---| | Publish | `modules.publish` | a git tag or OCI tag per release, pinned rollouts by pull request, and version bump jobs | | Registry | `modules.registry` | each release as a module registry's files in a bucket or a Pages site, so roots use registry sources and version constraints | | Test | `modules.test: true` | `tofu test` or `terraform test` on each module before a release of it publishes | | Attest | `modules.attest: true` as well | a signature, SLSA provenance and an SBOM for each release, and a record of it in the release ledger | ## Prerequisites | You need | Why | |---|---| | Modules in one place, such as `modules/network` and `modules/service` | each directory there is published on its own | | Conventional commit messages | they decide the version bump | | An OCI registry, or permission to push git tags to `origin` | where the versions go; Terraform has no OCI sources, so its roots pin a git tag | ## Steps 1. Name the modules and where they go in `terragucci.yml`. ```yaml modules: path: modules/* publish: oci://registry.example.com/acme/modules ``` `publish` takes an `oci://` registry address, `git-tags`, or a list of both. | Target | Roots pin | Credentials | |---|---|---| | `oci://` registry | OpenTofu roots, by tag or `@sha256:` digest | the registry variables in step 4 | | `git-tags` | Terraform roots, by git tag | none; the tags are pushed to `origin` | 2. Preview. ```bash npx terragucci publish --dry-run ``` The output lists each changed module and its next version under the [version bump rules](/terragucci/reference/stages/#version-bump-rules). For commits with no conventional type, see [`respond.version-bump`](/terragucci/reference/responses/#version-bump). 3. Add the publish job. ```bash npx terragucci init ``` That adds a `publish` job, which runs with full history after `apply` on each push to the default branch. 4. Give it registry credentials; no other job gets them. **GitHub** Repository secrets. **GitLab** Protected, masked CI/CD variables. **Forgejo** As on GitHub, repository secrets. | Variable | Holds | |---|---| | `TERRAGUCCI_REGISTRY_USER` | the registry user | | `TERRAGUCCI_REGISTRY_PASSWORD` | its password or token | | `TERRAGUCCI_REGISTRY_INSECURE` | `1` for a registry without TLS | 5. Merge a change to a module. The next push to the default branch publishes, printing each version's OCI manifest digest for a root to pin: `oci://registry.example.com/acme/modules/network@sha256:...`. With `git-tags`, each version is a tag on the repo, `/v`. Here `modules/service` moved to `0.2.0` after a change while `modules/queue` stayed at `0.1.0`: A published version never changes, so a rerun publishes nothing. Git-tag publishing fetches `origin`'s tags first; different content under an existing tag stops the run and names the tag. ## Test each release With `modules.test`, the publish job runs the binary's `test` on each module before releasing it. A module with no test files or with failing tests is refused; the other modules still publish, and the job fails at the end. ```yaml binary: tofu modules: path: modules/* publish: git-tags test: true ``` The test files are the binary's own: `*.tftest.hcl` (and `*.tofutest.hcl` for OpenTofu) in the module or in its `tests` directory. ```hcl # modules/network/tests/main.tftest.hcl variables { name = "dev" } run "names" { command = plan assert { condition = terraform_data.net.input == "dev" error_message = "the name is not passed through" } } ``` The job runs `init -backend=false` and then `test` in the module's directory. The forge token, signing key and registry credentials are withheld from it. A refused release says why, with the end of the binary's output when a test failed: ```text network 1.1.0: refused, not published to git-tags (modules/network has no tests (no *.tftest.hcl in it or its tests directory), and modules.test publishes only a release whose tests pass) ``` `publish --dry-run` runs no tests; it says which releases it would test. ## Serve a module registry With `modules.registry`, a bucket or a Pages site also serves each release as static files in the [module registry protocol](https://opentofu.org/docs/internals/module-registry-protocol/). Roots call the modules by registry address, with a `version` that can be a constraint: ```hcl module "network" { source = "modules.example.com/acme/network/generic" version = "~> 1.0" name = "dev" } ``` 1. Name the bucket, the address that serves it, and the namespace. ```yaml modules: path: modules/* publish: git-tags # optional; the registry can stand alone test: true registry: bucket: s3://acme-modules url: https://modules.example.com namespace: acme ``` | Setting | Default | Holds | |---|---|---| | `url` | required | the `https://` address that serves the files, with no path. Terraform and OpenTofu look for a registry at its host's root, over HTTPS only. Its host starts each module's source | | `namespace` | required | the namespace modules go under | | `bucket` or `dir` | one is required | an `s3://`, `gs://` or `az://` bucket, or a directory in the repo that a Pages site deploys | | `prefix` | none | where the files go in the bucket; `url` serves that prefix as its root | | `endpoint` | none | an S3-compatible store's address | | `namespaces` | none | a tag prefix (a path in the repo, such as `platform/`) to the namespace its modules go under; the longest prefix wins | | `system` | `generic` | the third part of each address | | `download` | `tarball` | what each version's download points at: a `.tar.gz` beside it, its git tag (`git-tags`, which `publish` must list), or its OCI artifact (`oci`, which `publish` must list; OpenTofu only) | A module's address is `///`, where the name is its directory's name: `modules/network` is `modules.example.com/acme/network/generic`. 2. Give the publish job write access to the bucket. ```bash npx terragucci init ``` The publish job on GitHub and Forgejo maps the bucket's key secrets as a job that [keeps reports in a bucket](/terragucci/guides/keep-reports-in-a-bucket/) does: for S3 the pair `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`, for Azure `AZURE_STORAGE_KEY`. GitLab passes it CI/CD variables of those names. 3. Serve the bucket over HTTPS at `url`: a CDN in front of it, or its own static website endpoint with a certificate. Every reader of the registry needs read access to it, and only the publish job writes it. 4. After the next module change merges, the publish job writes: | File | Holds | |---|---| | `.well-known/terraform.json` | service discovery: `{"modules.v1": "/v1/modules/"}` | | `v1/modules////versions` | every published version | | `v1/modules/////download` | `{"location": ...}`, the tarball, git tag or OCI artifact | | `v1/modules/////-.tar.gz` | the module, with `download: tarball` | | `v1/modules/////release.json` | the commit and content digest the next publish compares against | A static server sends no `X-Terraform-Get` header, so the download answer is a JSON body with `location`, which both binaries read. A version is listed in `versions` only after the files it points at are written. 5. Pin a root to the registry and run `init`. `~> 1.0` resolves to the newest `1.x` the registry lists. In a monorepo, each part's modules can go under a namespace of their own. Tags are `/v`, so the path prefix of a module is its tag prefix: ```yaml registry: bucket: s3://acme-modules url: https://modules.example.com namespace: acme namespaces: platform/: platform data/: data ``` `platform/modules/network` is `modules.example.com/platform/network/generic`. Two modules at one address stop the publish and name both. With `dir`, the publish job writes the files into that directory of its checkout, beside what is there. You deploy the directory to the Pages site and keep the earlier versions in it (from the site's branch, for example), since `versions` lists only what the directory holds. A [rollout](/terragucci/guides/roll-out-a-module-version/) of `modules/network` moves the `version` of each call to its registry address. A call pinned by a constraint such as `~> 1.0` has no one version to move, and the rollout refuses it. ## Attest each release With `modules.attest`, the publish job also signs each new release with a key you hold and records the version in the release ledger on [`chant/lifecycle`](/terragucci/concepts/glossary/#chantlifecycle), so a root's pin can be checked against all of it. 1. Make a cosign key pair, and commit the public half. ```bash cosign generate-key-pair git add cosign.pub ``` Keep `cosign.key` out of the repo. Signing never writes to Sigstore's public transparency log. 2. Add two more secrets, the same way as the registry credentials in step 4. | Variable | Holds | |---|---| | `COSIGN_PRIVATE_KEY` | the contents of `cosign.key` | | `COSIGN_PASSWORD` | its password | 3. Turn it on, and write the pipeline again. ```yaml modules: path: modules/* publish: git-tags attest: true # or attest: { key: keys/modules.pub } ``` ```bash npx terragucci init ``` The publish job now installs cosign, checked against its release checksums, before it publishes. 4. After a module change merges, check what was released. ```bash npx terragucci verify-release modules/service 0.2.0 ``` It reads the tag as it stands now and prints `verified` for each target, or `refused` and why. terragucci runs each version through these phases from this config, with no file of your own: | Phase | Does | |---|---| | Archive | takes the bytes the tag names: the module archive for a git tag, the manifest for an OCI tag | | Sbom | writes SPDX 2.3 JSON from the module's HCL: each provider in `required_providers`, at its lock file's version when there is one, and each module it calls from another source | | Sign | signs the bytes with cosign | | Provenance | attests SLSA v1 provenance naming the commit and the run | | SbomAttestation | attests the SBOM | | Verify | checks all three against `cosign.pub`; a job given the wrong key stops here, before it writes anything | | Record | makes the release record: the module's path, the digest of those bytes, the commit, the run and the actor | | What | Where | |---|---| | The release record | a line of `modules/releases.jsonl` on [`chant/lifecycle`](/terragucci/concepts/glossary/#chantlifecycle) | | The bundles and the SBOM | `modules/attest//` beside it | | An OCI tag's signature and attestations | attached to its manifest | | A git tag and its record | pushed together in one atomic push; an OCI tag is pushed only after its record | A tag pushed by hand has no record that matches it, and neither does one moved after its release; `verify-release` refuses both. To go back to an earlier version, [roll out](/terragucci/guides/roll-out-a-module-version/) that version again. ## Require attested releases With `modules.require: attested`, `tf-check` and `tf-plan` check each module pin a root makes before they plan it. A root pinning a release that does not verify fails. The check report and plan note name its module call and version and say why. In a Terragrunt repo the pin is each unit's `terraform { source }`. `tf-check` runs `terragucci check-pins` over every unit, and `tf-plan` takes a refused unit out of its wave, so it fails without planning while the rest of the wave plans. ```yaml modules: publish: git-tags attest: true require: attested ``` Only these sources are checked; a module from anywhere else, such as the public registry, is left alone: | Source | Checked when | Key | Ledger | |---|---|---|---| | This repo's own modules | `modules.attest` is on: the repo's git URL for `git-tags`, each `oci://` target, and the host of `modules.registry` | `modules.attest.key` | `chant/lifecycle` on `origin` | | A publisher in another repo | `modules.trusted` lists it | its `key` | its `ledger` | ```yaml modules: require: attested trusted: - source: git::https://git.example.com/platform/modules.git key: keys/platform-modules.pub ledger: https://git.example.com/platform/modules.git ``` To trust a publisher, commit their `cosign.pub` at the path `key` names. For each pin of a checked source: | Check | Refused when | |---|---| | The pin | it names no one release: a git `ref` that is not a tag, an `oci://` source with no tag or digest, a registry source whose `version` is a constraint | | The record | the publisher's ledger has no record of the bytes the tag names now, from the tag's commit: a tag pushed by hand, moved, or never published | | The signature, provenance and SBOM | any of them is missing or does not verify against the key | | Job | Reads the setting and the keys from | |---|---| | `tf-plan` | the base branch, as it reads [the policy](/terragucci/reference/policy/#the-base-branch-decides), so a pull request cannot turn the check off or trust a key of its own | | `tf-check` | the base branch when its checkout has it, else the checkout | A registry source is checked where its registry says the version is: the tarball's bytes, or the git tag or OCI artifact its download points at, each checked as that kind of source is. Under `download: tarball` a release is signed and recorded in the ledger before `versions` lists it. The jobs read the registry over HTTPS, as `tofu init` does, so a private CA's certificate goes in `NODE_EXTRA_CA_CERTS`. Tags and the ledger are fetched with the jobs' own git access; an `oci://` source is read with no login, or with `TERRAGUCCI_REGISTRY_USER` and `TERRAGUCCI_REGISTRY_PASSWORD` when the job has them. ## Next - [Roll out a new module version](/terragucci/guides/roll-out-a-module-version/) moves your roots onto it. - [Environment variables and credentials](/terragucci/reference/environment/) --- # Turn on drift checks Source: https://intentius.io/terragucci/guides/turn-on-drift-checks/ ## Optional: hand this page to your coding agent ```text Read https://intentius.io/terragucci/guides/turn-on-drift-checks/. Add a `drift:` cron to terragucci.yml, run `npx terragucci config check --json` and `npx terragucci init`, and open a pull request. Tell me the GitLab schedule or token scope I must set. Never apply, approve (a pull request review or `terragucci approve`), override a policy denial (`terragucci override`), use `--mode apply`, or merge; never touch `.chant/allowed_signers` or `chant/lifecycle`. ``` ## Result A scheduled `tf-drift` run that plans every root, and one issue that groups what drifted like a pull-request plan. ## Prerequisites | You need | Why | |---|---| | The pipeline from [Get your first plan note](/terragucci/getting-started/) | `init` adds the drift job to it | | A plan role that can read your cloud | drift plans use the same read-only role as pull requests | | Terraform or OpenTofu roots, or Terragrunt units | the issue names roots, or units in a Terragrunt repo | | A cron expression in UTC | forge schedulers read cron in UTC | ## Steps 1. Set a schedule in `terragucci.yml`. Pick an odd minute; forge schedulers are busiest on the hour. ```yaml drift: "17 4 * * *" ``` 2. Check the file. ```bash npx terragucci config check ``` ```text terragucci.yml: ok approval: ledger (the default) ``` A bad value is listed as a problem, and the command exits 2. 3. Write the pipeline again and merge it. ```bash npx terragucci init ``` **GitHub** `init` adds a `drift` job with the cron. A `workflow_dispatch` with no `pr` input runs it by hand. **GitLab** Set the same cron under CI/CD, Schedules. `GITLAB_TOKEN` (or the `token_env` variable) needs the `api` scope. Give the drift schedule no `TERRAGUCCI_SCHEDULE` variable. A schedule with `TERRAGUCCI_SCHEDULE` set to `comments` is the [comments schedule](/terragucci/guides/re-plan-from-a-comment/), and its pipelines run no drift job. **Forgejo** The same job and trigger as GitHub. 4. Wait for the first run, or start one. `terragucci stage tf-drift` plans each root with `-refresh-only`, comparing state with real objects. Only an object changed or deleted outside Terraform counts as drift, and a merged but unapplied change does not. A [choudoufu](/terragucci/concepts/glossary/#choudoufu) root under live resource markers plans in full instead ([below](#live-roots)). 5. Read the issue. Each project has at most one open issue, `terragucci: drift found`, which every run updates. A root that cannot be refreshed fails the job; drift alone does not. | Event | The issue | |---|---| | Drift found | opened, or updated if one is open | | Every root clean | closed | | A root could not be planned | left as it is | **GitHub** **GitLab** **Forgejo** 6. Remediate the drift. With `respond.drift` left at its default, the drift job also opens a pull request from `terragucci/drift`. It writes the live value where the root's own resource block sets a literal, and adds an import block for a resource the state does not hold. [Responses](/terragucci/reference/responses/#drift) lists what is only reported. | To | Do | Then | |---|---|---| | keep the live value | read the pull request, comment `/terragucci plan` on it for its plan note ([Re-plan from a comment](/terragucci/guides/re-plan-from-a-comment/)), and merge it | the apply job applies it in waves, behind the gate like any change | | put the object back | close the pull request and apply the root again | the apply makes the object match the code | A coding agent can take what the pull request cannot write (a value set through a variable or a module). Set [`agent.drift`](/terragucci/guides/agent-fix-drift/) and the drift issue starts an agent that returns its change as a pull request. Nothing in the drift job applies or merges. Each wave's approval and each run's report stay in the [audit trail](/terragucci/reference/audit-trail/). Under `respond.drift: attribute` the issue also names each attribute's changer from the audit log ([Drift attribution](/terragucci/reference/responses/#drift-attribution)): 7. Watch for an overdue note. When the schedule stops, the next pull request's plan note says why the drift checks are overdue ([Overdue drift checks](/terragucci/reference/stages/#overdue-drift-checks)). ## Roots another branch applies With [`apply.branches`](/terragucci/reference/config/#apply-from-other-branches), the drift job checks a root that another branch applies against that branch's code. The job runs on the default branch and plans the root in a checkout of its branch fetched from `origin`. The log names the branch and its commit: ```text apply.branches: envs/prod/app plans from release at 3f9a1c2e, the branch that applies it ``` A provider, backend or version the branch sets differently is the one the check uses, so a difference between the two branches is never reported as drift. A root whose branch cannot be fetched fails its check with the reason; it is never checked against the default branch instead. In a Terragrunt repo, a branch's units plan with one `run --all` in that branch's checkout. ## Live roots A choudoufu root under live resource markers keeps no state to refresh: every plan of it reads the live system, and choudoufu refuses `-refresh-only` there. `tf-drift` plans such a root in full, and each change the plan would make is drift, read from the live side: | The plan would | The issue says | |---|---| | change an attribute | the attribute changed | | create a resource | it no longer exists | | destroy a resource | it exists outside the code | A change merged to the default branch but not applied yet also shows here, since the plan cannot tell it from a change made outside choudoufu. The rest of the drift job is the same: one issue, `respond.drift` and attribution. ## Next - [Stages](/terragucci/reference/stages/) lists what `tf-drift` reads and writes. --- # Keep reports in a bucket Source: https://intentius.io/terragucci/guides/keep-reports-in-a-bucket/ ## Optional: hand this page to your coding agent ```text Read https://intentius.io/terragucci/guides/keep-reports-in-a-bucket/. Add the `reports:` block for bucket , rerun `npx terragucci init`, write the access policy the page gives for my cloud as a file for me to review, and open a pull request. Do not create the role, the bucket, the container, any secret or the front door stack. Never apply, approve (a pull request review or `terragucci approve`), override a policy denial (`terragucci override`), use `--mode apply`, or merge; never touch `.chant/allowed_signers` or `chant/lifecycle`. ``` ## Result Every run's report in a bucket under one path per run, and an `index.html` that lists every plan you have run. ## Prerequisites | Cloud | Store | Identity the plan job writes with | |---|---|---| | AWS | an S3 bucket, or an S3-compatible store such as R2 or MinIO | `reports.role` over OIDC, or keys | | GCP | a Cloud Storage bucket, through its JSON API | the plan service account of `oidc.gcp` | | Azure | a Blob Storage container | the plan client of `oidc.azure`, or the account key | [OIDC](/terragucci/reference/pipeline/#credentials) sets up the GCP and Azure identities. ## Steps 1. Name the bucket in `terragucci.yml`. **AWS** ```yaml reports: bucket: s3://acme-terragucci prefix: reports url: https://reports.acme.example role: arn:aws:iam::123456789012:role/terragucci-reports ``` For a store that is not AWS, add `endpoint`; without it, the job reads `AWS_ENDPOINT_URL_S3` or `AWS_ENDPOINT_URL`. Requests are signed for `AWS_REGION` or `AWS_DEFAULT_REGION`, else `us-east-1`. To sign for another region, set it in the config's `env`: ```yaml env: AWS_REGION: eu-west-2 ``` **GCP** ```yaml reports: bucket: gs://acme-terragucci prefix: reports ``` `endpoint` defaults to `https://storage.googleapis.com`; an emulator sets its own. **Azure** ```yaml reports: bucket: az://acmeterragucci/reports prefix: reports ``` The bucket is `az:///`. Set `endpoint` for a sovereign cloud or an emulator; it defaults to `https://.blob.core.windows.net`. `url` is where a browser opens the reports, such as the front door (step 5). The plan note links `report.html` and each root's `plan.txt` there. Without `url` the note uses presigned bucket links that last up to 7 days. 2. Regenerate the pipeline and commit it. ```bash npx terragucci init ``` 3. Give the job an identity that writes only reports. The plan job runs the pull request's code, and that code can read its credentials. Limit this identity to read and write under the prefix. **AWS** Allow only `s3:GetObject` and `s3:PutObject` on `arn:aws:s3:::acme-terragucci/reports/*`. With `oidc` set, `reports.role` is that identity, assumed through STS `AssumeRoleWithWebIdentity` under a trust policy like your plan role's. `config check` refuses a `reports.role` that is the plan or apply role. Without `oidc`, give an IAM user the same policy and store its keys as secrets. `init` passes them only to the jobs that plan, where OpenTofu sees them too. | Secret | Needed | |---|---| | `AWS_ACCESS_KEY_ID` | always | | `AWS_SECRET_ACCESS_KEY` | always | | `AWS_SESSION_TOKEN` | when the keys are temporary | | Order | Identity | |---|---| | 1 | `reports.role` | | 2 | `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` | | 3 | `AWS_ROLE_ARN`, which a job with `oidc` sets to its own role; that role then needs write access to the reports | A role is assumed with the token in `AWS_WEB_IDENTITY_TOKEN_FILE`; `AWS_ENDPOINT_URL_STS` or `AWS_ENDPOINT_URL` sets the STS address. When STS refuses it, the job fails with STS's error. **GCP** Writes go through `oidc.gcp.plan_service_account`, using the `external_account` file in `GOOGLE_APPLICATION_CREDENTIALS`. | Grant to the plan service account | On | For | |---|---|---| | `roles/storage.objectUser`, with an IAM condition on the `reports/` prefix | the bucket | reading and writing reports | | `roles/iam.serviceAccountTokenCreator` | the service account itself | signing the estate page's link through IAM `signBlob` | A `service_account` key file in `GOOGLE_APPLICATION_CREDENTIALS` works too: requests carry a JWT it signs, and links are signed with the key. **Azure** The job writes as `oidc.azure.plan_client_id`, trading its token in `ARM_OIDC_TOKEN_FILE_PATH` at Entra ID for a storage token. | Assign to the plan client | Scope | For | |---|---|---| | Storage Blob Data Contributor | the container | reading and writing reports | | Storage Blob Delegator | the storage account | the user delegation key that signs a link | `AZURE_AUTHORITY_HOST` names Entra ID outside the public cloud, such as `https://login.microsoftonline.us`. Without `oidc.azure`, store the account key as the secret `AZURE_STORAGE_KEY`; `init` maps it to the jobs that plan. An account key reaches every container in the account. **GitHub** Store them as repository secrets. **GitLab** Use unprotected CI/CD variables. **Forgejo** Repository secrets, as on GitHub. Even so, a pull request's plan can overwrite any object under the prefix, including the index. The apply job's CI artifact is the record of what was applied. 4. Open a pull request. When the plan job finishes, its log says where the report went: ```text report: terragucci-report/report.html copied to the bucket under reports/github.com/acme/infra/2026/10/4f1a9c0d2e7b8a6c5d4e3f2a1b0c9d8e7f6a5b4c/tf-plan; index rewritten at reports/github.com/acme/infra/index.json and reports/index.json served at https://reports.acme.example/reports/github.com/acme/infra/2026/10/4f1a9c0d2e7b8a6c5d4e3f2a1b0c9d8e7f6a5b4c/tf-plan/report.html ``` Each run gets one path holding the commit's full SHA. Links inside a report are relative: ```text reports/github.com/acme/infra/2026/10/4f1a9c0d2e7b8a6c5d4e3f2a1b0c9d8e7f6a5b4c/tf-plan/report.html reports/github.com/acme/infra/2026/10/4f1a9c0d2e7b8a6c5d4e3f2a1b0c9d8e7f6a5b4c/tf-apply-wave-2/report.json ``` | Forge | Commit in the path | |---|---| | GitHub | `GITHUB_SHA`, the merge commit on a pull request | | GitLab | `CI_COMMIT_SHA` | | Forgejo | `GITHUB_SHA` | 5. Open the reports. Every upload adds the run's row to two `index.json` files, one at the project's path and one at the top of the prefix, and writes `index.html` from each. A `tf-apply` wave also writes these at the project's path: | File | Holds | |---|---| | `inventory.json` | the resources each applied root holds | | `changes.json` | what the wave did to each resource | | `states.json` | the version of each applied root's state | Writes are retried up to 8 times: | Store | Index write | |---|---| | S3, Azure Blob | conditional: `If-Match` the ETag read, or `If-None-Match: *` | | GCS | conditional: `ifGenerationMatch` the generation read, or `0` | | An S3-compatible store with no ETag | plain write, last run wins | | An S3-compatible store that answers `501 Not Implemented` | the condition is dropped | The bucket holds every plan, including each attribute value not marked sensitive. Keep it private and open it one of two ways: | | Presigned link | Front door | |---|---|---| | You deploy | nothing | one CloudFormation stack in your account, in `us-east-1`; AWS only | | Who can open it | anyone holding the link, until it expires | whoever your identity provider signs in | | What opens | one object; its links to other reports need links of their own | every report and index, with their links | | `reports.url` | not set: the plan note links `report.html` and each `plan.txt`, presigned | `https://` | **Presigned link** Sign one object for an hour: | Cloud | Command | Longest life | |---|---|---| | AWS | `aws s3 presign s3://acme-terragucci/reports/github.com/acme/infra/index.html --expires-in 3600` | 7 days with an IAM user's keys; a role's session ends sooner | | GCP | `gcloud storage sign-url gs://acme-terragucci/reports/github.com/acme/infra/index.html --duration 1h --impersonate-service-account ` | 7 days | | Azure | `az storage blob generate-sas --account-name acmeterragucci --container-name reports --name reports/github.com/acme/infra/index.html --permissions r --expiry