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 = {
for repo, cls in local.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>" handle
map(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