Publish your modules
Optional: hand this page to your coding agentThe steps work by hand too.Show the whole prompt
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
Section titled “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
Section titled “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 |
-
Name the modules and where they go in
terragucci.yml.modules:path: modules/*publish: oci://registry.example.com/acme/modulespublishtakes anoci://registry address,git-tags, or a list of both.Target Roots pin Credentials oci://registryOpenTofu roots, by tag or @sha256:digestthe registry variables in step 4 git-tagsTerraform roots, by git tag none; the tags are pushed to origin -
Preview.
Terminal window npx terragucci publish --dry-runThe output lists each changed module and its next version under the version bump rules. For commits with no conventional type, see
respond.version-bump. -
Add the publish job.
Terminal window npx terragucci initThat adds a
publishjob, which runs with full history afterapplyon each push to the default branch. -
Give it registry credentials; no other job gets them.
Repository secrets.
Protected, masked CI/CD variables.
As on GitHub, repository secrets.
Variable Holds TERRAGUCCI_REGISTRY_USERthe registry user TERRAGUCCI_REGISTRY_PASSWORDits password or token TERRAGUCCI_REGISTRY_INSECURE1for a registry without TLS -
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,<module>/v<version>. Heremodules/servicemoved to0.2.0after a change whilemodules/queuestayed at0.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
Section titled “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.
binary: tofu
modules:
path: modules/*
publish: git-tags
test: trueThe test files are the binary’s own: *.tftest.hcl (and *.tofutest.hcl for OpenTofu) in the module or in its tests directory.
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:
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
Section titled “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. Roots call the modules by registry address, with a version that can be a constraint:
module "network" {
source = "modules.example.com/acme/network/generic"
version = "~> 1.0"
name = "dev"
}-
Name the bucket, the address that serves it, and the namespace.
modules:path: modules/*publish: git-tags # optional; the registry can stand alonetest: trueregistry:bucket: s3://acme-modulesurl: https://modules.example.comnamespace: acmeSetting Default Holds urlrequired 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 sourcenamespacerequired the namespace modules go under bucketordirone is required an s3://,gs://oraz://bucket, or a directory in the repo that a Pages site deploysprefixnone where the files go in the bucket; urlserves that prefix as its rootendpointnone an S3-compatible store’s address namespacesnone a tag prefix (a path in the repo, such as platform/) to the namespace its modules go under; the longest prefix winssystemgenericthe third part of each address downloadtarballwhat each version’s download points at: a .tar.gzbeside it, its git tag (git-tags, whichpublishmust list), or its OCI artifact (oci, whichpublishmust list; OpenTofu only)A module’s address is
<host>/<namespace>/<name>/<system>, where the name is its directory’s name:modules/networkismodules.example.com/acme/network/generic. -
Give the publish job write access to the bucket.
Terminal window npx terragucci initThe publish job on GitHub and Forgejo maps the bucket’s key secrets as a job that keeps reports in a bucket does: for S3 the pair
AWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEY, for AzureAZURE_STORAGE_KEY. GitLab passes it CI/CD variables of those names. -
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. -
After the next module change merges, the publish job writes:
File Holds .well-known/terraform.jsonservice discovery: {"modules.v1": "/v1/modules/"}v1/modules/<namespace>/<name>/<system>/versionsevery published version v1/modules/<namespace>/<name>/<system>/<version>/download{"location": ...}, the tarball, git tag or OCI artifactv1/modules/<namespace>/<name>/<system>/<version>/<name>-<version>.tar.gzthe module, with download: tarballv1/modules/<namespace>/<name>/<system>/<version>/release.jsonthe commit and content digest the next publish compares against A static server sends no
X-Terraform-Getheader, so the download answer is a JSON body withlocation, which both binaries read. A version is listed inversionsonly after the files it points at are written. -
Pin a root to the registry and run
init.~> 1.0resolves to the newest1.xthe registry lists.
In a monorepo, each part’s modules can go under a namespace of their own. Tags are <path>/v<version>, so the path prefix of a module is its tag prefix:
registry:
bucket: s3://acme-modules
url: https://modules.example.com
namespace: acme
namespaces:
platform/: platform
data/: dataplatform/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 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
Section titled “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, so a root’s pin can be checked against all of it.
-
Make a cosign key pair, and commit the public half.
Terminal window cosign generate-key-pairgit add cosign.pubKeep
cosign.keyout of the repo. Signing never writes to Sigstore’s public transparency log. -
Add two more secrets, the same way as the registry credentials in step 4.
Variable Holds COSIGN_PRIVATE_KEYthe contents of cosign.keyCOSIGN_PASSWORDits password -
Turn it on, and write the pipeline again.
modules:path: modules/*publish: git-tagsattest: true # or attest: { key: keys/modules.pub }Terminal window npx terragucci initThe publish job now installs cosign, checked against its release checksums, before it publishes.
-
After a module change merges, check what was released.
Terminal window npx terragucci verify-release modules/service 0.2.0It reads the tag as it stands now and prints
verifiedfor each target, orrefusedand 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 |
| The bundles and the SBOM | modules/attest/<digest>/ 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 that version again.
Require attested releases
Section titled “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.
modules:
publish: git-tags
attest: true
require: attestedOnly 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 |
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.gitTo 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, 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.
- Roll out a new module version moves your roots onto it.
- Environment variables and credentials
These docs count page views and clicks with PostHog. They set no cookies, store nothing in your browser, and send nothing when your browser asks not to be tracked.