Skip to content

microsoftexpert/tf-mod-github-organization-ruleset

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🐙 GitHub Organization Ruleset Terraform Module

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.

Terraform GitHub provider module type resources


🧩 Overview

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-widecreation / deletion / update locks, non_fast_forward (no force-push), required_linear_history, and required_signatures across 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_name glob, or custom repository property, and by ref pattern with the ~ALL / ~DEFAULT_BRANCH tokens.
  • 🧱 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 / exempt modes.

💡 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.


❤️ 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

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
Loading

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.


🧬 What this module builds

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
Loading

Resource inventory

  • github_organization_ruleset.this — the one resource this module manages. Carries the ruleset's name, target, enforcement, a required rules block, an optional conditions block (targeting), and zero or more bypass_actors blocks (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 exhaustive object schemas rather than any.


✅ Provider / Versions

Requirement Version
Terraform >= 1.12.0
integrations/github ~> 6.0 (tested 6.12.1)

⚠️ Provider source is integrations/github — never hashicorp/github (deprecated). Migrate old state with terraform 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_reviewers inside pull_request is a newer/beta surface.

📁 Module Structure

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

⚙️ Quick Start

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

🔒 enforcement defaults to "active" and bypass_actors defaults to [] (nobody bypasses). You opt out of strictness, never into it.


🔌 Cross-Module Contract

Sourced directly from SCOPE.md. Org-governance modules consume repositories and teams by id; they never create them.

Consumes

Input Type Source
conditions.repository_id list(number) tf-mod-github-repositoryrepo_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-repositoryrepo_id (the repo hosting the workflow)
bypass_actors[].actor_id number tf-mod-github-teamid, a GitHub App id, a repo-role id, or tf-mod-github-organization-roles custom role id

Emits

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

📚 Example Library

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_push and require_code_owner_review default to true in this module — set them false explicitly 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 .pem files 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 (environment here) 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"] }
  }
}

💡 evaluate surfaces the insights tab so you can see who would be blocked before flipping to active. 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
}

⚠️ Bulk for_each over the org REST API can trip secondary rate limits — see Architecture Notes. Keep -parallelism modest 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_id takes the numeric repo_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; team idbypass_actors[].actor_id. Outputs flow into inputs with no manual id copying.


📥 Inputs

Identity & enforcement

  • name (string, required) — the ruleset name.
  • target (string, default "branch")branch | tag | push. For push, only file/size/path rules apply and conditions.ref_name must 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, default null)repository_id / repository_name / repository_property plus ref_name. Exactly one repository selector should be set.
  • bypass_actors (list(object), default []) — who may bypass, with actor_type, optional actor_id, and bypass_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
}))

🧾 Outputs

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, no slug, no full_name — org rulesets don't expose them. None of these outputs are sensitive.


🧠 Architecture Notes

  • id vs ruleset_id vs node_id. id is the provider's primary reference (a node identifier); ruleset_id is the numeric id the REST API (/orgs/{org}/rulesets/{id}) expects; node_id is the GraphQL global node id. Use ruleset_id for gh api automation, node_id for GraphQL.
  • Org rulesets vs branch protection. This module deliberately uses github_organization_ruleset, the modern API, over legacy github_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_id and required_workflows[].repository_id are number (the repo_id output), not the repo name. Wiring module.repo.id/full_name here silently mis-targets.
  • Push targets are special. With target = "push", conditions.ref_name must 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_size is 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 repository name.
  • Sensitive secrets — none here. Org rulesets carry no secret values, so there are no sensitive inputs or outputs. (Contrast Actions/Dependabot secret modules.)
  • Authoritative resource. github_organization_ruleset is 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 same name will 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_each applies can trip secondary rate limits (HTTP 403 with a Retry-After). Keep terraform apply -parallelism modest (e.g. 5) for large fleets; the provider retries but tight loops still throttle.

🧱 Design Principles

  • 🔒 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 concrete object with optional defaults and validation {} on all closed value sets (operators, thresholds, merge methods, actor types, bypass modes).
  • 🚫 No tags, no timeouts, no auth. GitHub has none; auth and owner are 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.

🚀 Runbook

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.


🧪 Testing

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 exists

All three must pass before commit. terraform plan/apply require a provider configured with an org-owner identity.


💬 Example Output

id         = "RRS_lACqRmluUGFydG5lcnPOAAEx5w"
ruleset_id = 70631
node_id    = "RRS_lACqRmluUGFydG5lcnPOAAEx5w"
etag       = "W/\"a1b2c3d4e5f6\""
name       = "payments-ledger-protection"

🔍 Troubleshooting

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.

🔗 Related Docs

  • tf-mod-github-repository — the keystone module emitting repo_id consumed here
  • tf-mod-github-repository-ruleset — the per-repo counterpart that layers with this module
  • tf-mod-github-team / tf-mod-github-organization-roles — sources of bypass_actors[].actor_id
  • tf-mod-github-organization-settings — the org-scope sibling for org-wide configuration
  • integrations/github provider — github_organization_ruleset resource reference
  • GitHub Docs — About rulesets and Managing rulesets for repositories in your organization

About

No description or website provided.

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages