template-tf-provider is the GitHub template Meigma Terraform providers start
from. It is a working Plugin Framework
provider — a worked resource and data source over a hexagonal package split —
wrapped in the toolchain, documentation pipeline, and release machinery a
published provider needs.
The release path is enabled in this repository rather than left as a sketch, so the whole thing (GPG signing, registry contract checks, attestation) is exercised here before a generated project inherits it.
- Create a repository from this template. Name it
terraform-provider-<name>, all lowercase — the Terraform Registry accepts no other pattern. - Clone it and run
mise install. - Work through DELETE_ME.md, which covers renaming the module and provider, replacing the example resource, and the one-time repository setup needed before the first release.
Prerequisites:
- mise — provisions every pinned tool from
mise.tomlandmise.lock: Go, Moon, Python and uv (for the MkDocs project),golangci-lint,tfplugindocs,terraformandtofufor acceptance tests, andgoreleaser/syft/cosignfor releases. Runmise installonce; there is nothing else to install by hand.
mise.lock records a per-platform download URL and checksum for each tool (and,
for the aqua-backed CLIs, cosign/SLSA/GitHub-attestation verification).
mise install runs with locked = true, so it fails closed if a tool lacks
a pre-resolved, checksummed entry for the current platform. Moon runs every task
against these tools as system binaries on PATH and manages no toolchain
itself. To bump a tool, edit its version in mise.toml, run
mise lock --platform linux-x64,linux-arm64,macos-x64,macos-arm64, and commit
both files.
The provider serves one resource and one data source, both named example_item,
backed by a JSON file on disk. They are meant to be replaced. What should
survive the replacement is the split they demonstrate:
| Package | Role |
|---|---|
internal/core |
The domain: the item type, its rules, and the Store port. No Terraform types, no I/O. |
internal/client |
A Store implementation over a JSON file. A real provider puts an HTTP client here. |
internal/provider |
The Terraform adapter: configuration, plan, and state in, domain calls out, diagnostics back. |
Dependencies point inward, so the domain tests run without side effects, the
provider tests run against a mockery-generated mock, and the storage adapter
is tested on its own. How this provider is structured
covers why, including the rule that a provider may not silently rewrite a value
the user configured.
Moon is the task front door:
moon run root:format
moon run root:lint
moon run root:build
moon run root:test
moon run root:checkcheck is the aggregate: formatting, linting, build, unit tests, the release
script tests, the docs drift check, the registry docs validation, and the
MkDocs build. CI runs the same path:
moon ci --summary minimalmoon run root:build writes bin/terraform-provider-example. A provider is a
plugin, so running that binary directly only prints an error; to exercise it
against a CLI, see
Use the provider from a local build.
Acceptance tests drive a real Terraform-compatible CLI through plan, apply,
import, and destroy. Nothing runs them automatically — not moon ci, not a
pull request, not a schedule:
moon run root:testacc # the pinned OpenTofu
TF_ACC_TERRAFORM_PATH=$(mise which terraform) \
moon run root:testacc # the pinned TerraformOr dispatch the Acceptance Tests workflow from the Actions tab and pick
tofu, terraform, or both.
The task is marked runInCI: false, and Moon drops such tasks whenever CI is
set in the environment — including for an explicit moon run root:testacc. If
you export CI in your shell, the run fails with "No tasks found" rather than
skipping quietly. Unset it, or run the task through env -u CI.
docs/ is one tree with two consumers. The Terraform and OpenTofu registries
read docs/index.md, docs/resources/, and docs/data-sources/ from it in
their own format; MkDocs builds the same tree, plus the hand-written tutorial,
how-to guides, and explanation, into the
published site.
Those three registry paths are generated by
tfplugindocs from the
provider schema, templates/, and examples/. Never edit them by hand —
moon run root:check regenerates them into a scratch directory and fails when
the committed copies differ.
moon run root:docs-gen # regenerate index.md, resources/, data-sources/
moon run root:docs-check # fail if the committed pages have drifted
moon run root:docs-validate # check the registries' own rules on those pages
moon run root:docs-build # build the MkDocs site into site/
moon run root:docs-serve # preview it at http://127.0.0.1:8000Everything else under docs/ is written by hand and belongs in mkdocs.yml's
nav. The MkDocs config, the uv project, and the Python pin all live at the
repository root, because MkDocs requires its config outside docs_dir and the
registries expect nothing but documentation inside it.
The CI workflow keeps token permissions minimal, pins external actions,
disables checkout credential persistence, and delegates the checks to Moon. It
caches Go modules, Go build artifacts, golangci-lint state, and uv downloads
through GitHub Actions; Moon remote caching is left as an optional follow-up
for repositories that need a shared task-output cache. The GitHub Pages
workflow builds the site on pull requests and deploys site/ from the default
branch. Dependabot covers GitHub Actions, the Go module, and the uv project.
Repository settings live in .github/repository-settings.toml and are applied
by .github/scripts/configure_github_repo.py. They set signed commits,
squash-only merges, protected tags, immutable releases, private vulnerability
reporting, and Pages publishing from a workflow.
A release publishes exactly what the two registries ingest. Both discover a
release by reading its asset names, so for version X.Y.Z (tag vX.Y.Z) the
release carries:
| Asset | Contents |
|---|---|
terraform-provider-example_X.Y.Z_<os>_<arch>.zip |
One per platform (13 in total), each holding a single binary named terraform-provider-example_vX.Y.Z (.exe on Windows) |
terraform-provider-example_X.Y.Z_manifest.json |
Copy of terraform-registry-manifest.json, declaring protocol 6.0 |
terraform-provider-example_X.Y.Z_SHA256SUMS |
Checksums for every zip, SBOM, and the manifest |
terraform-provider-example_X.Y.Z_SHA256SUMS.sig |
Detached binary GPG signature the registries verify |
The release also ships a CycloneDX SBOM per zip and a cosign bundle over the checksum file. Those are additive; the registries ignore them.
The release path is:
- Release Please opens and maintains the release PR.
- Release Please creates the tag and a draft GitHub release after merge.
- Release Dry Run rehearses the whole GoReleaser path — GPG signing and the contract checks included — against a synthetic version on release PRs, and uploads nothing.
- GoReleaser builds the zips, SBOMs, and checksum file, and signs the checksum file with the repository's GPG key.
- The release workflow stages the assets, asserts the registry contract
(
scripts/check-release-contract.sh), smoke tests the host binary, and uploads everything to the draft. - A separate job adds a keyless cosign signature over the checksum file, and
the isolated
attest.ymlreusable workflow generates GitHub-native provenance for every file the checksum file lists. - The draft becomes public only after all of the above succeed. A failure anywhere leaves the release a draft, invisible to both registries.
Generating provenance in the isolated attest.yml reusable workflow rather than
in the build job keeps the signing identity unreachable by build steps — the
SLSA Build L3 isolation requirement — while staying on GitHub's attestation API.
Before the first release, provision the signing key and register it with both registries:
scripts/gpg-provision.shIt generates a dedicated RSA 4096 signing key, stores the private half and its
passphrase in the GPG_PRIVATE_KEY and GPG_PASSPHRASE Actions secrets, and
prints the public key. Neither registry accepts a release it cannot verify
against a key you registered first. Re-run with --force only to rotate the
key; the script refuses to overwrite existing secrets otherwise.
How to publish the provider to the registries walks through key registration and the one-time listing in each registry.
Import the project's public signing key (the one registered with the registries), then:
gh release download vX.Y.Z --repo meigma/template-tf-provider --dir provider
cd provider
# 1. The signature the registries themselves check.
gpg --verify terraform-provider-example_X.Y.Z_SHA256SUMS.sig \
terraform-provider-example_X.Y.Z_SHA256SUMS
# 2. Every downloaded file matches the checksum file.
sha256sum -c terraform-provider-example_X.Y.Z_SHA256SUMS
# 3. The checksum file came from this repository's release workflow.
cosign verify-blob \
--bundle terraform-provider-example_X.Y.Z_SHA256SUMS.sigstore.json \
--certificate-identity https://github.com/meigma/template-tf-provider/.github/workflows/release.yml@refs/tags/vX.Y.Z \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
terraform-provider-example_X.Y.Z_SHA256SUMS
# 4. GitHub-native build provenance for any artifact.
gh attestation verify terraform-provider-example_X.Y.Z_linux_amd64.zip \
--repo meigma/template-tf-provider \
--signer-workflow meigma/template-tf-provider/.github/workflows/attest.yml \
--source-ref refs/tags/vX.Y.Z \
--deny-self-hosted-runnersRepositories generated from this template must update the provider name, module path, provider address, and repository slugs in these commands before cutting a release. DELETE_ME.md lists every place those names appear.
See CONTRIBUTING.md for contribution guidelines, local setup expectations, and pull request workflow.
See SECURITY.md for supported versions and the private vulnerability reporting path.
Add the repository license before publishing a project generated from this template.