Evidence · Use it

Running an estate from CI

What a choudoufu pipeline is

A chant project over one live root, with the workflows generated from it, rather than a YAML file per forge that you fill in. chant’s terraform lexicon runs a choudoufu root natively under binary: "choudoufu" once the root declares an estate. So the pipeline a choudoufu estate needs is a set of Ops, and each forge’s YAML falls out of them. examples/ci-pipelines is that project, with the generated workflows checked in beside it.

Five Ops, whose names are also the five job names:

JobTriggerWhat it does
live-checkpull requestRuns choudoufu live-check and fails the pull request on a refusal. No cloud call, no state, no credential.
live-planpull requestRuns choudoufu live-plan -detailed-exitcode -json and reports drift, live resources sitting at a declared identity with no marker, and the subset an exact content match makes adoptable.
live-applypush to mainInit, plan -out, a gate on the rendered plan, then apply <planfile>.
live-adoptpush to stagingCheck, the adoption ledger, a gate, then the two marker tags per adoptable resource. Nothing in the source changes.
live-discovercronSweeps the account with live-ls -consistent, reads the adoption ledger over it, and reports what carries this estate’s marker that nobody declared.

Three of them read and nothing else. The two that write are the two on a push trigger, and both stop at an approval first.

The gate is a fact on chant’s ledger rather than a runner held open: a run that reaches it finds no resolution, records that it is waiting, and ends. chant approve live-apply approve-live-apply writes the resolution, and re-running the workflow walks through it, via the plan file plan -out wrote - apply <planfile> does not replay that file. Prior state is rebuilt from the live system on every run, so the apply re-plans against the cloud as it is now and compares that fresh plan against the approved one, down to the planned values: agreeing, it applies without re-prompting; disagreeing, it refuses before anything changes, printing The approved plan no longer matches the live system and exiting 3 - neither an ordinary failure nor -detailed-exitcode’s 2, so a pipeline can route the run back to review instead of paging someone. That refusal is the gauntlet’s plan_approval stage, measured on every estate: see the stage table and Compatibility reference for what the two plans are compared on. What a gate resolution binds - and does not - is covered below.

The per-environment dial

chant’s lifecycle dial is observe, reconcile, authoritative, chosen per environment. Here it is a choice of which Op an environment runs:

All of that is one chant.config.ts over one root, so there is no second copy of the pipeline to keep in step with the first. An environment differs by which Op fires on which trigger.

Per forge

What each forge’s pipeline does. All three generate the full five-job set now (GitLab crossed over in chant #2268), and the differences below come from what each forge’s dialect can carry:

GitHubForgejoGitLab
Jobsall 5, permissions: computed per joball 5, permissions:/id-token: dropped (runner ignores them)all 5, one combined file (rules: per job, not on:)
Credentials3 OIDC roles, one per job that needs one3 static key pairs, one per job that needs one - live-check holds none (#1028)3 OIDC roles via id_tokens: and a shell script, no marketplace action
Plan lands asa pull-request comment, edited in placea pull-request comment, edited in place (chant#2291)a merge-request note, edited in place
Sweep reachesa GitHub issue, edited in placethe run’s own log - issue’s endpoints are unverified there (#1027)a GitLab issue, edited in place (chant#2292)
Apply gatechant’s gate, plus a production environment reviewerchant’s gate only - Forgejo Actions has no environmentschant’s gate, plus production; an audit trail only on CE
Reportingcomment/issue/pull-request via ghcomment via gh, built from GITHUB_API_URL (chant#2291); issue stays reportcomment/issue via a plain REST call to GitLab’s own API

All three have now been run, once each; the three below ran against no real cloud account, and GitHub has since had a fourth run against a real one:

StackResult
GitHubv0.16.0, run 34313049854, floci as a service container, all four triggers plus the gate/approve/re-run loop (#1026)pass=12 fail=0 verdict lines - two jobs could not have started; see below
Forgejo12.0.4+gitea-1.22.0, forgejo-runner v9.1.1, no cloud behind it (#1027)checkout, npm ci, the pinned binary, init and chant run all worked; the run ended at the credential call
GitLabCE 17.11.0, gitlab-runner 17.11.0, docker executor, floci behind it, 10 pipelines / 17 jobs (#1026)live-check, live-plan, live-discover green unmodified; live-apply and live-adopt red, both fixed by setup steps below
GitHub, real AWSmain at 172bf2a390, run 34644390301, account 354867293429, us-east-1, 2026-09-11, OIDC-minted credentials, no stored key (#807)pass=13 fail=0 - the gate recorded and resolved on the git ledger, the apply refusal returning exit 3

What running rather than reading found:

None of the three dialect-proof runs above had an AWS account behind it. GitHub’s own OIDC exchange has since been verified for real, separately: run 34644390301 (main at 172bf2a390, 2026-09-11) minted a short-lived credential over token.actions.githubusercontent.com and applied against account 354867293429 with no stored key. GitLab’s id_tokens: role assumption follows GitHub’s shape, but floci’s static credentials shadowed it end to end in the GitLab run above, so no STS call was made there (#807 Q2 stays open). Forgejo has no OIDC surface to reach for at all - it ships static keys and says so - which is the credential model worth replacing outright rather than the one waiting on a proof. Since #1028, at least each job holds only the key pair its own Op needs, rather than one pair shared by every job in the file.

examples/ci-pipelines/README.md and its smoke workflow header comment carry the full detail, and examples/ci-pipelines/e2e/gitlab/README.md carries the GitLab run in full.

Governance

examples/pipeline-governance holds one warden policy per forge - github, forgejo and gitlab (#1008) - written against the five job names above. live-check and live-plan are required checks before a pull request can merge; main, staging and chant/lifecycle are all protected branches on every forge (#1024); the apply credential is required to be present without its value being read.

GitLab’s policy takes its own shape rather than reskinning the other two: gitlab-warden’s config is a flat nodes: map rather than the orgs: -> repos: shape the other two share, and two of its rules - the approval requirement and the protected environment - are Premium-only and answer 404 on CE, so the policy declares them and says so rather than pretending they converge. Where GitLab differs, and why has the rest, including the presence-only GITLAB_TOKEN variable and its own apply-time trap.

The approval of record is chant’s gate, not the forge’s. A forge-side reviewer stacks on top of it: live-apply’s spec carries environment: { name: "production" }, so the generated GitHub and GitLab jobs both declare it, and GitHub’s policy gates the job with a required reviewer. GitLab CE cannot, per “Per forge” above. Forgejo Actions has no environments at all, and its generated file says so in a header comment.

What keeps all three current is the currency guard in examples/ci-pipelines: npm test regenerates into a scratch directory and diffs the checked-in workflows byte for byte, so an edited or orphaned workflow fails loudly. That needs node, which this repository’s Go CI does not have, so live/ci_pipelines_test.go is the backstop: every workflow tracked, named to its own Op and forge, the choudoufu install pinned and checksum-verified, and the per-forge policy check reading the real forge list rather than a hardcoded pair.

Before the first run

Each forge’s generated YAML reads a set of variables, secrets and platform objects that nothing in this project provisions. Create these before pointing a trigger at the generated workflow; the names below are read out of the generated trees themselves, not assumed.

GitHubForgejoGitLab
RegionAWS_REGION repository variable, read by every jobAWS_REGION repository variableAWS_REGION project CI/CD variable
Credentials3 role ARNs as repository variables (CHOUDOUFU_PLAN_ROLE_ARN for live-plan/live-discover, CHOUDOUFU_ADOPT_ROLE_ARN for live-adopt, CHOUDOUFU_APPLY_ROLE_ARN for live-apply) plus matching IAM roles, OIDC-trusted for pull_request+refs/heads/main (plan role), refs/heads/staging (adopt role), refs/heads/main (apply role); live-check needs none3 static key pairs as repository secrets, one per job that needs one (CHOUDOUFU_PLAN_ACCESS_KEY_ID/_SECRET_ACCESS_KEY for live-plan/live-discover, CHOUDOUFU_ADOPT_* for live-adopt, CHOUDOUFU_APPLY_* for live-apply) - job-scoped since #1028, so live-check holds nonethe same three role ARNs as project CI/CD variables (unprotected, or paired with protected source branches - a protected one is silently absent otherwise) plus IAM roles trusting aud: $CI_SERVER_URL; GITLAB_TOKEN (masked, scope api) for live-plan’s note and live-discover’s issue, required rather than a fallback
Compute-a runner registered under the docker label, reachable to https://code.forgejo.org/actions/checkout@v4 (every job’s first step)gitlab-runner on the docker executor
Apply gateproduction environment, required reviewer - live-apply declares environment: { name: production } and nothing else gates itnone - Forgejo Actions has no environmentsproduction protected environment; Premium only, 404 on CE, where chant’s own gate is the only control
Governanceexamples/pipeline-governance/github’s branch rulesexamples/pipeline-governance/forgejo’s branch rulesexamples/pipeline-governance/gitlab’s rules

GitLab also needs a git identity for the jobs - two git config lines in a default: before_script: on the consumer .gitlab-ci.yml (chant#2301) - and the chant/lifecycle ref fetched in the job, or the approve-then-re-run loop reads an empty ledger and gates again (chant#2303). Also a Pipeline Schedule (Settings > CI/CD > Schedules) with its CHANT_SCHEDULED_OP variable set to live-discover, since the job’s own rules: only fire on a scheduled pipeline carrying that value, and the include: of the generated file in the project’s own .gitlab-ci.yml - GitLab does not read ops.gitlab-ci.yml on its own.

Forgejo also needs write access, treated as the right to start live-apply: the dispatch endpoint does not check that a workflow declares workflow_dispatch:, so the missing “Run workflow” button on live-apply.yml is not the surface to reason about (#1027) - what holds the apply back is chant’s own gate. And somewhere else to serialize applies if two running at once would hurt: concurrency: is inert on Forgejo 12.0.4, so two live-apply runs on different refs proceed side by side, and two on the same ref end with the earlier one cancelled mid-step rather than queued, the opposite of what cancel-in-progress: false reads as promising.