Defines an organization's custom-property schema and sets the per-repository values that populate it — the governance/classification layer that tags every repo with compliance metadata (data-classification, owning-team, sox-in-scope, …) behind one secure-by-default boundary. Deeply-typed
map(object)collections,validation-enforced enums, governance defaults (required = true, org-owner-only edits), and adepends_oncontract so definitions land before values in a single apply. Built for integrations/github v6.x.
This composite owns the GitHub custom-property feature end to end, across two planes:
- 🏷️ Property definitions (the schema) —
github_organization_custom_properties.this: the org-level definition for each property (value_type,required,default_value,allowed_values, who may edit). One instance per property, keyed by property name. - 📌 Per-repository values (the data) —
github_repository_custom_property.values: the value each repository carries for a property (one entry per repo × property), keyed by a stable"<repo>:<property>"handle. - 🧬 Both planes are deeply-typed
map(object)collections keyed on stable strings, so adds/removes never churn unrelated entries. - 🛡️ Governance defaults — properties are
required = trueand editable only byorg_actors(org owners) unless you opt out;single_select/multi_selectarevalidation-checked to carryallowed_values. - 🔗 Self-wiring lifecycle —
property_typeon a value is derived from its definition (declare the type once), anddepends_onforces definitions to exist before any value is written.
💡 Why it matters: In a regulated estate every repository should declare what it holds and who owns it. This module makes that classification declarative and auditable — and it is the keystone that org rulesets key on to govern repositories by their compliance metadata instead of by hand-maintained name lists.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn:linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee:buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
flowchart LR
repo["terraform-github-repository<br/>(keystone — emits id = repo name)"]
cp["terraform-github-custom-properties<br/>(THIS module)"]
ruleset["terraform-github-organization-ruleset<br/>(targets repos by custom property)"]
orgset["terraform-github-organization-settings"]
repo -- "repository = id (repo name)" --> cp
cp -- "property_names" --> ruleset
orgset -. "org custom-property feature / plan".-> cp
style cp fill:#8957E5,color:#fff
style repo fill:#24292F,color:#fff
This module consumes repository names from the keystone terraform-github-repository (the repos whose values are set), and emits the set of defined property_names that terraform-github-organization-ruleset uses to target repositories by classification. See the Cross-Module Contract.
flowchart TD
props["var.properties<br/>map(object) keyed by property name"]
vals["var.repository_values<br/>map(object) keyed by repo:property"]
props --> this["github_organization_custom_properties.this<br/>(PRIMARY · for_each · org property DEFINITIONS)"]
vals --> values["github_repository_custom_property.values<br/>(for_each · per-repository VALUES)"]
this -- "depends_on · value_type → property_type" --> values
style this fill:#8957E5,color:#fff
Resource inventory
github_organization_custom_properties.this— the primary plane: one organization custom-property definition perpropertiesentry,for_eachover the map keyed by property name. (Despite the resource's plural name, each instance manages exactly one property.)github_repository_custom_property.values— the role-named child collection: one per-repository value perrepository_valuesentry,for_eachover the map keyed by"<repo>:<property>",depends_onthe definitions so a single apply defines then populates.
ℹ️ The primary resource is a
for_eachset, not a singleton — the provider'sgithub_organization_custom_propertiesis single-property-per-instance (see Architecture Notes).
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
| Provider | integrations/github~> 6.0 (current 6.12.1) |
Schema notes that bite (verified against the v6 provider binary schema):
- Provider source is
integrations/github— never the deprecatedhashicorp/github. - Single-property arity.
github_organization_custom_propertiesmanages one property per instance (imported by a single property name); itsidis the property name. There is no whole-set "definitions" singleton. - Different argument names per plane. The definition uses
value_type(string | single_select | multi_select | true_false, defaultstring); the repository value usesproperty_type(the same set plusurl) andproperty_valueis aset(string)(a single-element set for non-multi types). - No
repository_id.github_repository_custom_propertyexposes onlyid,property_name,property_type,property_value,repositoryin v6.x — there is norepository_idattribute (older docs list one; it does not exist).
terraform-github-custom-properties/
├── providers.tf # terraform{} + integrations/github ~> 6.0 (no provider block)
├── variables.tf # properties (definitions) + repository_values (values) — typed maps
├── main.tf # github_organization_custom_properties.this + github_repository_custom_property.values
├── outputs.tf # property_ids / property_names / properties + repository_value_ids / repository_values
├── SCOPE.md # design contract: scope, consumes/emits, token scopes, prerequisites
└── README.md # this file
module"custom_properties" {
source="git::https://github.com/microsoftexpert/terraform-github-custom-properties?ref=v1.0.0"properties={
"data-classification"= {
value_type ="single_select"
allowed_values = ["public", "internal", "confidential", "restricted"]
default_value ="confidential"# required properties should carry a default
description ="Data sensitivity classification for the repository."# required defaults to true; values_editable_by defaults to org_actors
}
}
}
⚠️ Always pin the module source to a tag (?ref=v1.0.0) — never a branch — so applies are reproducible.
| Input | Type | Source |
|---|---|---|
repository_values[].repository | string (repo name) | terraform-github-repository → id output |
repository_values[].property_name | string | a property defined in this module's properties (or an existing org property) |
| Output | Description | Consumed by |
|---|---|---|
property_ids | Map of property name → definition resource id (the property name) | Audit / drift reporting |
property_names | Set of defined custom-property names | terraform-github-organization-ruleset (repository_property conditions), audit |
properties | Map of name → applied definition (value_type, required, default_value, allowed_values, values_editable_by, description) | Governance dashboards, compliance reporting |
repository_value_ids | Map of "<repo>:<property>" handle → value resource id | Audit / drift reporting |
repository_values | Map of handle → applied value (repository, property_name, property_type, value) | Compliance reporting — which repos carry which classification |
Sourced from this repo's
SCOPE.md. There is no scalaridoutput — the provider has no org-level custom-properties singleton (see Architecture Notes).
1️⃣ Minimal — one required classification property (no values yet)
module"custom_properties" {
source="git::https://github.com/microsoftexpert/terraform-github-custom-properties?ref=v1.0.0"properties={
"data-classification"= {
value_type ="single_select"
allowed_values = ["public", "internal", "confidential", "restricted"]
default_value ="confidential"
description ="Data sensitivity classification for the repository."
}
}
}🔒 Governance defaults apply:
required = trueandvalues_editable_by = "org_actors". A required property should carry adefault_valueso repos without an explicit value are still compliant.
2️⃣ Full classification schema — single_select + string + true_false
module"custom_properties" {
source="git::https://github.com/microsoftexpert/terraform-github-custom-properties?ref=v1.0.0"properties={
"data-classification"= {
value_type ="single_select"
required =true
allowed_values = ["public", "internal", "confidential", "restricted"]
default_value ="confidential"
description ="Data sensitivity classification."
}
"owning-team"= {
value_type ="string"
required =true
description ="Team accountable for the repository (set per repo via repository_values)."
}
"sox-in-scope"= {
value_type ="true_false"
required =true
default_value ="false"
description ="Whether the repository is in scope for SOX controls."
}
}
}💡
owning-teamis required but has no sensible default — every repository should set it viarepository_values(or callers will see it as unset/non-compliant).
3️⃣ Optional metadata property (opt out of the required default)
module"custom_properties" {
source="git::https://github.com/microsoftexpert/terraform-github-custom-properties?ref=v1.0.0"properties={
"cost-center"= {
value_type ="string"
required =false# purely optional metadata — opt out of the governance default
description ="Finance cost-center code, if applicable."
}
}
}4️⃣ multi_select property — multiple compliance frameworks
module"custom_properties" {
source="git::https://github.com/microsoftexpert/terraform-github-custom-properties?ref=v1.0.0"properties={
"compliance-frameworks"= {
value_type ="multi_select"
required =false
allowed_values = ["sox", "pci-dss", "glba", "ffiec"]
description ="Regulatory frameworks the repository must satisfy."
}
}
}
⚠️ single_selectandmulti_selectmust set a non-emptyallowed_values— this isvalidation-enforced at plan time.
5️⃣ Set one repository value (property_type derived from the definition)
module"custom_properties" {
source="git::https://github.com/microsoftexpert/terraform-github-custom-properties?ref=v1.0.0"properties={
"data-classification"= {
value_type ="single_select"
allowed_values = ["public", "internal", "confidential", "restricted"]
default_value ="confidential"
}
}
repository_values={
"payments-api:data-classification"= {
repository ="payments-api"
property_name ="data-classification"
property_value = ["restricted"] # a single-element set for single_select# property_type omitted — derived from the definition's value_type
}
}
}6️⃣ Repository value wired from terraform-github-repository (cross-module)
module"repository" {
source="git::https://github.com/microsoftexpert/terraform-github-repository?ref=v1.0.0"name="payments-api"visibility="private"
}
module"custom_properties" {
source="git::https://github.com/microsoftexpert/terraform-github-custom-properties?ref=v1.0.0"properties={
"data-classification"= {
value_type ="single_select"
allowed_values = ["public", "internal", "confidential", "restricted"]
default_value ="confidential"
}
}
repository_values={
"payments-api:data-classification"= {
repository = module.repository.id # id == repo name
property_name ="data-classification"
property_value = ["restricted"]
}
}
}💡 The keystone's
idoutput is the repository name — exactly whatrepositoryexpects.
7️⃣ Explicit property_type — value for a property defined outside this module
module"custom_properties" {
source="git::https://github.com/microsoftexpert/terraform-github-custom-properties?ref=v1.0.0"# No `properties` here — "cost-center" is defined elsewhere in the org.repository_values={
"payments-api:cost-center"= {
repository ="payments-api"
property_name ="cost-center"
property_type ="string"# REQUIRED here — cannot be derived (not in this module's properties)
property_value = ["CC-4815"]
}
}
}
⚠️ When a value targets a property not defined in this module'sproperties, you must setproperty_typeexplicitly — otherwise plan fails the resolvabilityvalidation.
8️⃣ Delegated editing — values_editable_by = org_and_repo_actors
module"custom_properties" {
source="git::https://github.com/microsoftexpert/terraform-github-custom-properties?ref=v1.0.0"properties={
"team-contact"= {
value_type ="string"
required =false
values_editable_by ="org_and_repo_actors"# repo admins may edit this value
description ="Owning-team contact (delegated to repository admins)."
}
}
}🔒 Default is
org_actors(org owners only). Opt intoorg_and_repo_actorsonly where decentralised editing is acceptable.
9️⃣ for_each at scale — repo values from a map(object)
locals {
# Built from your repository inventory: repo => classification.repo_classifications={
"payments-api"="restricted""ledger-core"="confidential""docs-site"="public"
}
}
module"custom_properties" {
source="git::https://github.com/microsoftexpert/terraform-github-custom-properties?ref=v1.0.0"properties={
"data-classification"= {
value_type ="single_select"
allowed_values = ["public", "internal", "confidential", "restricted"]
default_value ="confidential"
}
}
repository_values={
forrepo, clsinlocal.repo_classifications:"${repo}:data-classification"=> {
repository = repo
property_name ="data-classification"
property_value = [cls]
}
}
}
⚠️ Bulkfor_eachacross many repositories can trip GitHub secondary rate limits — see Troubleshooting.
🔟 multi_select value — several values on one repository
module"custom_properties" {
source="git::https://github.com/microsoftexpert/terraform-github-custom-properties?ref=v1.0.0"properties={
"compliance-frameworks"= {
value_type ="multi_select"
allowed_values = ["sox", "pci-dss", "glba", "ffiec"]
required =false
}
}
repository_values={
"payments-api:compliance-frameworks"= {
repository ="payments-api"
property_name ="compliance-frameworks"
property_value = ["sox", "pci-dss", "glba"] # multi_select takes multiple values
}
}
}1️⃣1️⃣ Schema + values in one call (definitions land before values)
module"custom_properties" {
source="git::https://github.com/microsoftexpert/terraform-github-custom-properties?ref=v1.0.0"properties={
"data-classification"= {
value_type ="single_select"
allowed_values = ["public", "internal", "confidential", "restricted"]
default_value ="confidential"
}
"owning-team"= {
value_type ="string"
}
}
repository_values={
"payments-api:data-classification"= { repository ="payments-api", property_name ="data-classification", property_value = ["restricted"] }
"payments-api:owning-team"= { repository ="payments-api", property_name ="owning-team", property_value = ["payments-platform"] }
}
}💡
depends_oninside the module guarantees both definitions exist before either value is written — a singleapplyboth defines and populates.
1️⃣2️⃣ 🔒 Secure / hardened governance variant
module"custom_properties" {
source="git::https://github.com/microsoftexpert/terraform-github-custom-properties?ref=v1.0.0"# Every classification property is REQUIRED and editable ONLY by org owners.properties={
"data-classification"= {
value_type ="single_select"
required =true
values_editable_by ="org_actors"# org owners only (default, made explicit)
allowed_values = ["public", "internal", "confidential", "restricted"]
default_value ="confidential"
description ="Data sensitivity classification (governed centrally)."
}
"owning-team"= {
value_type ="string"
required =true
values_editable_by ="org_actors"
description ="Accountable team — must be set on every repository."
}
"sox-in-scope"= {
value_type ="true_false"
required =true
values_editable_by ="org_actors"
default_value ="false"
description ="SOX control scope flag."
}
}
}🔒 Hardening checklist: every property
required = true;values_editable_by = "org_actors"; a safedefault_valueon each so no repo is ever non-compliant by omission; classification governed centrally, never by repo admins.
1️⃣3️⃣ 🏗️ End-to-end composition (mandatory) — repository → custom properties → org ruleset
# 1 · Keystone repository.module"repository" {
source="git::https://github.com/microsoftexpert/terraform-github-repository?ref=v1.0.0"name="payments-api"visibility="private"
}
# 2 · Define the classification schema AND set this repo's value.module"custom_properties" {
source="git::https://github.com/microsoftexpert/terraform-github-custom-properties?ref=v1.0.0"properties={
"data-classification"= {
value_type ="single_select"
required =true
allowed_values = ["public", "internal", "confidential", "restricted"]
default_value ="confidential"
description ="Data sensitivity classification."
}
}
repository_values={
"payments-api:data-classification"= {
repository = module.repository.id # ← repository.id (repo name)
property_name ="data-classification"
property_value = ["restricted"]
}
}
}
# 3 · Govern: an org ruleset that targets repositories BY the custom property.# property_names is the published contract this ruleset keys on.module"classification_ruleset" {
source="git::https://github.com/microsoftexpert/terraform-github-organization-ruleset?ref=v1.0.0"name="protect-restricted-repos"target="branch"enforcement="active"rules={
non_fast_forward =true
deletion =true
pull_request = {
required_approving_review_count =2
require_code_owner_review =true
}
}
conditions={
ref_name = {
include = ["~DEFAULT_BRANCH"]
}
repository_property = {
include = [
{
name ="data-classification"# one of module.custom_properties.property_names
property_values = ["restricted"]
}
]
}
}
# The ruleset can only target a property that already exists — ensure the# definitions apply first. property_names is emitted for exactly this contract.depends_on=[module.custom_properties]
}
output"classification_property_names" {
value=module.custom_properties.property_names
}🏗️ The canonical wiring:
repository.id→ the value'srepository; the custom-property name flows into the org ruleset'srepository_propertycondition so everyrestrictedrepo is governed automatically — no hand-maintained repo lists.
High-level:
properties(map(object), default{}) — organization custom-property definitions, keyed by property name. Governance defaults:required = true,values_editable_by = "org_actors".repository_values(map(object), default{}) — per-repository values, keyed by a stable"<repo>:<property>"handle.property_typeis optional (derived from the matching definition).
Full object schemas for the map inputs
# properties — map keyed by property NAME (the key IS the property name)map(object({
value_type =optional(string, "string") # string | single_select | multi_select | true_false
required =optional(bool, true) # governance default: REQUIRED unless opted out
description =optional(string) # human description of the property
default_value =optional(string) # default applied to repos that do not set a value
allowed_values =optional(list(string), []) # REQUIRED for single_select / multi_select; ignored otherwise
values_editable_by =optional(string, "org_actors") # org_actors (most restrictive) | org_and_repo_actors
}))
# repository_values — map keyed by a stable "<repo>:<property>" handlemap(object({
repository =string# repo name whose value is set (wire from terraform-github-repository.id)
property_name =string# name of the (already-defined) custom property
property_type =optional(string) # string | single_select | multi_select | true_false | url# OMIT to derive from the matching `properties` definition's value_type
property_value =set(string) # the value(s): single-element set for non-multi types; multiple for multi_select
}))Validations enforced at plan time:
properties[].value_type∈string | single_select | multi_select | true_false.properties[].values_editable_by∈org_actors | org_and_repo_actors.single_select/multi_selectproperties must set a non-emptyallowed_values.repository_values[].property_type, when set, ∈string | single_select | multi_select | true_false | url.- Each
repository_valuesentry must setproperty_typeor reference a property defined inproperties(so the type can be derived). - Each
repository_valuesentry must have a non-emptyrepository,property_name, and at least oneproperty_value.
| Output | Description |
|---|---|
property_ids | Map of property name → definition resource id (the property name). |
property_names | Set of defined custom-property names — the key wire for terraform-github-organization-ruleset. |
properties | Map of name → applied definition (value_type, required, default_value, allowed_values, values_editable_by, description). |
repository_value_ids | Map of "<repo>:<property>" handle → value resource id (org:repo:property). |
repository_values | Map of handle → applied value (repository, property_name, property_type, value). |
ℹ️ There is no scalar
id— the provider has no org-level custom-properties singleton. The definitions plane is exposed as maps keyed by property name. Nothing issensitive: property names/values are governance metadata, not secrets or member PII.
- Per-property definitions, not a whole-set overwrite. Despite the plural name,
github_organization_custom_propertiesmanages one property per instance (imported by a single property name; itsidis the name). So the definitions plane is afor_eachset keyed by property name — each property is its own authoritative resource. There is no single resource that owns "the whole schema," and therefore no scalarid/node_id/slugto emit; the module exposesproperty_ids/property_names/propertiesmaps instead. - Two argument vocabularies. The definition uses
value_type(string | single_select | multi_select | true_false); the repository value usesproperty_type(the same set plusurl) and aproperty_valueset. The module hides this asymmetry by derivingproperty_typefrom the matching definition'svalue_typewhen you omit it — declare the type once on the definition. single_select/multi_selectneedallowed_values. These types are meaningless without an enumerated value list, so the modulevalidation-rejects them without one at plan time, before any API call. A value not inallowed_valuesfails at apply.- Definitions before values — one apply.
github_repository_custom_property.valuescarriesdepends_on = [github_organization_custom_properties.this], so every definition exists before any value is written. A value referencing an undefined property fails at apply — keep the property inproperties(or define it elsewhere and passproperty_type). - The compliance/classification keystone. Once repositories carry
data-classification,owning-team,sox-in-scope, an org ruleset (terraform-github-organization-ruleset) can target them byrepository_propertyconditions — governing whole classes of repos by metadata instead of by hand-maintained name lists.property_namesis the published contract for that wiring. value_typeis effectively immutable. Changing a property's type after repositories hold values can fail at apply. Plan type changes carefully; treat the schema as append-mostly.- No tags, no timeouts. GitHub has neither a resource-tagging concept nor a
timeoutsblock on these resources — there is intentionally notags/timeoutstail, and auth/ownerare provider concerns, never module variables. - Eventual consistency / secondary rate limits. The GitHub REST API is eventually consistent and enforces undocumented secondary rate limits on bursts of writes. Populating values across many repositories via
for_eachcan intermittently 403/422 mid-apply; a re-run usually converges.
- 🔒 Governed by default — properties are
required = trueand editable only byorg_actors; you opt out explicitly for optional or delegated metadata. - 🏷️ Type is the contract — deeply-typed
map(object)collections +validationenums mean a malformed schema fails at plan time, not at apply. - 🧬 Declare once —
property_typeon values is derived from the definition, so the type lives in exactly one place. - 🪪 Least privilege —
values_editable_bydefaults to the most restrictive option (org_actors). - 🔗 Composable governance — emits
property_namesso rulesets govern repos by classification; consumes repo names from the keystone. - 🚫 No tags, no timeouts, no auth variables — GitHub has no tags; auth and
ownerare provider concerns.
terraform init -backend=false
terraform validate
terraform fmt -check
terraform plan
terraform apply
terraform output
⚠️ Pin the module source to a tag (?ref=v1.0.0) — never a branch — so applies are reproducible.
plan/apply require a configured integrations/github provider (PAT / GitHub App) with the token scopes below, an org-owner identity, and the target owner / GITHUB_OWNER set.
Classic PAT scopes
admin:org— read/write organization custom-property definitions.repo— set custom-property values on repositories.
Fine-grained PAT / GitHub App permissions
- Organization → Custom properties: read/write (the definitions).
- Repository → Custom properties: read/write (per-repo values).
- Metadata: read.
⚠️ Auth and the target org (owner/GITHUB_OWNER) are provider concerns — never module variables.
- Plan / edition. Custom properties are an organization feature, available on GitHub Team and Enterprise Cloud (managed orgs only — not user accounts). Confirm availability for the org's plan.
- Org-owner identity. The provider identity must be an organization owner to manage definitions.
- Definitions before values. A repository value can only be set for a property that already exists; the module wires
repository_valuestodepends_onthe definitions so a single apply succeeds. - Rate limits. Per-repo value writes are one API call each — watch secondary rate limits when populating values across many repositories via
for_each.
The offline proof gate (no live org required):
terraform fmt -check # zero formatting differences
terraform validate # configuration is valid
tflint # core rules — no dedicated GitHub ruleset exists✅ This module passes
terraform init -backend=false,terraform validate, andterraform fmt -checkcleanly.
property_ids = {
"data-classification" = "data-classification"
"owning-team" = "owning-team"
"sox-in-scope" = "sox-in-scope"
}
property_names = toset([
"data-classification",
"owning-team",
"sox-in-scope",
])
properties = {
"data-classification" = {
"allowed_values" = tolist(["public", "internal", "confidential", "restricted"])
"default_value" = "confidential"
"description" = "Data sensitivity classification."
"required" = true
"value_type" = "single_select"
"values_editable_by" = "org_actors"
}
}
repository_value_ids = {
"payments-api:data-classification" = "casey:payments-api:data-classification"
}
repository_values = {
"payments-api:data-classification" = {
"property_name" = "data-classification"
"property_type" = "single_select"
"repository" = "payments-api"
"value" = toset(["restricted"])
}
}
| Symptom | Likely cause | Resolution |
|---|---|---|
403 Resource not accessible by integration | Token lacks admin:org/repo (classic) or Custom properties: read/write (fine-grained); or identity is not an org owner | Grant the scopes above and use an org-owner identity |
404 Not Found / feature unavailable | Org is not Team/Enterprise, or the provider points at a user account | Custom properties are an org feature on Team/Enterprise — target a managed org |
| Apply error: property does not exist | A value references a property not yet defined | Define it in properties, or define it in the org and pass property_type; depends_on handles in-module ordering |
| Apply error: value not allowed | Value not in the property's allowed_values | Use one of the enumerated values (or widen allowed_values) |
| Plan error: must set property_type or match a defined property | A value targets an external property with no property_type | Add property_type, or add the property to properties so the type can be derived |
| Plan error: single_select/multi_select requires allowed_values | Enumerated type without a value list | Add a non-empty allowed_values |
Intermittent 403/422 during bulk apply | GitHub secondary rate limit on bursty writes | Re-run terraform apply; reduce -parallelism; stagger large rollouts |
| Unexpected replace on a property | value_type changed (effectively immutable once values exist) | Avoid type changes; migrate to a new property if the type must change |
- This module's design contract —
SCOPE.md - Keystone repository module —
terraform-github-repository(emitsid= repo name) - Governance sibling —
terraform-github-organization-ruleset(consumesproperty_namesto target repos by property) integrations/githubprovider reference — organization custom properties and repository custom property resources- GitHub custom properties documentation — organization metadata and classification