Guidance for Claude Code (claude.ai/code) when working in this repository.
Reference documentation lives in docs/. This file does not duplicate it — it carries the
rules that decide whether a change is safe, and points at the docs for everything descriptive. When
you need the detail, read the doc; when you change a convention, update the doc.
| Looking for | Read |
|---|---|
| Naming, module anatomy, variable and comment style | docs/conventions.md |
| Validation loop, adding a module, deferred work | docs/contributing.md |
| Source refs, pinning, provider wiring, version matrix | docs/usage.md |
| Per-family behaviour and quirks | docs/modules/ — azure, entra, fortios, github |
A library of reusable, independently-versioned Terraform modules — not a deployment repo. No root configuration, no state, no environment composition. Modules are consumed by other repos via a git source ref.
Every module lives under src/modules/<module-name>/ and stands on its own. Some carry nested
submodules under modules/<child>/; those are module directories too, so a tree-wide sweep has to
include them. The inventory is docs/modules/ — nowhere else.
CI is .github/workflows/pipeline.yaml, four jobs:
discovery → build → gate → release. discovery resolves the version, build runs fmt + the docs
check + validate, gate is the single required status check, and release tags and publishes.
build is skipped entirely when a change touches neither src/ nor the docs; any change under src/
validates the whole tree. Every merge that touches src/ is released — feat: bumps the minor and
anything else the patch. A major is never inferred: ask for it with +semver: major or a bumped
next-version. Docs- and CI-only merges do not publish.
(details)
Run the same checks locally before pushing:
terraform fmt -recursive # from repo root
python .github/scripts/check-docs.py
cd src/modules/<module-name>
terraform init -backend=false && terraform validatevalidateneedsinitfirst to resolve providers and relative submodule sources.- Sweeping the tree: set
TF_PLUGIN_CACHE_DIRfirst, or you re-download every provider once per module directory — plus the oneexamples/basic/, which inits like any other. Script in contributing.md. - An
initfailure after a version bump is usually a stale local.terraform.lock.hcl, not a repo defect. Re-run with-upgrade. Do not "fix"versions.tfto satisfy a stale lock. (why)
These are the ones that cause real damage when ignored. Each links to the reasoning.
- Every merge that touches
src/is a release. Trunk-based: there is one long-lived branch, and merging a module change to it tags a version and publishes it. Docs, CI and repository config do not publish — a consumer resolves//src/modules/<name>, so nothing outsidesrc/reaches them. The commit subject picks the segment —feat:a minor, anything else a patch. Verify a module's interface and behaviour before changing either, and prefer additive changes with defaults. A breaking change is not forbidden, but it does not bump the major on its own: mark it<type>!:or with aBREAKING CHANGE:footer so the release notes say so, and let breaks accumulate until a major is cut deliberately. Because the version no longer carries that signal, an unmarked break is invisible — the notes are the only place a consumer reads it. - Never delete or re-address a module based on in-repo reference count. Submodule paths are
addressable by git ref, so external callers reach them directly.
azure-res-network-privatednszone/modules/vnet-linkreads as dead code and is not. (why) - Keep consumer identifiers out. Public repo: no customer names, real hostnames, switch or SSID
names, domains, serials, directory object IDs, or downstream filesystem paths. Use
example.com,core-sw-01,corp-nac,rg-example. - Never commit
.terraform.lock.hcl. Gitignored on purpose — lock files in reusable modules cause cross-platform checksum mismatches. Consumers pin their own. Same for state and tfvars. - No
providerblocks inside modules.required_providersinversions.tf; the consumer configures and authenticates. (The one exception in the tree is underexamples/.) - Every provider constraint keeps an upper bound below the next major. (why)
GitVersion.yamlandcliff.tomldiverge on purpose — keep the anchoring identical.cliff.tomlflags all three breaking markers (<type>!:, aBREAKING CHANGE:footer,+semver: major) in the notes; GitVersion majors on the last one only. That gap is the design. What must not drift is the(?m)anchoring: a marker on a body line has to behave the same in both, or the notes and the number describe different commits. (why)
- Mixing inline and child-resource collections causes permanent drift. Several modules
deliberately leave the parent's inline attribute unset (
azuread_group.members, AUmembers) because a submodule owns it. Read the banner before "completing" the parent. (why) - UUID-vs-UPN routing has an unknown-at-plan-time edge. Keep resource
for_eachkeyed on the static input map and resolve the routing inside the body. Only theazuread_userdata source may take a value-derivedfor_each. Filtering the map andfor_each-ing the result is the bug. (why) - FortiOS perpetual diffs are the dominant failure mode in that family — the provider returns values you never set. Where a module omits a default, ignores an attribute, or forces sort order, that is deliberate. Always explain such a choice in a banner. (why)
- Tautological validation never fires.
alltrue([for v in values(x) : v.scope != null || true])is always true. Reference the second variable directly — cross-variable validation is legal andrequired_versionis high enough everywhere. (more) - Don't guess enum values you cannot verify. The fortios provider does not publish accepted values
for many attributes; a guessed
contains(...)rejects working configurations. - The Aegis tag contract is fixed. Consumers key off the tag name and value. Read the banner in
each
main.tfbefore touching it. (why) prevent_destroyis hardcoded on three GitHub resources and is not a bug. (why)
Comments explain what the code cannot — why a lifecycle block exists, why an attribute is
deliberately unset, a provider bug being worked around. Not prose restating the declaration below it.
Banners are not a mandatory file header; add one when there is something non-obvious to say. Exact
format and the full do/don't list: conventions.md.
Every variable and output carries a description — the tree is at 100% coverage, keep it there.
Validation coverage is uneven (heavy in azure/entra, sparse in fortios); follow the azure/entra
standard in new code. (more)
.terraform/provider-cache directories exist on disk and are not source — including their vendored READMEs and CHANGELOGs. Don't read them, don't count them, don't edit them..gitignoreis a ~430-line Visual Studio/.NET template. Everything Terraform-relevant is from# Plugin cache and module cacheonward; the rest is inherited noise.
Conventional Commits, scoped to the module or component changed:
feat(modules): add FortiGate user setting module
feat(vnet-link): add Azure VNet link module
fix(modules): set default for protected_resources
chore: update provider versions for compatibility
Trunk-based: main is the only long-lived branch. Work on short-lived branches and open a PR
against main. A merge touching src/ ships a version — feat: a minor, anything else a patch. Mark breaking
changes <type>!: or with a BREAKING CHANGE: footer or they release as routine ones — see
GitVersion.yaml.
- The module inventory lives only in
docs/modules/*.md. Adding a module means adding one row to one family guide —check-docs.pyfails a module on disk with no row, and a row with no module. Don't reintroduce the inventory elsewhere. - Never write a module count. They were all removed deliberately. A tally in prose has to be restated in every file that carries one, goes stale the moment it isn't, and buys a reader nothing a list doesn't. The guides enumerate modules; they do not total them.
- Per-module input/output tables are deliberately not generated —
variables.tfandoutputs.tfare the reference, and there is no CI to keep generated copies honest.