Skip to content

Repository files navigation

🐙 GitHub Custom Properties Terraform Module

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 a depends_on contract so definitions land before values in a single apply. Built for integrations/github v6.x.

TerraformGitHub providermoduletyperesources


🧩 Overview

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 = true and editable only by org_actors (org owners) unless you opt out; single_select/multi_select are validation-checked to carry allowed_values.
  • 🔗 Self-wiring lifecycleproperty_type on a value is derived from its definition (declare the type once), and depends_on forces 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.


❤️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

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!


🗺️ Where this fits in the family

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
Loading

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.


🧬 What this module builds

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
Loading

Resource inventory

  • github_organization_custom_properties.this — the primary plane: one organization custom-property definition per properties entry, for_each over 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 per repository_values entry, for_each over the map keyed by "<repo>:<property>", depends_on the definitions so a single apply defines then populates.

ℹ️ The primary resource is a for_each set, not a singleton — the provider's github_organization_custom_properties is single-property-per-instance (see Architecture Notes).


✅ Provider / Versions

RequirementValue
Terraform>= 1.12.0
Providerintegrations/github~> 6.0 (current 6.12.1)

Schema notes that bite (verified against the v6 provider binary schema):

  • Provider source is integrations/githubnever the deprecated hashicorp/github.
  • Single-property arity.github_organization_custom_properties manages one property per instance (imported by a single property name); its id is 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, default string); the repository value uses property_type (the same set plus url) and property_value is a set(string) (a single-element set for non-multi types).
  • No repository_id.github_repository_custom_property exposes only id, property_name, property_type, property_value, repository in v6.x — there is no repository_id attribute (older docs list one; it does not exist).

📁 Module Structure

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

⚙️ Quick Start

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.


🔌 Cross-Module Contract

Consumes

InputTypeSource
repository_values[].repositorystring (repo name)terraform-github-repositoryid output
repository_values[].property_namestringa property defined in this module's properties (or an existing org property)

Emits

OutputDescriptionConsumed by
property_idsMap of property name → definition resource id (the property name)Audit / drift reporting
property_namesSet of defined custom-property namesterraform-github-organization-ruleset (repository_property conditions), audit
propertiesMap of name → applied definition (value_type, required, default_value, allowed_values, values_editable_by, description)Governance dashboards, compliance reporting
repository_value_idsMap of "<repo>:<property>" handle → value resource idAudit / drift reporting
repository_valuesMap 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 scalar id output — the provider has no org-level custom-properties singleton (see Architecture Notes).


📚 Example Library

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 = true and values_editable_by = "org_actors". A required property should carry a default_value so 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-team is required but has no sensible default — every repository should set it via repository_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_select and multi_selectmust set a non-empty allowed_values — this is validation-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 id output is the repository name — exactly what repository expects.

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's properties, you must set property_type explicitly — otherwise plan fails the resolvability validation.

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 into org_and_repo_actors only 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]
}
}
}

⚠️ Bulk for_each across 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_on inside the module guarantees both definitions exist before either value is written — a single apply both 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 safe default_value on 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's repository; the custom-property name flows into the org ruleset's repository_property condition so every restricted repo is governed automatically — no hand-maintained repo lists.


📥 Inputs

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_type is 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_typestring | single_select | multi_select | true_false.
  • properties[].values_editable_byorg_actors | org_and_repo_actors.
  • single_select / multi_select properties must set a non-empty allowed_values.
  • repository_values[].property_type, when set, ∈ string | single_select | multi_select | true_false | url.
  • Each repository_values entry must set property_typeor reference a property defined in properties (so the type can be derived).
  • Each repository_values entry must have a non-empty repository, property_name, and at least one property_value.

🧾 Outputs

OutputDescription
property_idsMap of property name → definition resource id (the property name).
property_namesSet of defined custom-property names — the key wire for terraform-github-organization-ruleset.
propertiesMap of name → applied definition (value_type, required, default_value, allowed_values, values_editable_by, description).
repository_value_idsMap of "<repo>:<property>" handle → value resource id (org:repo:property).
repository_valuesMap 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 is sensitive: property names/values are governance metadata, not secrets or member PII.


🧠 Architecture Notes

  • Per-property definitions, not a whole-set overwrite. Despite the plural name, github_organization_custom_properties manages one property per instance (imported by a single property name; its idis the name). So the definitions plane is a for_each set keyed by property name — each property is its own authoritative resource. There is no single resource that owns "the whole schema," and therefore no scalar id / node_id / slug to emit; the module exposes property_ids / property_names / properties maps instead.
  • Two argument vocabularies. The definition uses value_type (string | single_select | multi_select | true_false); the repository value uses property_type (the same set plus url) and a property_value set. The module hides this asymmetry by derivingproperty_type from the matching definition's value_type when you omit it — declare the type once on the definition.
  • single_select / multi_select need allowed_values. These types are meaningless without an enumerated value list, so the module validation-rejects them without one at plan time, before any API call. A value not in allowed_values fails at apply.
  • Definitions before values — one apply.github_repository_custom_property.values carries depends_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 in properties (or define it elsewhere and pass property_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 by repository_property conditions — governing whole classes of repos by metadata instead of by hand-maintained name lists. property_names is the published contract for that wiring.
  • value_type is 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 timeouts block on these resources — there is intentionally no tags / timeouts tail, and auth/owner are 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_each can intermittently 403/422 mid-apply; a re-run usually converges.

🧱 Design Principles

  • 🔒 Governed by default — properties are required = true and editable only by org_actors; you opt out explicitly for optional or delegated metadata.
  • 🏷️ Type is the contract — deeply-typed map(object) collections + validation enums mean a malformed schema fails at plan time, not at apply.
  • 🧬 Declare onceproperty_type on values is derived from the definition, so the type lives in exactly one place.
  • 🪪 Least privilegevalues_editable_by defaults to the most restrictive option (org_actors).
  • 🔗 Composable governance — emits property_names so rulesets govern repos by classification; consumes repo names from the keystone.
  • 🚫 No tags, no timeouts, no auth variables — GitHub has no tags; auth and owner are provider concerns.

🚀 Runbook

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.

Required token scopes / GitHub App permissions

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.

GitHub Prerequisites

  • 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_values to depends_on the 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.

🧪 Testing

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, and terraform fmt -check cleanly.


💬 Example Output

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"])
}
}

🔍 Troubleshooting

SymptomLikely causeResolution
403 Resource not accessible by integrationToken lacks admin:org/repo (classic) or Custom properties: read/write (fine-grained); or identity is not an org ownerGrant the scopes above and use an org-owner identity
404 Not Found / feature unavailableOrg is not Team/Enterprise, or the provider points at a user accountCustom properties are an org feature on Team/Enterprise — target a managed org
Apply error: property does not existA value references a property not yet definedDefine it in properties, or define it in the org and pass property_type; depends_on handles in-module ordering
Apply error: value not allowedValue not in the property's allowed_valuesUse one of the enumerated values (or widen allowed_values)
Plan error: must set property_type or match a defined propertyA value targets an external property with no property_typeAdd property_type, or add the property to properties so the type can be derived
Plan error: single_select/multi_select requires allowed_valuesEnumerated type without a value listAdd a non-empty allowed_values
Intermittent 403/422 during bulk applyGitHub secondary rate limit on bursty writesRe-run terraform apply; reduce -parallelism; stagger large rollouts
Unexpected replace on a propertyvalue_type changed (effectively immutable once values exist)Avoid type changes; migrate to a new property if the type must change

🔗 Related Docs

  • This module's design contract — SCOPE.md
  • Keystone repository module — terraform-github-repository (emits id = repo name)
  • Governance sibling — terraform-github-organization-ruleset (consumes property_names to target repos by property)
  • integrations/github provider reference — organization custom properties and repository custom property resources
  • GitHub custom properties documentation — organization metadata and classification

About

Terraform module: terraform-github-custom-properties

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages