Every merge that touches src/ is a release. This repository is trunk-based: main is the only
long-lived branch, and merging a module change to it tags a version and publishes it automatically.
There is no staging branch between your PR and a consumer's terraform init.
Docs, CI and repository config do not publish. A consumer resolves //src/modules/<name> at a ref,
so nothing outside src/ reaches them — numbering it would ship a release identical to the one
before it. Those commits are not stranded: they stay unreleased until the next module change carries
them out, and the notes cover the whole range since the last tag, so they still appear.
That makes interface stability the default posture:
- Prefer additive changes with defaults over anything that alters an existing contract.
- Renaming or removing a variable or output is a breaking change. So is changing a default, changing a resource address, or tightening a validation rule that previously accepted a working value.
- If a change must break, say so explicitly in the commit body.
Keep consumer identifiers out of this repository. It is a public module library. No customer
names, real hostnames, switch or SSID names, domains, serial numbers, directory object IDs, or
filesystem paths from downstream repositories. Use neutral placeholders: example.com, core-sw-01,
corp-nac, rg-example.
.github/workflows/pipeline.yaml runs on every push and pull
request against main:
| Job | What it does |
|---|---|
discovery |
Resolves the version from GitVersion and decides whether this run releases |
build |
terraform fmt, the consistency checks, then init -backend=false + validate over what actually changed |
gate |
Aggregates the results into one always-reporting status — this is the required check |
release |
Tags and publishes — runs behind gate, on main only (push, or break-glass dispatch), when something under src/ changed and discovery resolved an unreleased version |
build does not recompute the version; it is resolved once in discovery and read from its outputs.
build is skipped when a change touches neither modules nor docs, and a required check that never
reports leaves the pull request permanently unmergeable. gate runs with if: always(), so it
reports on every run, and fails if any job it depends on failed or was cancelled. Point branch
protection at it and nothing else.
The required context string is gate.
Not yet applied. Merge the pipeline and let it run once first, or gate will not exist as a
selectable context.
gh api repos/emberstack/terraform/rulesets -X POST --input - <<'JSON'
{
"name": "main",
"target": "branch",
"enforcement": "active",
"conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } },
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{ "type": "pull_request",
"parameters": { "required_approving_review_count": 0,
"dismiss_stale_reviews_on_push": false,
"require_code_owner_review": false,
"require_last_push_approval": false,
"required_review_thread_resolution": false } },
{ "type": "required_status_checks",
"parameters": { "strict_required_status_checks_policy": false,
"required_status_checks": [ { "context": "gate" } ] } }
]
}
JSONRelease tags, so a published version can never be moved or deleted. creation is deliberately absent
— the pipeline has to be able to tag:
gh api repos/emberstack/terraform/rulesets -X POST --input - <<'JSON'
{
"name": "release-tags",
"target": "tag",
"enforcement": "active",
"conditions": { "ref_name": { "include": ["refs/tags/v*"], "exclude": [] } },
"rules": [ { "type": "deletion" }, { "type": "non_fast_forward" } ]
}
JSONNotes:
- 0 required approvals is deliberate. With a single collaborator, requiring one locks you out of your own repository; the CI check does the gating.
- No bypass actors. Admins are bound too — that is the point. To force a fix through, set the
ruleset's
enforcementtodisabled, do the work, set it back. gh api repos/emberstack/terraform/rulesetslists them;... /rulesets/<id> -X DELETEremoves one.
Two want changing and one wants deliberately leaving alone. All three matter more on a public repository than a private one:
# The default GITHUB_TOKEN is currently `write`. This workflow overrides it to
# `read`, but any future workflow added without an explicit `permissions:` block
# would inherit a write token.
gh api repos/emberstack/terraform/actions/permissions/workflow -X PUT \
-f default_workflow_permissions=read -F can_approve_pull_request_reviews=false# Enable private vulnerability reporting. SECURITY.md names it as the only way
# to report a vulnerability, and it is OFF by default — until this runs, that
# link is a dead end for anyone trying to disclose responsibly.
gh api repos/emberstack/terraform/private-vulnerability-reporting -X PUT# LEAVE THIS ONE OFF. Requiring actions to be pinned to a commit SHA would fail
# every workflow run, because every action here is pinned to a version tag by
# choice — see "How actions are pinned". Listed so the reasoning is on record
# when someone finds the setting and wonders why it is not enabled.
gh api repos/emberstack/terraform/actions/permissions -X PUT \
-F enabled=true -f allowed_actions=all -F sha_pinning_required=true! marker. The repository's squash title is COMMIT_OR_PR_TITLE, so on
a multi-commit pull request the squashed subject becomes the PR title, and a feat!: written only
in a commit subject is lost. That no longer changes the version — feat!: and feat: are both a
minor — but it silently drops the change out of the Breaking Changes section of the release
notes, which is now the only place a consumer learns of it. The squash body is COMMIT_MESSAGES, so
a BREAKING CHANGE: footer does survive. Under squash-merge, use the footer — the one marker
that survives whatever the title resolves to. A ! only lands where that setting reads from: the
commit subject on a single-commit branch, the pull request title on a multi-commit one.
All three checks live in one job on purpose: split across separate jobs, per-job VM and checkout
overhead costs more runner time than the parallelism saves. The steps use !cancelled(), so a
formatting failure still reports the docs and validate results: one push surfaces every problem,
not just the first.
No cloud credentials are involved — validation resolves providers from the registry and checks configuration statically.
discovery classifies the change with dorny/paths-filter and build skips the steps that cannot
be affected:
| Changed | fmt + validate |
consistency checks | releases |
|---|---|---|---|
src/** |
yes | yes | yes |
docs/**, *.md |
no | yes | no |
.github/workflows/pipeline.yaml |
yes | yes | no |
.github/**, LICENSE, GitVersion.yaml, renovate.json, cliff.toml |
no | yes | no |
| anything else | no | no — build is skipped entirely |
no |
Three filters, not two. src exists only to gate the release and is deliberately narrower than
modules: a change to pipeline.yaml has to re-validate the whole tree, but re-validating is not a
reason to publish — no module moved. LICENSE is in docs for the same reason; it is worth a
consistency check, and a licence change can ride out with the next module release.
The docs filter is wider than the set of files the consistency check reads, because it must also
cover every path the documentation links to. LICENSE, GitVersion.yaml, renovate.json,
cliff.toml and the chore workflows are all link targets: move one without touching a .md file
and build would skip, the merge would go green, and the broken link would fail the next
contributor's unrelated pull request instead.
The consistency checks run whenever build runs, because they validate module structure — the four
required files, and a description on every variable and output — as well as documentation. A new
module with a missing outputs.tf has to fail CI, not review.
Anything under src/ rebuilds all of src. Selecting individual modules would be wrong here:
they source each other by relative path, so editing entra-res-group/modules/member can break
entra-res-group, entra-ptn-group-collection and its example — three directories nobody touched.
Validating the whole tree removes that question rather than trying to answer it with a dependency
graph.
A merge that touches neither modules nor docs skips build entirely — and no longer releases
either, because the release gate asks for a change under src/. That closes a hole worth knowing
about: release used to sit beside gate with its own copy of the pass check, and a skipped
build is neither failed nor cancelled — so before the src gate a .gitignore edit published a
version having run no checks at all. release now depends on gate instead, which is the only
place that decides whether everything passed.
If the sweep ever resolves zero directories it fails rather than reporting a green tick.
build sets TF_PLUGIN_CACHE_DIR so every init shares one provider directory. That part is not
optional — without it Terraform downloads a private copy of every provider per module, which runs to
gigabytes and a dozen copies of azurerm.
Persisting that directory between runs with actions/cache is a different question, and the answer
is no. TF_PLUGIN_CACHE_DIR never evicts: on each provider bump the restore-key pulls the previous
cache, Terraform adds the new version alongside the old, and the whole directory is saved again. The
cache grows by another provider-sized chunk every bump, forever, and never shrinks.
If builds ever get slow enough to revisit this, cache a pinned set of provider versions rather than the whole plugin directory.
The commands below are the same checks, run locally. Run them before pushing — CI is a backstop, not a substitute.
terraform fmt -recursiveRun from the repository root before committing. terraform fmt -recursive -check exits non-zero if
anything is unformatted.
python .github/scripts/check-docs.pyThe one check that runs on every build, including changes that touch no Terraform at all. It verifies three things and exits non-zero on any of them:
| Links and anchors | every relative link and #anchor in the documentation resolves |
| Inventory | every module directory on disk appears in a family guide, and vice versa |
| Structure | the four required files per module, and a description on every variable and output |
Run it from the repository root. It needs no providers, no network and no credentials, so it is the cheapest of the three to run and the one most likely to catch you out — a renamed heading or a new module both fail it.
It deliberately does not check module counts, because the docs deliberately don't state any — see Keeping docs in sync.
cd src/modules/entra-res-group
terraform init -backend=false
terraform validateterraform validate needs init first to resolve providers and relative submodule sources.
-backend=false is sufficient — no module here has a backend.
Where a module has an examples/basic/, validate through it instead. The example supplies the
provider configuration a bare module lacks:
cd src/modules/entra-ptn-group-collection/examples/basic
terraform init
terraform validateThe same directories CI covers — every module directory plus examples/basic.
Set a plugin cache first or you will download the same providers once per directory:
export TF_PLUGIN_CACHE_DIR="$HOME/.terraform.d/plugin-cache"
mkdir -p "$TF_PLUGIN_CACHE_DIR"
find src/modules -name '*.tf' -not -path '*/.terraform/*' -printf '%h\n' | sort -u | while read -r d; do
(cd "$d" && terraform init -backend=false -upgrade >/dev/null && terraform validate) \
|| echo "FAILED: $d"
done.terraform.lock.hcl is gitignored but still written locally. After a version floor bump in
versions.tf, an old local lock fails init with:
locked provider ... does not match configured version constraint
Re-run with terraform init -upgrade. This is a local artefact, never a repository defect — don't
"fix" versions.tf to satisfy a stale lock.
- Name it to match the naming convention exactly, and place it as a
sibling in
src/modules/. FortiGate modules need thefortigateplatform segment. - Create all four files —
main.tf,variables.tf,outputs.tf,versions.tf— even if one is short. Never fold them together. - Pin versions in
versions.tf:required_versionmatching the rest of the tree, and a provider constraint with both a floor and an upper bound below the next major. - Describe every variable and output. The tree is at 100% description coverage; keep it there.
- Validate inputs — length bounds, enums, regex — to the azure/entra standard, except where the provider schema does not let you prove the accepted values.
- Add a banner to
main.tfonly if there is non-obvious behaviour to explain. - Add the module to the relevant family guide in
docs/modules/— one row, and that is the whole documentation obligation. No counts to update anywhere; don't add any. terraform fmt,init -backend=false,validate, andcheck-docs.py— the last one is what verifies steps 2, 4 and 7.
A README.md and an examples/basic/ are welcome but not required — most modules have neither,
so a new module without them is not an outlier.
Where a parent gains a submodule, add a Submodules section to the parent's README if it has one.
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 and always reflects the released library. Work on
short-lived branches — feat/…, fix/… — and open a pull request against main. Both pipeline
triggers are scoped to main, so a branch builds once a pull request is open against it and never
before. Nothing but main publishes, and the version discovery reports on a pull request is the
plain X.Y.Z that merging would release — there are no prerelease versions in this repository.
The pipeline tags and publishes a GitHub Release on every push to main that changes something
under src/. The version comes from GitVersion.yaml:
Merge to main |
Bump |
|---|---|
feat: in the subject — with or without a ! — or +semver: minor |
minor |
anything else — fix:, fix!:, refactor:, docs:, chore:, an unconventional subject |
patch |
+semver: major on a line of its own, or a bumped next-version |
major |
Only feat: earns a minor because a new module is additive surface that breaks no existing caller —
the definition of a minor. A fix or a dependency bump is not, and numbering one as a minor tells a
consumer pinned to a tag that there is something new to adopt when there is not. A subject matching
neither pattern falls through to patch, so a sloppy message under-reports rather than inflates.
A docs: or chore: merge that leaves src/ alone gets no version of its own. It is not lost —
the next module change releases the whole range since the last tag, and its notes list every commit
in it. Only a change a consumer can actually resolve earns a number.
v0.1.0 --feat--> v0.2.0 --fix--> v0.2.1 --feat!--> v0.3.0 --+semver: major--> v1.0.0
<type>!: and a BREAKING CHANGE: footer do not bump the major. They mark the change as
breaking in the release notes and nothing more. On a 0.x library that is the
useful behaviour: breaks accumulate and get released together as a considered major, rather than
each one graduating the library the moment somebody types a !.
Two ways to cut one, both deliberate:
# GitVersion.yaml — set it in the same pull request as the change.
next-version: 1.0.0# ...or a commit footer, on a line of its own.
+semver: major
next-version is a floor, so it wins over whatever the subject says and the next release takes it
exactly. It then goes inert by itself: once the tag exists it sits below it and stops mattering, so
v1.0.0 is followed by v1.0.1 and v1.1.0 normally, whether or not you tidy the line away.
⚠️ next-versionis a switch, not a plan. While it is set and not yet reached, every merge releases it — adocs:typo fix included. Set it in the pull request that carries the change, not ahead of time.
Still mark breaking changes. The version no longer carries the signal, so the notes are the only
place a consumer pinned to a tag can read it. Use feat(modules)!: rename input or a
BREAKING CHANGE: footer — under squash-merge only the latter always survives.
One nuance worth knowing: GitVersion computes a single version for the whole range since the last
tag, not one per commit. Because the pipeline tags every push, that range is normally one merge. If a
release is ever skipped — CI red, a cancelled run — the next successful release covers both merges
under one version, and the strongest bump in the range wins: three fix: commits and one feat:
release together as a minor. That is correct; a version numbers a release, not a commit. It also means
a patch release never contains a feat:, which is the guarantee a consumer pinned to a tag reads
off the version number.
Previewing a version needs nothing special: discovery runs on every pull request and writes the
version it would release into the run summary.
There is no version override. The workflow takes no inputs. Versions are derived from tags and commit messages, and a hand-picked version desynchronises the two permanently — the manual tag becomes the base GitVersion derives the next version from, so a typo shifts the whole line and cannot be walked back without deleting a published tag.
Notes are generated by git-cliff from the commits in the release,
configured by cliff.toml. There is no committed CHANGELOG.md — on a repo that
publishes every merge the releases page already is one, and a file would be a second copy to keep
honest.
Commits are grouped by type: breaking changes first, then features, fixes, performance,
refactoring, CI, docs, tests and reverts. Maintenance and dependency bumps collapse behind a
<details> toggle, so a release dominated by Renovate still shows the one fix: that matters. A
subject matching no convention lands under Other Changes rather than disappearing — it
released, so it gets listed. Every set of notes ends with the ?ref= pin for that exact version,
because a module library is only useful if you can copy the thing you just published.
⚠️ cliff.tomlandGitVersion.yamldiverge on purpose — keep the anchoring identical. git-cliff flags all three markers (<type>!:, aBREAKING CHANGE:footer,+semver: major) as breaking; GitVersion bumps the major for the last one only. That gap is the design — it is what lets a break be announced without forcing a major. What must not drift is the(?m)anchoring: a marker on a body line has to mean the same thing in both, or the notes and the number describe different commits.
| What happened | Recovery |
|---|---|
| A job failed, or the runner died mid-run | Re-run from the Actions UI, or gh run rerun <run-id> --failed. Replays the same commit and event payload. |
| The run never started — the workflow file itself was broken | Push the fix. That merge triggers the pipeline and releases normally. |
| Re-run is no longer offered, because the run aged out | Run the workflow manually from the Actions tab against main. |
| A merge landed while CI was red | Nothing to do. The next successful release covers both merges under one version. |
| A tag exists with no release behind it | Generate the notes and attach them by hand — GITHUB_TOKEN=$(gh auth token) git cliff --tag v<x.y.z> v<prev>..v<x.y.z> --strip header -o notes.md then gh release create v<x.y.z> --notes-file notes.md. The pipeline will not backfill it — discovery skips any version whose tag already exists. |
The manual trigger takes no inputs on purpose. It re-runs the pipeline against the current head of the selected branch, derives the version the same way a push would, and the existing-tag check makes a dispatch against an already-released commit a no-op rather than a duplicate.
Renovate runs from this repository —
.github/workflows/chore-renovate.yaml, configured by
renovate.json. The Mend GitHub App is deliberately not installed, so no third
party holds a standing grant here. The job runs on a GitHub-hosted runner, and the org PAT is handed
to the pinned action for the length of one step rather than left sitting on the repository.
| Schedule | 02:00 on weekdays, every push to main, plus manual dispatch with a log-level choice |
| Actions | one weekly batch, reviewed by hand. Renovate's own action updates in a separate group |
| Providers | not automated — the declared range is never rewritten |
| Terraform (CI) | patch opens a pull request; minor and major wait on the dashboard |
| Branches | chore/renovate/…, commits prefixed chore(deps): |
Provider constraint bumps are not automated. A floor in versions.tf is a compatibility promise
to consumers, not a number to keep current — raising a floor forces every consumer to upgrade.
With rangeStrategy: in-range-only Renovate never rewrites a declared range, so no provider pull
request opens and nothing waits on the dashboard either.
That covers hashicorp/azurerm majors too. The azurerm major cap in versions.tf is unchanged, and moving
to v5 stays a deliberate manual edit — the management_group_id migration that used to block it is
done, but the remaining azurerm modules have not been checked against v5.
Terraform itself is tracked in two places, differently. The required_version in every
versions.tf is picked up by the built-in terraform manager, so the same rule leaves it alone.
TERRAFORM_VERSION in pipeline.yaml is a plain YAML value that no built-in manager can see, so a
customManagers regex matches it and resolves it against hashicorp/terraform releases.
That pin is deliberately the newest patch of the required_version floor, so the pipeline proves the
floor is honest rather than testing something newer than the modules declare. A patch keeps that true
and opens on its own. A minor or major would quietly break it — CI would start validating against a
Terraform the modules never promised — so it waits for a tick on the dashboard, and taking it means
raising the floor in every versions.tf with it as a marked breaking change.
A pull request that edits renovate.json is schema-checked by a separate validate-config job that
holds no credentials at all, so the one file a pull request fully controls never reaches the step
carrying the PAT. A malformed file or an invalid option fails on the pull request that introduced it
rather than at 02:00 the next morning. Know its limit: the validator reads the schema and resolves no
dependencies, so a config that parses and is still wrong gets through. Fork pull requests run it like
anyone else's — there is no secret in that job to withhold.
The renovate job itself does not run on pull_request at all, and that is what keeps the PAT away
from unreviewed code — including Renovate's own self-update pull request, whose only change is the
version of renovatebot/github-action in the workflow file. The paths filter on the trigger is a
convenience, not a security boundary: a pull_request run evaluates the workflow as the pull request
has it, so the pull request controls that filter too.
Renovate authenticates with the org-level ES_GITHUB_PAT. That matters beyond access: pull requests
opened with the default GITHUB_TOKEN do not trigger other workflows, so Renovate's own pull requests
would never run pipeline. With the PAT they are validated like anyone else's. Moving to a GitHub App
token would be the stronger option if one is ever registered for the organisation.
.github/workflows/chore-cleanup-stale.yaml
sweeps daily at 02:00 UTC:
| Stale after | Closed after | |
|---|---|---|
| Issues | 60 days | 14 further days |
| Pull requests | 30 days | 14 further days |
Issues get the longer fuse deliberately. An unanswered bug report is usually waiting on a maintainer,
and closing it charges the reporter for that; an abandoned pull request rots against main, and
reopening costs its author nothing. Closed issues use not_planned, so being aged out is visibly
distinct from being fixed.
Never marked:
- anything labelled
pinnedorsecurity - issues labelled
good first issueorhelp wanted— those are advertisements, meant to sit open until a stranger picks them up - anything in a milestone, which is planned work rather than drift
- draft pull requests
A comment from anyone resets the clock, and the sweep processes oldest first so a backlog drains predictably under the 100-operation cap.
A closed item is not a rejected one. Comment on a closed issue and a maintainer will reopen it — reopening an issue the bot closed needs the Triage role, so it is not something a reporter can do themselves. A pull request is different: its author can reopen it directly, and the branch is untouched either way.
.github/workflows/chore-cleanup-workflow-runs.yaml
deletes workflow runs older than 30 days at 04:00 UTC, keeping a minimum of 5 runs per
workflow so a rarely-triggered one never disappears entirely.
This removes the run records themselves, which is a different thing from GitHub's built-in log and artifact retention — that is a repository setting, defaults to 90 days, and is untouched here.
v0.3.0 is the only record of which Terraform version and which providers it was checked
against. The release, the tag and the
commit all survive; the proof does not.
Every action is pinned to a version tag, never a commit SHA. Most sit on a major tag and pick up patches silently; the rest pin an exact version and get a Renovate pull request for each release instead. The workflows themselves are the list.
Tags are mutable, so this is a deliberate trade: readable diffs and a legible upgrade history, against
trusting each action's owner not to move a tag underneath us. It also means the sha_pinning_required
repository setting must stay off — switching it on fails every workflow run until the convention
changes.