Skip to content

microsoftexpert/tf-mod-github-repository-ruleset

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🐙 GitHub Repository Ruleset Terraform Module

One enforced, deeply-typed github_repository_ruleset — the modern, secure-by-default replacement for branch protection that governs a single repository's branches, tags, and pushes from one place, with every rule, condition, and bypass actor expressed as a validated object schema. Built for integrations/github v6.x.

Terraform GitHub provider module type resources


🧩 Overview

  • 🛡️ Creates one github_repository_ruleset pinned to a single repository — the enforced, API-v4 successor to github_branch_protection.
  • 🎯 Targets branches, tags, or pushes (target = "branch" | "tag" | "push").
  • ⚙️ Projects a fully-typed rules object onto the provider: scalar toggles (non_fast_forward, required_signatures, …) plus block rules (pull_request, required_status_checks, merge_queue, required_deployments, push file restrictions, and Enterprise-only pattern / code-scanning / Copilot rules).
  • 🔀 Targets refs precisely via conditions.ref_name.include / exclude, including the ~ALL and ~DEFAULT_BRANCH tokens.
  • 🚦 Grants least-privilege bypassbypass_actors defaults to [] (nobody bypasses); every entry is validated for actor_type and bypass_mode.
  • 🔒 Secure by default: enforcement = "active", PR review with dismiss_stale_reviews_on_push and require_code_owner_review on, required_approving_review_count = 1, and strict status-check policy on.

💡 Why it matters: rulesets are GitHub's current, layerable enforcement model — an org ruleset and a repo ruleset can both match a ref and the most restrictive rule wins. This module gives a single, validated, reviewable boundary for "what may land on a protected branch" without hand-editing the GitHub UI.


❤️ 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["tf-mod-github-repository<br/>(keystone)"]
 team["tf-mod-github-team"]
 rs["tf-mod-github-repository-ruleset<br/>(THIS module)"]
 orgrs["tf-mod-github-organization-ruleset"]
 bp["tf-mod-github-branch-protection<br/>(legacy alternative)"]
 collab["tf-mod-github-repository-collaborators"]

 repo -- "id (repo name)" --> rs
 team -- "id (Team bypass actor)" --> rs
 repo -. "alternative".-> bp
 orgrs -. "org-scope sibling".-> rs
 repo --> collab

 style rs fill:#8957E5,color:#fff
 style repo fill:#24292F,color:#fff
Loading

This module consumes a repository name (from tf-mod-github-repository.id) and optional Team / App / Role ids for bypass actors, and emits the ruleset id / ruleset_id / node_id for audit and automation. See the 🔌 Cross-Module Contract.


🧬 What this module builds

flowchart TD
 this["github_repository_ruleset.this<br/>name · repository · target · enforcement"]

 rules["rules { } (required, 1)"]
 conditions["conditions { } (dynamic)"]
 bypass["bypass_actors [ ] (dynamic, default none)"]

 this --> rules
 this --> conditions
 this --> bypass

 rules --> toggles["scalar toggles<br/>creation · deletion · update<br/>non_fast_forward · required_signatures<br/>required_linear_history"]
 rules --> pr["pull_request<br/>(reviews · merge methods · required_reviewers)"]
 rules --> checks["required_status_checks<br/>(required_check[])"]
 rules --> mq["merge_queue"]
 rules --> deploy["required_deployments"]
 rules --> ent["Enterprise-only<br/>*_pattern · required_code_scanning · copilot_code_review"]
 rules --> push["push-target rules<br/>file_path / file_extension / max_file_size / max_file_path_length"]

 conditions --> refname["ref_name { include / exclude }"]
 bypass --> actor["actor_id · actor_type · bypass_mode"]

 style this fill:#8957E5,color:#fff
Loading

Resource inventory

  • github_repository_ruleset.this — the single primary resource. Although the module manages exactly one github_* resource, it is treated as composite because that resource carries deeply-nested repeating blocks (rules, conditions, bypass_actors) rendered as dynamic blocks, demanding the composite typing discipline.

✅ Provider / Versions

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

ℹ️ The provider source is integrations/githubnever the deprecated hashicorp/github. If you have old state on the deprecated provider, migrate first with terraform state replace-provider registry.terraform.io/hashicorp/github registry.terraform.io/integrations/github.

Schema notes that bite:

  • 🏢 Enterprise-only rules are silently meaningful only on GitHub Enterprise: the *_pattern rules, required_code_scanning, copilot_code_review, and certain bypass-actor types. They validate fine offline but fail (or no-op) on apply against a non-Enterprise org.
  • 🧮 enforcement = "evaluate" is only supported by the provider for organization-owned targets. For repository rulesets use "active" or "disabled".

📁 Module Structure

tf-mod-github-repository-ruleset/
├── providers.tf # terraform{} + required_providers (integrations/github ~> 6.0)
├── variables.tf # name, repository, target, enforcement, rules, conditions, bypass_actors
├── main.tf # github_repository_ruleset.this — dynamic-block projection of var.rules
├── outputs.tf # id, ruleset_id, node_id, etag, name, repository
├── SCOPE.md # scope contract: consumes/emits, token scopes, prerequisites
└── README.md # this file

⚙️ Quick Start

The smallest call that protects the default branch of a repository created by the keystone module:

module "repository" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-repository?ref=v1.0.0"
  name   = "payments-api"
}

module "default_branch_ruleset" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-repository-ruleset?ref=v1.0.0"

  name       = "protect-default-branch"
  repository = module.repository.id # the keystone's id IS the repo name

  conditions = {
    ref_name = {
      include = ["~DEFAULT_BRANCH"]
    }
  }

  rules = {
    deletion         = true # only bypassers may delete
    non_fast_forward = true # block force-push
    pull_request     = {}   # secure PR defaults kick in (1 approval, code-owner review, dismiss stale)
  }
}

🔌 Cross-Module Contract

Consumes

Input Type Source
repository string (repo name) tf-mod-github-repositoryid output
bypass_actors[].actor_id number tf-mod-github-teamid (Team bypass), a GitHub App / Integration id, or a custom org-role id

Emits

Output Description Consumed by
id Ruleset node ID (primary reference) Audit / reporting modules
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 / reporting
repository Repository the ruleset applies to (echo of input) Audit / reporting

ℹ️ Sourced directly from SCOPE.md. Repository rulesets have no ARN, no slug, no full_name — the cross-module surface is the node id plus the numeric ruleset_id.


📚 Example Library

1️⃣ Minimal — protect the default branch
module "ruleset" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-repository-ruleset?ref=v1.0.0"

  name       = "protect-default-branch"
  repository = "payments-api"

  conditions = { ref_name = { include = ["~DEFAULT_BRANCH"] } }
  rules      = { deletion = true, non_fast_forward = true }
}
2️⃣ Repository wired from tf-mod-github-repository
module "repository" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-repository?ref=v1.0.0"
  name   = "ledger-core"
}

module "ruleset" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-repository-ruleset?ref=v1.0.0"

  name       = "protect-default-branch"
  repository = module.repository.id # id IS the repo name

  conditions = { ref_name = { include = ["~DEFAULT_BRANCH"] } }
  rules = {
    deletion         = true
    non_fast_forward = true
    pull_request     = {} # secure defaults
  }
}

💡 Always wire repository from the keystone's id so the ruleset is created after the repo and tracks renames in plan order.

3️⃣ Pull-request review protection
module "ruleset" {
  source     = "git::https://github.com/microsoftexpert/tf-mod-github-repository-ruleset?ref=v1.0.0"
  name       = "require-reviews"
  repository = "ledger-core"

  conditions = { ref_name = { include = ["~DEFAULT_BRANCH"] } }

  rules = {
    pull_request = {
      required_approving_review_count   = 2
      require_code_owner_review         = true
      dismiss_stale_reviews_on_push     = true
      require_last_push_approval        = true
      required_review_thread_resolution = true
      allowed_merge_methods             = ["squash"]
    }
  }
}
4️⃣ Required status checks
module "ruleset" {
  source     = "git::https://github.com/microsoftexpert/tf-mod-github-repository-ruleset?ref=v1.0.0"
  name       = "require-ci"
  repository = "ledger-core"

  conditions = { ref_name = { include = ["~DEFAULT_BRANCH"] } }

  rules = {
    required_status_checks = {
      strict_required_status_checks_policy = true
      required_check = [
        { context = "build" },
        { context = "unit-tests" },
        { context = "terraform-validate" },
      ]
    }
  }
}

⚠️ A check context will only ever pass if a CI run actually reports that exact name. A typo'd context blocks every merge silently.

5️⃣ Signed commits + linear history
module "ruleset" {
  source     = "git::https://github.com/microsoftexpert/tf-mod-github-repository-ruleset?ref=v1.0.0"
  name       = "verified-linear"
  repository = "ledger-core"

  conditions = { ref_name = { include = ["~DEFAULT_BRANCH"] } }

  rules = {
    required_signatures     = true
    required_linear_history = true
    non_fast_forward        = true
  }
}
6️⃣ Merge queue
module "ruleset" {
  source     = "git::https://github.com/microsoftexpert/tf-mod-github-repository-ruleset?ref=v1.0.0"
  name       = "merge-queue"
  repository = "ledger-core"

  conditions = { ref_name = { include = ["~DEFAULT_BRANCH"] } }

  rules = {
    merge_queue = {
      merge_method                   = "SQUASH"
      grouping_strategy              = "ALLGREEN"
      max_entries_to_merge           = 5
      min_entries_to_merge           = 1
      check_response_timeout_minutes = 60
    }
  }
}
7️⃣ Tag protection (target = "tag")
module "ruleset" {
  source     = "git::https://github.com/microsoftexpert/tf-mod-github-repository-ruleset?ref=v1.0.0"
  name       = "protect-release-tags"
  repository = "ledger-core"
  target     = "tag"

  conditions = { ref_name = { include = ["refs/tags/v*"] } }

  rules = {
    deletion         = true
    update           = true
    non_fast_forward = true
  }
}
8️⃣ Push protection (target = "push") — file restrictions
module "ruleset" {
  source     = "git::https://github.com/microsoftexpert/tf-mod-github-repository-ruleset?ref=v1.0.0"
  name       = "block-large-and-secrets"
  repository = "ledger-core"
  target     = "push"

  # NOTE: push targets must NOT set conditions.ref_name — leave conditions null.

  rules = {
    max_file_size              = { max_file_size = 100 } # MB
    max_file_path_length       = { max_file_path_length = 255 }
    file_extension_restriction = { restricted_file_extensions = ["exe", "dll", "pem", "key"] }
    file_path_restriction      = { restricted_file_paths = ["secrets/**", "**/*.env"] }
  }
}

⚠️ Push rulesets are Enterprise-org-only in some editions. See GitHub Prerequisites.

9️⃣ Bypass actors — least-privilege exceptions
module "ruleset" {
  source     = "git::https://github.com/microsoftexpert/tf-mod-github-repository-ruleset?ref=v1.0.0"
  name       = "protect-with-breakglass"
  repository = "ledger-core"

  conditions = { ref_name = { include = ["~DEFAULT_BRANCH"] } }
  rules      = { deletion = true, non_fast_forward = true, pull_request = {} }

  bypass_actors = [
    # Org admins may bypass only via a pull request (no direct push)
    { actor_type = "OrganizationAdmin", actor_id = 1, bypass_mode = "pull_request" },
    # A release-automation Team always bypasses
    { actor_type = "Team", actor_id = module.release_team.id, bypass_mode = "always" },
  ]
}

🔒 bypass_actors defaults to []nobody bypasses. Grant explicitly and sparingly. actor_id semantics depend on actor_type (App id vs Team id vs Role id).

🔟 Include / exclude ref patterns
module "ruleset" {
  source     = "git::https://github.com/microsoftexpert/tf-mod-github-repository-ruleset?ref=v1.0.0"
  name       = "protect-release-branches"
  repository = "ledger-core"

  conditions = {
    ref_name = {
      include = ["refs/heads/release/*", "~DEFAULT_BRANCH"]
      exclude = ["refs/heads/release/experimental-*"]
    }
  }

  rules = { non_fast_forward = true, pull_request = {} }
}
1️⃣1️⃣ Required deployments (environment gates)
module "ruleset" {
  source     = "git::https://github.com/microsoftexpert/tf-mod-github-repository-ruleset?ref=v1.0.0"
  name       = "require-staging-first"
  repository = "ledger-core"

  conditions = { ref_name = { include = ["~DEFAULT_BRANCH"] } }

  rules = {
    required_deployments = {
      required_deployment_environments = ["staging"]
    }
  }
}
1️⃣2️⃣ 🏢 Enterprise — code scanning + commit-message pattern
module "ruleset" {
  source     = "git::https://github.com/microsoftexpert/tf-mod-github-repository-ruleset?ref=v1.0.0"
  name       = "enterprise-hardening"
  repository = "ledger-core"

  conditions = { ref_name = { include = ["~DEFAULT_BRANCH"] } }

  rules = {
    commit_message_pattern = {
      operator = "regex"
      pattern  = "^(feat|fix|chore|docs)(\\(.+\\))?:.+"
      name     = "conventional-commits"
    }

    required_code_scanning = {
      required_code_scanning_tool = [
        {
          tool                      = "CodeQL"
          alerts_threshold          = "errors"
          security_alerts_threshold = "high_or_higher"
        }
      ]
    }
  }
}

🏢 *_pattern and required_code_scanning rules require GitHub Enterprise (and Advanced Security for code scanning). They will fail on apply against a non-Enterprise org.

1️⃣3️⃣ 🔒 Secure / hardened variant — the default for a protected branch
module "hardened_ruleset" {
  source     = "git::https://github.com/microsoftexpert/tf-mod-github-repository-ruleset?ref=v1.0.0"
  name       = "casey-hardened-default-branch"
  repository = module.repository.id

  enforcement = "active"
  conditions  = { ref_name = { include = ["~DEFAULT_BRANCH"] } }

  rules = {
    # Only bypassers may create/delete/force-push the protected branch
    creation                = true
    deletion                = true
    update                  = true
    non_fast_forward        = true
    required_linear_history = true
    required_signatures     = true

    pull_request = {
      required_approving_review_count   = 2
      require_code_owner_review         = true
      dismiss_stale_reviews_on_push     = true
      require_last_push_approval        = true
      required_review_thread_resolution = true
      allowed_merge_methods             = ["squash"]
    }

    required_status_checks = {
      strict_required_status_checks_policy = true
      required_check = [
        { context = "build" },
        { context = "test" },
        { context = "security-scan" },
      ]
    }
  }

  # No bypass actors — nobody is exempt on the most sensitive branch.
  bypass_actors = []
}

🔒 This is the recommended baseline for any regulated repository: enforced, signed, reviewed, CI-gated, and zero bypass.

1️⃣4️⃣ for_each at scale — one ruleset per repo from a map(object)
variable "rulesets" {
  type = map(object({
    repository = string
    reviews    = optional(number, 1)
    checks     = optional(list(string), [])
  }))
  default = {
    "payments-api" = { repository = "payments-api", reviews = 2, checks = ["build", "test"] }
    "ledger-core"  = { repository = "ledger-core", reviews = 2, checks = ["build"] }
    "web-portal"   = { repository = "web-portal", reviews = 1 }
  }
}

module "rulesets" {
  source   = "git::https://github.com/microsoftexpert/tf-mod-github-repository-ruleset?ref=v1.0.0"
  for_each = var.rulesets

  name       = "protect-default-branch"
  repository = each.value.repository

  conditions = { ref_name = { include = ["~DEFAULT_BRANCH"] } }

  rules = {
    non_fast_forward = true
    deletion         = true
    pull_request     = { required_approving_review_count = each.value.reviews }
    required_status_checks = length(each.value.checks) > 0 ? {
      required_check = [for c in each.value.checks : { context = c }]
    } : null
  }
}

⚠️ Bulk for_each over many repos can trip GitHub's secondary rate limits. See Troubleshooting.

1️⃣5️⃣ 🏗️ End-to-end composition (mandatory) — full suite wired outputs → inputs
# 1) The keystone repository
module "repository" {
  source               = "git::https://github.com/microsoftexpert/tf-mod-github-repository?ref=v1.0.0"
  name                 = "regulated-service"
  visibility           = "private"
  vulnerability_alerts = true
}

# 2) A team that owns the repo (and is a trusted bypass actor)
module "platform_team" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"
  name   = "platform-engineering"
}

# 3) THIS module — the enforced ruleset, wired entirely from sibling outputs
module "ruleset" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-repository-ruleset?ref=v1.0.0"

  name        = "regulated-default-branch"
  repository  = module.repository.id # <- from keystone
  enforcement = "active"

  conditions = { ref_name = { include = ["~DEFAULT_BRANCH"] } }

  rules = {
    deletion                = true
    non_fast_forward        = true
    required_linear_history = true
    required_signatures     = true
    pull_request = {
      required_approving_review_count = 2
      require_code_owner_review       = true
      dismiss_stale_reviews_on_push   = true
    }
    required_status_checks = {
      required_check = [{ context = "build" }, { context = "test" }]
    }
  }

  bypass_actors = [
    { actor_type = "Team", actor_id = module.platform_team.id, bypass_mode = "pull_request" }, # <- from team module
  ]
}

output "ruleset_id" {
  value = module.ruleset.id
}

💡 This is the canonical pattern: tf-mod-github-repository emits the repo name, tf-mod-github-team emits the Team id, and tf-mod-github-repository-ruleset composes both into a single enforced boundary.


📥 Inputs

Identity & target

  • name (string, required) — the ruleset name. Must be non-empty.
  • repository (string, required) — repository name (not numeric id, not owner/name). Wire from tf-mod-github-repository.id.
  • target (string, default "branch")branch | tag | push.
  • enforcement (string, default "active")active | evaluate | disabled. (evaluate is org-targets-only.)

Policy

  • rules (object, required) — the protections enforced. Scalar toggles plus block rules (PR review, status checks, merge queue, deployments, push file restrictions, Enterprise pattern / code-scanning / Copilot rules).

Targeting & exceptions

  • conditions (object, default null)ref_name.include / exclude. Required for branch/tag; must be null for push.
  • bypass_actors (list(object), default []) — who may bypass; least-privilege empty by default.
Full object schemas (rules / conditions / bypass_actors)
rules = object({
 creation = optional(bool)
 deletion = optional(bool)
 update = optional(bool)
 update_allows_fetch_and_merge = 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, pattern, name?, negate? }))
 commit_author_email_pattern = optional(object({ operator, pattern, name?, negate? }))
 commit_message_pattern = optional(object({ operator, pattern, name?, negate? }))
 committer_email_pattern = optional(object({ operator, pattern, name?, negate? }))
 tag_name_pattern = optional(object({ operator, pattern, name?, negate? }))

 copilot_code_review = optional(object({ review_on_push?, review_draft_pull_requests? }))
 required_deployments = optional(object({ required_deployment_environments = list(string) }))

 merge_queue = optional(object({
 check_response_timeout_minutes = optional(number, 60)
 grouping_strategy = optional(string, "ALLGREEN") # ALLGREEN | HEADGREEN
 max_entries_to_build = optional(number, 5)
 max_entries_to_merge = optional(number, 5)
 merge_method = optional(string, "MERGE") # MERGE | SQUASH | REBASE
 min_entries_to_merge = optional(number, 1)
 min_entries_to_merge_wait_minutes = optional(number, 5)
 }))

 pull_request = optional(object({
 allowed_merge_methods = optional(list(string)) # merge|squash|rebase
 dismiss_stale_reviews_on_push = optional(bool, true)
 require_code_owner_review = optional(bool, true)
 require_last_push_approval = optional(bool)
 required_approving_review_count = optional(number, 1)
 required_review_thread_resolution = optional(bool)
 required_reviewers = optional(list(object({
 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)
 do_not_enforce_on_create = optional(bool)
 required_check = optional(list(object({
 context = string
 integration_id = optional(number)
 })), [])
 }))

 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 = object({
 ref_name = optional(object({
 include = optional(list(string), []) # ref patterns / ~ALL / ~DEFAULT_BRANCH
 exclude = optional(list(string), [])
 }))
})

bypass_actors = list(object({
 actor_id = optional(number) # Team / App / RepositoryRole id (omit for id-less types)
 actor_type = string # RepositoryRole | Team | Integration | OrganizationAdmin | DeployKey
 bypass_mode = optional(string, "always") # always | pull_request | exempt
}))

🧾 Outputs

Output Description
id Ruleset node ID — the primary cross-module reference.
ruleset_id Numeric ruleset ID — for GitHub REST API automation.
node_id GraphQL global node ID — for GraphQL automation.
etag Resource etag — for change detection.
name Ruleset name (echo of input).
repository Repository the ruleset applies to (echo of input).

ℹ️ No arn, no slug, no full_name — repository rulesets do not expose them. None of these outputs are sensitive.


🧠 Architecture Notes

  • id vs ruleset_id vs node_id. id is the provider's resource id (the GraphQL node id), the primary reference. ruleset_id is the numeric id GitHub's REST API uses (/repos/{owner}/{repo}/rulesets/{id}). node_id is the GraphQL global node id. Rulesets have no slug or full_name — don't expect them.
  • Rulesets vs branch protection. Prefer this module over tf-mod-github-branch-protection for all new work. Branch protection is the legacy v3 API; rulesets are the current model, are layerable (org + repo rulesets both apply, most-restrictive wins), and support tags and push targets that branch protection cannot.
  • enforcement semantics. Defaults to active — rulesets actually enforce by default. evaluate (dry-run reporting) is supported only for organization-owned targets; for a repo ruleset use active or disabled.
  • ForceNew / immutability. repository and target are effectively the resource's identity — changing them forces a destroy/recreate of the ruleset. Plan accordingly when renaming repos.
  • bypass_actors.actor_id is type-dependent. The number means different things per actor_type: a Team id for Team, a GitHub App/Integration id for Integration, a role id for RepositoryRole (maintain 2, write 4, admin 5), 1 for OrganizationAdmin. DeployKey carries no id — omit actor_id.
  • Authoritative resource. github_repository_ruleset is authoritative for the ruleset it names — it fully owns that named ruleset's contents, but it does not clobber other rulesets on the same repo. Org rulesets remain independent.
  • No sensitive inputs. Unlike Actions/Dependabot secret modules, a ruleset carries no secret values — nothing here is marked sensitive.
  • Eventual consistency & secondary rate limits. The GitHub REST API is eventually consistent and enforces secondary rate limits on bursts. Large for_each rollouts of rulesets can intermittently 403/429 mid-apply; re-running terraform apply converges.

🧱 Design Principles

  • 🔒 Enforced by defaultenforcement = "active", not evaluate/disabled.
  • Secure PR defaultsdismiss_stale_reviews_on_push = true, require_code_owner_review = true, required_approving_review_count = 1, strict status-check policy on.
  • 🚫 Force-push & deletion blocked on protected branches via non_fast_forward / deletion.
  • 🧬 Deeply-typed schema — no any, no loose maps; every enum and closed value set has a validation {} block.
  • 🪪 Least-privilege bypassbypass_actors defaults to empty; exceptions are explicit and validated.
  • 🧩 Provider-owned auth — no owner / token / app_auth variables; the caller's provider block configures identity and org.

🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check
terraform plan
terraform apply
terraform output

⚠️ Always pin the module source to a tag?ref=v1.0.0, never a branch. A moving branch ref turns an unrelated upstream change into an unreviewed change to your protected branches.


🧪 Testing

The offline proof gate (no live org needed):

terraform fmt -check # zero formatting differences
terraform validate # zero errors
tflint # core rules — no dedicated GitHub ruleset exists

ℹ️ plan/apply require live GitHub credentials (PAT, GitHub CLI token, or GitHub App) configured in the provider, not the module.


💬 Example Output

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

id = "RRS_lACq_examplenodeid"
name = "protect-default-branch"
node_id = "RRS_lACq_examplenodeid"
repository = "payments-api"
ruleset_id = 1234567
etag = "W/\"abc123...\""

🔍 Troubleshooting

Symptom Cause Resolution
403: Resource not accessible by integration on apply Token/App lacks Administration: write Grant repo (classic) or fine-grained Administration r/w + Metadata r.
enforcement "evaluate" not supported evaluate used on a repo ruleset Use active or disabled; evaluate is org-targets-only.
Validation error: push target with conditions conditions.ref_name set while target = "push" Leave conditions = null for push rulesets.
Pattern / code-scanning / Copilot rule fails on apply Org is not GitHub Enterprise (+ Advanced Security) Remove the Enterprise-only rule or move the repo to an Enterprise org.
422 on a status-check context context name doesn't match any reported check Use the exact check name CI reports; check casing.
Intermittent 429 / secondary rate limit during bulk for_each Too many ruleset writes in a burst Re-run apply (converges); reduce parallelism with -parallelism=2.
Bypass actor has no effect Wrong actor_id for the actor_type Verify the id type: Team id vs App id vs Role id vs 1 for OrganizationAdmin.

🔗 Related Docs

  • tf-mod-github-repository — the keystone that emits the repository name.
  • tf-mod-github-team — emits Team ids used as bypass actors.
  • tf-mod-github-organization-ruleset — the org-scope sibling (most-restrictive-wins layering).
  • tf-mod-github-branch-protection — the legacy v3 alternative.
  • integrations/github provider — github_repository_ruleset resource reference.
  • GitHub Docs — "About rulesets" and "Managing rulesets for a repository."

Packages

 
 
 

Contributors

Languages