One organization-wide ruleset that enforces branch / tag / push policy across many repositories from a single place — deeply-typed rules, surgical targeting conditions, and least-privilege bypass actors behind one boundary. Built for integrations/github v6.x.
This module manages a single github_organization_ruleset — the modern, org-scoped replacement for per-repo branch protection. From one declaration you:
- 🛡️ Enforce protections fleet-wide —
creation/deletion/updatelocks,non_fast_forward(no force-push),required_linear_history, andrequired_signaturesacross every matching repository. - 🔍 Gate merges — required pull-request reviews, code-owner approval, status checks, and (Enterprise) required workflows and code-scanning thresholds.
- 🎯 Target precisely — scope by
repository_id,repository_nameglob, or custom repository property, and by ref pattern with the~ALL/~DEFAULT_BRANCHtokens. - 🧱 Apply push-time content rules (Enterprise Cloud) — restricted file paths/extensions, max file size, max path length.
- 🔑 Grant bypass sparingly — an explicit, least-privilege allow-list of teams, apps, repo roles, or org admins, each with
always/pull_request/exemptmodes.
💡 Why it matters: one ruleset replaces dozens of drifting per-repo branch-protection configs. Policy lives in one reviewed file, applies the moment a new repo matches, and — being secure-by-default (
enforcement = "active", nobody bypasses) — never silently weakens the supply chain.
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!
This is an Org Governance module. It operates at organization scope and consumes repositories and teams by reference (their numeric ids) to target policy and grant bypass — it never creates them.
flowchart LR
repo["tf-mod-github-repository<br/>(keystone)"]
team["tf-mod-github-team"]
orgset["tf-mod-github-organization-settings"]
ruleset["tf-mod-github-organization-ruleset<br/>(this module)"]
membership["tf-mod-github-membership"]
orghook["tf-mod-github-organization-webhook"]
orgroles["tf-mod-github-organization-roles"]
repo -- "repo_id → conditions.repository_id" --> ruleset
team -- "id → bypass_actors[].actor_id" --> ruleset
orgroles -. "custom role id → bypass_actors[].actor_id".-> ruleset
orgset -. "org-scope sibling".-> ruleset
membership -. "org members governed".-> ruleset
orghook -. "org-scope sibling".-> ruleset
style ruleset fill:#8957E5,color:#fff
style repo fill:#24292F,color:#fff
Consumes repository ids (conditions.repository_id) and team / app / role ids (bypass_actors[].actor_id); emits id, ruleset_id, node_id, etag, name for audit and automation. See the 🔌 Cross-Module Contract.
A single org ruleset whose entire shape is driven by the typed rules, conditions, and bypass_actors inputs — every optional/repeating structure is a dynamic block.
flowchart TD
this["github_organization_ruleset.this<br/>name · target · enforcement"]
rules["rules { }<br/>(required, 1)"]
conditions["conditions { }<br/>(which repos / refs)"]
bypass["bypass_actors [ ]<br/>(who is exempt)"]
this --> rules
this --> conditions
this --> bypass
rules --> toggles["scalar toggles<br/>creation · deletion · update<br/>non_fast_forward · linear_history · signatures"]
rules --> pr["pull_request { }<br/>reviews · merge methods"]
rules --> checks["required_status_checks { }"]
rules --> patterns["*_name / *_email patterns<br/>(Enterprise)"]
rules --> ent["required_workflows · required_code_scanning<br/>(Enterprise Cloud)"]
rules --> push["file/size/path push rules<br/>(push target)"]
conditions --> refn["ref_name include/exclude"]
conditions --> repn["repository_name / repository_id / repository_property"]
style this fill:#8957E5,color:#fff
Resource inventory
github_organization_ruleset.this— the one resource this module manages. Carries the ruleset'sname,target,enforcement, a requiredrulesblock, an optionalconditionsblock (targeting), and zero or morebypass_actorsblocks (exemptions).
ℹ️ This is a composite by typing discipline: a single
github_*resource whose deeply-nested block tree (pattern rules, PR reviews, status checks, Enterprise workflows/code-scanning, push rules, three condition shapes, bypass actors) is modeled with exhaustiveobjectschemas rather thanany.
| Requirement | Version |
|---|---|
| Terraform | >= 1.12.0 |
integrations/github |
~> 6.0 (tested 6.12.1) |
⚠️ Provider source isintegrations/github— neverhashicorp/github(deprecated). Migrate old state withterraform state replace-provider registry.terraform.io/hashicorp/github registry.terraform.io/integrations/github.
Schema notes that bite:
- Pattern rules (
branch_name_pattern,commit_*_pattern,committer_email_pattern,tag_name_pattern) apply only to repositories inside a GitHub Enterprise — they are silently ignored on non-Enterprise orgs. required_workflows,required_code_scanning, and push-target rules require GitHub Enterprise Cloud.required_reviewersinsidepull_requestis a newer/beta surface.
tf-mod-github-organization-ruleset/
├── providers.tf # terraform{} + required_providers (integrations/github ~> 6.0)
├── variables.tf # name, target, enforcement, rules, conditions, bypass_actors
├── main.tf # github_organization_ruleset.this — pure projection of typed inputs
├── outputs.tf # id, ruleset_id, node_id, etag, name
├── SCOPE.md # scope contract: consumes/emits, token scopes, prerequisites
└── README.md # this file
The smallest useful org ruleset — protect the default branch on all repos, require one PR approval, block force-pushes and deletion:
module "protect_default_branches" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-organization-ruleset?ref=v1.0.0"
name = "protect-default-branches"
target = "branch"
enforcement = "active"
rules = {
deletion = true
non_fast_forward = true
pull_request = {
required_approving_review_count = 1
}
}
conditions = {
repository_name = { include = ["~ALL"] }
ref_name = { include = ["~DEFAULT_BRANCH"] }
}
}🔒
enforcementdefaults to"active"andbypass_actorsdefaults to[](nobody bypasses). You opt out of strictness, never into it.
Sourced directly from SCOPE.md. Org-governance modules consume repositories and teams by id; they never create them.
| Input | Type | Source |
|---|---|---|
conditions.repository_id |
list(number) |
tf-mod-github-repository → repo_id (numeric id, not name) |
conditions.repository_name.include/exclude |
list(string) |
tf-mod-github-repository → repo name, or literal globs / ~ALL |
rules.required_workflows[].repository_id |
number |
tf-mod-github-repository → repo_id (the repo hosting the workflow) |
bypass_actors[].actor_id |
number |
tf-mod-github-team → id, a GitHub App id, a repo-role id, or tf-mod-github-organization-roles custom role id |
| Output | Description | Consumed by |
|---|---|---|
id |
Ruleset node ID (primary reference) | Audit / reporting |
ruleset_id |
Numeric ruleset ID | GitHub REST API automation |
node_id |
GraphQL global node ID | GraphQL automation |
etag |
Resource etag | Change detection |
name |
Ruleset name (echo of input) | Audit / cross-reference |
1️⃣ Minimal — block deletion + force-push on every default branch
module "min_ruleset" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-organization-ruleset?ref=v1.0.0"
name = "no-delete-no-force-push"
rules = {
deletion = true
non_fast_forward = true
}
conditions = {
repository_name = { include = ["~ALL"] }
ref_name = { include = ["~DEFAULT_BRANCH"] }
}
}2️⃣ Pull-request review protection
module "pr_reviews" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-organization-ruleset?ref=v1.0.0"
name = "require-pr-review"
rules = {
pull_request = {
allowed_merge_methods = ["squash", "merge"]
dismiss_stale_reviews_on_push = true # secure default
require_code_owner_review = true # secure default
required_approving_review_count = 2
required_review_thread_resolution = true
}
}
conditions = {
repository_name = { include = ["~ALL"] }
ref_name = { include = ["~DEFAULT_BRANCH"] }
}
}💡
dismiss_stale_reviews_on_pushandrequire_code_owner_reviewdefault totruein this module — set themfalseexplicitly if you must relax them.
3️⃣ Required status checks (strict)
module "status_checks" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-organization-ruleset?ref=v1.0.0"
name = "require-ci-green"
rules = {
required_status_checks = {
strict_required_status_checks_policy = true # branch must be up to date
required_check = [
{ context = "build" },
{ context = "unit-tests" },
{ context = "terraform-validate", integration_id = 15368 } # GitHub Actions app id
]
}
}
conditions = {
repository_name = { include = ["~ALL"] }
ref_name = { include = ["~DEFAULT_BRANCH"] }
}
}4️⃣ Require signed commits + linear history
module "signed_linear" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-organization-ruleset?ref=v1.0.0"
name = "signed-and-linear"
rules = {
required_signatures = true
required_linear_history = true
non_fast_forward = true
}
conditions = {
repository_name = { include = ["~ALL"] }
ref_name = { include = ["~DEFAULT_BRANCH"] }
}
}5️⃣ Tag protection — lock release tags
module "protect_tags" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-organization-ruleset?ref=v1.0.0"
name = "immutable-release-tags"
target = "tag"
rules = {
creation = true # only bypassers create matching tags
deletion = true
update = true
}
conditions = {
repository_name = { include = ["~ALL"] }
ref_name = { include = ["refs/tags/v*"] }
}
}6️⃣ Branch-name pattern (Enterprise)
module "branch_naming" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-organization-ruleset?ref=v1.0.0"
name = "branch-naming-convention"
rules = {
branch_name_pattern = {
operator = "regex"
pattern = "^(feature|bugfix|hotfix)/[a-z0-9-]+$"
name = "conventional branch names"
}
}
conditions = {
repository_name = { include = ["~ALL"] }
ref_name = { include = ["~ALL"] }
}
}
⚠️ Pattern rules (branch_name_pattern,commit_*_pattern,tag_name_pattern) only apply to repos inside a GitHub Enterprise; they are ignored elsewhere.
7️⃣ Push-target content rules (Enterprise Cloud)
module "push_rules" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-organization-ruleset?ref=v1.0.0"
name = "block-large-and-binary"
target = "push" # NOTE: ref_name must NOT be set for push targets
rules = {
max_file_size = { max_file_size = 50 } # MB (1–100)
max_file_path_length = { max_file_path_length = 255 }
file_extension_restriction = { restricted_file_extensions = ["exe", "dll", "jar"] }
file_path_restriction = { restricted_file_paths = ["secrets/**", "**/*.pem"] }
}
conditions = {
repository_name = { include = ["~ALL"] }
# ref_name intentionally omitted — invalid on push targets
}
}🔒 Push rulesets reject pushes before they land — the only way to keep
.pemfiles and oversized binaries out of history fleet-wide.
8️⃣ Required workflows (Enterprise Cloud)
module "required_workflows" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-organization-ruleset?ref=v1.0.0"
name = "mandatory-security-scan"
rules = {
required_workflows = {
required_workflow = [
{
repository_id = module.workflow_host_repo.repo_id # numeric id, NOT name
path = ".github/workflows/security-scan.yml"
ref = "refs/heads/main"
}
]
}
}
conditions = {
repository_name = { include = ["~ALL"] }
ref_name = { include = ["~DEFAULT_BRANCH"] }
}
}9️⃣ Required code scanning (Enterprise Cloud)
module "code_scanning" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-organization-ruleset?ref=v1.0.0"
name = "codeql-required"
rules = {
required_code_scanning = {
required_code_scanning_tool = [
{
tool = "CodeQL"
alerts_threshold = "errors" # none|errors|errors_and_warnings|all
security_alerts_threshold = "high_or_higher" # none|critical|high_or_higher|medium_or_higher|all
}
]
}
}
conditions = {
repository_name = { include = ["~ALL"] }
ref_name = { include = ["~DEFAULT_BRANCH"] }
}
}🔟 Target by custom repository property
module "prod_repos" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-organization-ruleset?ref=v1.0.0"
name = "prod-tier-protection"
rules = {
deletion = true
non_fast_forward = true
pull_request = { required_approving_review_count = 2 }
}
conditions = {
ref_name = { include = ["~DEFAULT_BRANCH"] }
repository_property = {
include = [
{ name = "environment", property_values = ["production"] }
]
}
}
}
⚠️ The custom property (environmenthere) must already exist at the org level — define org custom properties before referencing them (out of scope for this module).
1️⃣1️⃣ Bypass actors — least-privilege exemptions
module "ruleset_with_bypass" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-organization-ruleset?ref=v1.0.0"
name = "protect-with-platform-bypass"
rules = {
deletion = true
non_fast_forward = true
pull_request = { required_approving_review_count = 1 }
}
conditions = {
repository_name = { include = ["~ALL"] }
ref_name = { include = ["~DEFAULT_BRANCH"] }
}
bypass_actors = [
{ actor_type = "OrganizationAdmin", actor_id = 1, bypass_mode = "always" },
{ actor_type = "Team", actor_id = module.platform_team.id, bypass_mode = "pull_request" },
{ actor_type = "Integration", actor_id = 15368, bypass_mode = "always" } # a trusted GitHub App
]
}🔒 Keep this list short.
bypass_mode = "pull_request"lets an actor merge without the rule via a PR but still records it;"always"is an unconditional escape hatch — reserve it for break-glass automation.
1️⃣2️⃣ Evaluate mode — test a policy before enforcing
module "dry_run" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-organization-ruleset?ref=v1.0.0"
name = "linear-history-trial"
enforcement = "evaluate" # reported but NOT enforced (org owners only)
rules = {
required_linear_history = true
}
conditions = {
repository_name = { include = ["~ALL"] }
ref_name = { include = ["~DEFAULT_BRANCH"] }
}
}💡
evaluatesurfaces the insights tab so you can see who would be blocked before flipping toactive. Only org owners can use it.
1️⃣3️⃣ Scope to a named subset of repos (exclude archived)
module "active_repos_only" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-organization-ruleset?ref=v1.0.0"
name = "active-service-repos"
rules = {
deletion = true
non_fast_forward = true
pull_request = { required_approving_review_count = 1 }
}
conditions = {
ref_name = { include = ["~DEFAULT_BRANCH"] }
repository_name = {
include = ["svc-*", "lib-*"]
exclude = ["*-sandbox", "*-archived"]
protected = false
}
}
}1️⃣4️⃣ for_each at scale — a fleet of rulesets from a map
locals {
rulesets = {
default-branch-protection = {
target = "branch"
rules = {
deletion = true
non_fast_forward = true
pull_request = { required_approving_review_count = 1 }
}
conditions = {
repository_name = { include = ["~ALL"] }
ref_name = { include = ["~DEFAULT_BRANCH"] }
}
}
release-tag-lock = {
target = "tag"
rules = { creation = true, update = true, deletion = true }
conditions = {
repository_name = { include = ["~ALL"] }
ref_name = { include = ["refs/tags/v*"] }
}
}
block-binaries = {
target = "push"
rules = { max_file_size = { max_file_size = 50 } }
conditions = {
repository_name = { include = ["~ALL"] }
}
}
}
}
module "org_rulesets" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-organization-ruleset?ref=v1.0.0"
for_each = local.rulesets
name = each.key
target = each.value.target
rules = each.value.rules
conditions = each.value.conditions
}
⚠️ Bulkfor_eachover the org REST API can trip secondary rate limits — see Architecture Notes. Keep-parallelismmodest on large fleets.
1️⃣5️⃣ Wire targeting from tf-mod-github-repository
module "service_repo" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-repository?ref=v1.0.0"
name = "loan-origination-api"
visibility = "private"
}
module "scoped_ruleset" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-organization-ruleset?ref=v1.0.0"
name = "loan-origination-protection"
rules = {
deletion = true
non_fast_forward = true
required_signatures = true
pull_request = { required_approving_review_count = 2, require_code_owner_review = true }
}
conditions = {
repository_id = [module.service_repo.repo_id] # numeric id, NOT the repo name
ref_name = { include = ["~DEFAULT_BRANCH"] }
}
}
⚠️ conditions.repository_idtakes the numericrepo_id, not the repo name (id/full_name). Wiring the wrong output is the most common targeting bug.
🏗️ End-to-end composition (mandatory) — repository + team + governed ruleset
A full org-governance slice: a private repo, a platform team, and a hardened org ruleset that targets the repo by id and grants the team a pull_request bypass.
# 1 · Keystone repository
module "payments_repo" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-repository?ref=v1.0.0"
name = "payments-ledger"
visibility = "private"
vulnerability_alerts = true
}
# 2 · Platform team (bypass actor)
module "platform_team" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"
name = "platform-engineering"
privacy = "closed"
}
# 3 · Org ruleset, wired entirely from siblings' outputs
module "payments_ruleset" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-organization-ruleset?ref=v1.0.0"
name = "payments-ledger-protection"
target = "branch"
enforcement = "active"
rules = {
deletion = true
non_fast_forward = true
required_linear_history = true
required_signatures = true
pull_request = {
allowed_merge_methods = ["squash"]
dismiss_stale_reviews_on_push = true
require_code_owner_review = true
required_approving_review_count = 2
}
required_status_checks = {
strict_required_status_checks_policy = true
required_check = [{ context = "ci/build" }, { context = "ci/test" }]
}
}
conditions = {
repository_id = [module.payments_repo.repo_id]
ref_name = { include = ["~DEFAULT_BRANCH"] }
}
bypass_actors = [
{ actor_type = "Team", actor_id = module.platform_team.id, bypass_mode = "pull_request" }
]
}
output "payments_ruleset_id" {
value = module.payments_ruleset.ruleset_id
}💡 Note the wiring directions:
repo_id(numeric) →conditions.repository_id; teamid→bypass_actors[].actor_id. Outputs flow into inputs with no manual id copying.
Identity & enforcement
name(string, required) — the ruleset name.target(string, default"branch") —branch|tag|push. Forpush, only file/size/path rules apply andconditions.ref_namemust be omitted.enforcement(string, default"active") —active|evaluate|disabled.
Policy
rules(object, required) — the protections. Scalar toggles, pattern rules (Enterprise),pull_request,required_status_checks,required_workflows/required_code_scanning(Enterprise Cloud), and push-target file rules.
Targeting & exemptions
conditions(object, defaultnull) —repository_id/repository_name/repository_propertyplusref_name. Exactly one repository selector should be set.bypass_actors(list(object), default[]) — who may bypass, withactor_type, optionalactor_id, andbypass_mode.
Full object schemas
rules = object({
creation = optional(bool)
deletion = optional(bool)
update = optional(bool)
non_fast_forward = optional(bool)
required_linear_history = optional(bool)
required_signatures = optional(bool)
# Enterprise-only pattern rules — operator: starts_with|ends_with|contains|regex
branch_name_pattern = optional(object({ operator = string, pattern = string, name = optional(string), negate = optional(bool) }))
commit_author_email_pattern = optional(object({ operator = string, pattern = string, name = optional(string), negate = optional(bool) }))
commit_message_pattern = optional(object({ operator = string, pattern = string, name = optional(string), negate = optional(bool) }))
committer_email_pattern = optional(object({ operator = string, pattern = string, name = optional(string), negate = optional(bool) }))
tag_name_pattern = optional(object({ operator = string, pattern = string, name = optional(string), negate = optional(bool) }))
copilot_code_review = optional(object({
review_on_push = optional(bool)
review_draft_pull_requests = optional(bool)
}))
pull_request = optional(object({
allowed_merge_methods = optional(list(string)) # merge|squash|rebase
dismiss_stale_reviews_on_push = optional(bool, true) # secure default
require_code_owner_review = optional(bool, true) # secure default
require_last_push_approval = optional(bool)
required_approving_review_count = optional(number, 1) # secure default >= 1
required_review_thread_resolution = optional(bool)
required_reviewers = optional(list(object({ # beta
file_patterns = list(string)
minimum_approvals = number
reviewer = object({ id = number, type = optional(string, "Team") })
})), [])
}))
required_status_checks = optional(object({
strict_required_status_checks_policy = optional(bool, true) # secure default
do_not_enforce_on_create = optional(bool)
required_check = optional(list(object({
context = string
integration_id = optional(number)
})), [])
}))
required_workflows = optional(object({ # Enterprise Cloud
do_not_enforce_on_create = optional(bool)
required_workflow = list(object({
repository_id = number # numeric repo id — wire from tf-mod-github-repository.repo_id
path = string
ref = optional(string)
}))
}))
required_code_scanning = optional(object({ # Enterprise Cloud
required_code_scanning_tool = list(object({
tool = string
alerts_threshold = string # none|errors|errors_and_warnings|all
security_alerts_threshold = string # none|critical|high_or_higher|medium_or_higher|all
}))
}))
# push-target rules only
file_path_restriction = optional(object({ restricted_file_paths = list(string) }))
file_extension_restriction = optional(object({ restricted_file_extensions = list(string) }))
max_file_size = optional(object({ max_file_size = number })) # 1-100 MB
max_file_path_length = optional(object({ max_file_path_length = number }))
})
conditions = optional(object({
repository_id = optional(list(number))
ref_name = optional(object({ include = optional(list(string), []), exclude = optional(list(string), []) }))
repository_name = optional(object({
include = optional(list(string), [])
exclude = optional(list(string), [])
protected = optional(bool)
}))
repository_property = optional(object({
include = optional(list(object({ name = string, property_values = optional(list(string), []), source = optional(string) })), [])
exclude = optional(list(object({ name = string, property_values = optional(list(string), []), source = optional(string) })), [])
}))
}))
bypass_actors = list(object({
actor_id = optional(number) # Team / App / RepositoryRole id
actor_type = string # RepositoryRole | Team | Integration | OrganizationAdmin
bypass_mode = optional(string, "always") # always | pull_request | exempt
}))| Output | Description |
|---|---|
id |
Ruleset node ID — primary cross-module reference |
ruleset_id |
Numeric ruleset ID for the GitHub REST API |
node_id |
GraphQL global node ID |
etag |
Resource etag — change detection |
name |
The ruleset name (echo of input) |
ℹ️ No
arn, noslug, nofull_name— org rulesets don't expose them. None of these outputs aresensitive.
idvsruleset_idvsnode_id.idis the provider's primary reference (a node identifier);ruleset_idis the numeric id the REST API (/orgs/{org}/rulesets/{id}) expects;node_idis the GraphQL global node id. Useruleset_idforgh apiautomation,node_idfor GraphQL.- Org rulesets vs branch protection. This module deliberately uses
github_organization_ruleset, the modern API, over legacygithub_branch_protection(v3). Rulesets are org-scoped (one declaration, many repos), layer cleanly, and support push rules and custom-property targeting that branch protection cannot. - Layering — most restrictive wins. An org ruleset and a per-repo ruleset (
tf-mod-github-repository-ruleset) can both match the same ref. GitHub takes the union of restrictions — the strictest applies. A repo cannot loosen an org rule; it can only add. Audit both layers when debugging "why can't I push." - Targeting takes the numeric id.
conditions.repository_idandrequired_workflows[].repository_idarenumber(therepo_idoutput), not the repo name. Wiringmodule.repo.id/full_namehere silently mis-targets. - Push targets are special. With
target = "push",conditions.ref_namemust be omitted and only the four push rules (file_path_restriction,file_extension_restriction,max_file_size,max_file_path_length) take effect.max_file_sizeis in MB (1–100). - Enterprise gating is silent. Pattern rules require GitHub Enterprise;
required_workflows,required_code_scanning, and push rulesets require Enterprise Cloud. On a non-eligible org the provider may accept the config but the rule is ignored — confirm your edition first. - No ForceNew surprises.
name,target, and rule changes update in place; this resource has no immutable-on-rename trap like repositoryname. - Sensitive secrets — none here. Org rulesets carry no secret values, so there are no
sensitiveinputs or outputs. (Contrast Actions/Dependabot secret modules.) - Authoritative resource.
github_organization_rulesetis authoritative for the ruleset it manages (Terraform owns its full rule/condition/bypass set) but additive at the org level — it does not delete rulesets created out-of-band. Two Terraform configs managing rulesets of the samenamewill fight; keep ruleset names unique and Terraform-owned. - Eventual consistency & secondary rate limits. The GitHub REST API is eventually consistent: a freshly created ruleset may not be readable for a beat, and bulk
for_eachapplies can trip secondary rate limits (HTTP 403 with aRetry-After). Keepterraform apply -parallelismmodest (e.g.5) for large fleets; the provider retries but tight loops still throttle.
- 🔒 Secure by default.
enforcement = "active",bypass_actors = [](nobody bypasses), and PR defaults (dismiss_stale_reviews_on_push = true,require_code_owner_review = true,required_approving_review_count = 1) are baked in. You opt out of strictness explicitly. - 🎯 Least-privilege bypass. Every exemption is an explicit list entry with a typed actor and mode — there is no "allow all" shortcut.
- 🧬 Deeply typed, no
any. Every nested block is a concreteobjectwithoptionaldefaults andvalidation {}on all closed value sets (operators, thresholds, merge methods, actor types, bypass modes). - 🚫 No tags, no timeouts, no auth. GitHub has none; auth and
ownerare provider concerns, never module variables. - 📜 Policy as one reviewed artifact. Fleet-wide protection lives in a single file under version control, applied the moment a repo matches a condition.
terraform init -backend=false
terraform validate
terraform fmt -check
terraform plan
terraform apply
terraform output
⚠️ Always pin the module to a tag —?ref=v1.0.0, never a branch. Branch refs drift and make plans non-reproducible.
The offline proof gate (no GitHub credentials needed):
terraform fmt -check # zero formatting differences
terraform validate # zero errors
tflint # core rules — no dedicated GitHub ruleset existsAll three must pass before commit. terraform plan/apply require a provider configured with an org-owner identity.
id = "RRS_lACqRmluUGFydG5lcnPOAAEx5w"
ruleset_id = 70631
node_id = "RRS_lACqRmluUGFydG5lcnPOAAEx5w"
etag = "W/\"a1b2c3d4e5f6\""
name = "payments-ledger-protection"| Symptom | Cause | Resolution |
|---|---|---|
403: Resource not accessible by integration |
Token lacks org-admin scope, or identity isn't an org owner | Use a PAT with admin:org (classic) or a fine-grained PAT/App with Organization administration: read/write; the identity must be an org owner. |
| Rule appears to do nothing | Enterprise-only rule on a non-Enterprise org | Pattern rules need Enterprise; required_workflows/required_code_scanning/push rules need Enterprise Cloud. Confirm your edition. |
conditions targets the wrong repos |
Wired repo name into repository_id |
repository_id is the numeric repo_id; use repository_name.include for name globs. |
Error: ref_name... not allowed on a push ruleset |
ref_name set with target = "push" |
Omit conditions.ref_name for push targets. |
Intermittent 403 / Retry-After on bulk apply |
Secondary rate limit on org API | Lower -parallelism (e.g. 5); retry. Avoid huge single applies. |
| Custom-property condition errors | Property not defined at org level | Create the org custom property first (out of scope for this module). |
| Push still blocked despite a bypass | A repo ruleset also matches | Org + repo rulesets layer — most restrictive wins. Audit tf-mod-github-repository-ruleset too. |
evaluate rejected |
Caller isn't an org owner | enforcement = "evaluate" is org-owner-only; use active or disabled otherwise. |
tf-mod-github-repository— the keystone module emittingrepo_idconsumed heretf-mod-github-repository-ruleset— the per-repo counterpart that layers with this moduletf-mod-github-team/tf-mod-github-organization-roles— sources ofbypass_actors[].actor_idtf-mod-github-organization-settings— the org-scope sibling for org-wide configurationintegrations/githubprovider —github_organization_rulesetresource reference- GitHub Docs — About rulesets and Managing rulesets for repositories in your organization