From 9be91f6c304c03db74506beea0caf010918c3d3e Mon Sep 17 00:00:00 2001 From: Joachim Hill-Grannec Date: Sat, 15 Aug 2026 21:24:04 -0600 Subject: [PATCH 1/8] feat!: generic organization_rulesets interface Replace baseline_ruleset and require_signed_commits with a generic organization_rulesets map driving a single for_each ruleset resource. --- modules/organization/main.tf | 95 ++++++++----------- .../tests/organization.tftest.hcl | 40 ++++++++ modules/organization/variables.tf | 71 ++++++++++---- 3 files changed, 131 insertions(+), 75 deletions(-) create mode 100644 modules/organization/tests/organization.tftest.hcl diff --git a/modules/organization/main.tf b/modules/organization/main.tf index 535f9ef..172c1d8 100644 --- a/modules/organization/main.tf +++ b/modules/organization/main.tf @@ -12,76 +12,59 @@ resource "github_membership" "internal" { } # -# Baseline branch-protection ruleset: applied to the default branch of every -# repository. Organization admins may bypass. Tunable via var.baseline_ruleset. +# Generic organization rulesets: one for_each resource driven by the +# organization_rulesets map. Presets (Task 2) populate local.all_rulesets on +# top of var.organization_rulesets; for now they are the same thing. # -resource "github_organization_ruleset" "baseline" { - count = var.baseline_ruleset.enabled ? 1 : 0 +locals { + all_rulesets = var.organization_rulesets +} + +resource "github_organization_ruleset" "internal" { + for_each = local.all_rulesets - name = "Baseline" - enforcement = "active" - target = "branch" + name = each.key + enforcement = each.value.enforcement + target = each.value.target conditions { ref_name { - exclude = [] - include = ["~DEFAULT_BRANCH"] + include = each.value.include_refs + exclude = each.value.exclude_refs } - repository_name { - exclude = [] - include = ["~ALL"] + include = each.value.include_repositories + exclude = each.value.exclude_repositories } } - bypass_actors { - actor_id = 0 - actor_type = "OrganizationAdmin" - bypass_mode = "always" - } - - rules { - deletion = var.baseline_ruleset.block_deletion - non_fast_forward = var.baseline_ruleset.block_force_pushes - - pull_request { - dismiss_stale_reviews_on_push = var.baseline_ruleset.dismiss_stale_reviews_on_push - require_code_owner_review = var.baseline_ruleset.require_code_owner_review - require_last_push_approval = var.baseline_ruleset.require_last_push_approval - required_approving_review_count = var.baseline_ruleset.required_approving_review_count - required_review_thread_resolution = var.baseline_ruleset.required_review_thread_resolution - } - } -} - -# -# Signed commits required on all branches of every repository by default. A -# repository opts out by being listed in signed_commits_excluded_repositories. -# Separate from Baseline so opting out does not weaken the other rules. No -# bypass_actors deliberately: org admins are bound too. -# - -resource "github_organization_ruleset" "signed_commits" { - count = var.require_signed_commits ? 1 : 0 - - name = "Signed Commits" - enforcement = "active" - target = "branch" - - conditions { - ref_name { - exclude = [] - include = ["~ALL"] - } - - repository_name { - include = ["~ALL"] - exclude = var.signed_commits_excluded_repositories + dynamic "bypass_actors" { + for_each = each.value.bypass_actors + content { + actor_id = bypass_actors.value.actor_type == "OrganizationAdmin" ? 1 : bypass_actors.value.actor_id + actor_type = bypass_actors.value.actor_type + bypass_mode = bypass_actors.value.bypass_mode } } rules { - required_signatures = true + creation = each.value.rules.creation + update = each.value.rules.update + deletion = each.value.rules.deletion + non_fast_forward = each.value.rules.non_fast_forward + required_signatures = each.value.rules.required_signatures + required_linear_history = each.value.rules.required_linear_history + + dynamic "pull_request" { + for_each = each.value.rules.pull_request != null ? [each.value.rules.pull_request] : [] + content { + required_approving_review_count = pull_request.value.required_approving_review_count + require_code_owner_review = pull_request.value.require_code_owner_review + require_last_push_approval = pull_request.value.require_last_push_approval + dismiss_stale_reviews_on_push = pull_request.value.dismiss_stale_reviews_on_push + required_review_thread_resolution = pull_request.value.required_review_thread_resolution + } + } } } diff --git a/modules/organization/tests/organization.tftest.hcl b/modules/organization/tests/organization.tftest.hcl new file mode 100644 index 0000000..f915476 --- /dev/null +++ b/modules/organization/tests/organization.tftest.hcl @@ -0,0 +1,40 @@ +mock_provider "github" { + mock_data "github_organization_roles" { + defaults = { roles = [{ name = "Reader", role_id = 1, source = "Predefined" }] } + } +} + +run "generic_ruleset_passes_through" { + command = plan + + variables { + create_all_members_team = false + organization_rulesets = { + "No Force Push" = { + include_refs = ["~DEFAULT_BRANCH"] + bypass_actors = [{ actor_type = "OrganizationAdmin" }] + rules = { non_fast_forward = true } + } + } + } + + assert { + condition = github_organization_ruleset.internal["No Force Push"].rules[0].non_fast_forward + error_message = "A generic ruleset must pass its rules through to github_organization_ruleset." + } + assert { + condition = one(github_organization_ruleset.internal["No Force Push"].bypass_actors).actor_id == 1 + error_message = "OrganizationAdmin bypass must coerce actor_id to 1." + } +} + +run "rejects_bad_enforcement" { + command = plan + variables { + create_all_members_team = false + organization_rulesets = { + bad = { enforcement = "sometimes", rules = {} } + } + } + expect_failures = [var.organization_rulesets] +} diff --git a/modules/organization/variables.tf b/modules/organization/variables.tf index 1a1f793..56c651f 100644 --- a/modules/organization/variables.tf +++ b/modules/organization/variables.tf @@ -14,29 +14,62 @@ variable "owners" { default = [] } -variable "baseline_ruleset" { +variable "organization_rulesets" { description = <<-EOT - Baseline org-wide branch-protection ruleset applied to the default branch of - every repository. Set `enabled = false` to disable it entirely. Defaults - reproduce a require-review + code-owner-review + thread-resolution policy. + Organization rulesets, keyed by name. Merged with (and overridden by name by) + the presets selected via enabled_presets. bypass_actors uses raw numeric + actor_id (OrganizationAdmin needs none); team-name bypass is per-repo only. EOT - type = object({ - enabled = optional(bool, true) - block_deletion = optional(bool, true) - block_force_pushes = optional(bool, false) - required_approving_review_count = optional(number, 1) - require_code_owner_review = optional(bool, true) - require_last_push_approval = optional(bool, false) - dismiss_stale_reviews_on_push = optional(bool, false) - required_review_thread_resolution = optional(bool, true) - }) + type = map(object({ + enforcement = optional(string, "active") + target = optional(string, "branch") + include_refs = optional(list(string), ["~ALL"]) + exclude_refs = optional(list(string), []) + include_repositories = optional(list(string), ["~ALL"]) + exclude_repositories = optional(list(string), []) + bypass_actors = optional(list(object({ + actor_type = string + actor_id = optional(number, 0) + bypass_mode = optional(string, "always") + })), []) + rules = object({ + creation = optional(bool, false) + update = optional(bool, false) + deletion = optional(bool, false) + non_fast_forward = optional(bool, false) + required_signatures = optional(bool, false) + required_linear_history = optional(bool, false) + pull_request = optional(object({ + required_approving_review_count = optional(number, 0) + require_code_owner_review = optional(bool, false) + require_last_push_approval = optional(bool, false) + dismiss_stale_reviews_on_push = optional(bool, false) + required_review_thread_resolution = optional(bool, false) + })) + }) + })) default = {} -} -variable "require_signed_commits" { - description = "Require signed commits on all branches of all repositories (except those excluded)." - type = bool - default = true + validation { + condition = alltrue([for r in values(var.organization_rulesets) : + contains(["active", "evaluate", "disabled"], r.enforcement)]) + error_message = "Ruleset enforcement must be one of: active, evaluate, disabled." + } + validation { + condition = alltrue([for r in values(var.organization_rulesets) : + contains(["branch", "tag"], r.target)]) + error_message = "Ruleset target must be one of: branch, tag." + } + validation { + condition = alltrue(flatten([for r in values(var.organization_rulesets) : + [for b in r.bypass_actors : contains(["OrganizationAdmin", "RepositoryRole", "Team", "Integration", "DeployKey"], b.actor_type)]])) + error_message = "bypass_actors.actor_type must be one of: OrganizationAdmin, RepositoryRole, Team, Integration, DeployKey." + } + validation { + condition = alltrue(flatten([for r in values(var.organization_rulesets) : + [for b in r.bypass_actors : contains(["always", "pull_request"], b.bypass_mode)]])) + error_message = "bypass_actors.bypass_mode must be one of: always, pull_request." + } } variable "signed_commits_excluded_repositories" { From 02ca5ccae151c4f5a5194ef05c75463c4e8dc57e Mon Sep 17 00:00:00 2001 From: Joachim Hill-Grannec Date: Sat, 15 Aug 2026 21:30:01 -0600 Subject: [PATCH 2/8] test: remove teams-module cruft from org ruleset test --- modules/organization/tests/organization.tftest.hcl | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/modules/organization/tests/organization.tftest.hcl b/modules/organization/tests/organization.tftest.hcl index f915476..539fd7c 100644 --- a/modules/organization/tests/organization.tftest.hcl +++ b/modules/organization/tests/organization.tftest.hcl @@ -1,14 +1,9 @@ -mock_provider "github" { - mock_data "github_organization_roles" { - defaults = { roles = [{ name = "Reader", role_id = 1, source = "Predefined" }] } - } -} +mock_provider "github" {} run "generic_ruleset_passes_through" { command = plan variables { - create_all_members_team = false organization_rulesets = { "No Force Push" = { include_refs = ["~DEFAULT_BRANCH"] @@ -31,7 +26,6 @@ run "generic_ruleset_passes_through" { run "rejects_bad_enforcement" { command = plan variables { - create_all_members_team = false organization_rulesets = { bad = { enforcement = "sometimes", rules = {} } } From bc2522a491225b9e5cb1b1a7a128511f554d30db Mon Sep 17 00:00:00 2001 From: Joachim Hill-Grannec Date: Sat, 15 Aug 2026 21:33:44 -0600 Subject: [PATCH 3/8] feat: ruleset presets via enabled_presets Curated presets (require_pull_request_reviews, restrict_deletions, require_signed_commits, block_force_pushes, require_linear_history). Default enabled set reproduces prior baseline + signed-commits behavior. --- modules/organization/main.tf | 63 +++++++++++++++++-- .../tests/organization.tftest.hcl | 50 +++++++++++++++ modules/organization/variables.tf | 14 +++++ 3 files changed, 123 insertions(+), 4 deletions(-) diff --git a/modules/organization/main.tf b/modules/organization/main.tf index 172c1d8..4f421be 100644 --- a/modules/organization/main.tf +++ b/modules/organization/main.tf @@ -12,13 +12,68 @@ resource "github_membership" "internal" { } # -# Generic organization rulesets: one for_each resource driven by the -# organization_rulesets map. Presets (Task 2) populate local.all_rulesets on -# top of var.organization_rulesets; for now they are the same thing. +# Generic organization rulesets: one for_each resource driven by the merged +# preset + organization_rulesets map. Presets are curated, opt-in defaults +# selected via var.enabled_presets; entries in var.organization_rulesets with +# the same key override the preset of that name. # locals { - all_rulesets = var.organization_rulesets + # Each preset is a full ruleset spec (same shape as var.organization_rulesets + # entries). rules objects specify every field so preset and user maps share a + # type when merged. + _empty_rules = { + creation = false, update = false, deletion = false, non_fast_forward = false, + required_signatures = false, required_linear_history = false, pull_request = null + } + _admin_bypass = [{ actor_type = "OrganizationAdmin", actor_id = 0, bypass_mode = "always" }] + + presets = { + require_pull_request_reviews = { + enforcement = "active", target = "branch" + include_refs = ["~DEFAULT_BRANCH"], exclude_refs = [] + include_repositories = ["~ALL"], exclude_repositories = [] + bypass_actors = local._admin_bypass + rules = merge(local._empty_rules, { pull_request = { + required_approving_review_count = 1 + require_code_owner_review = true + require_last_push_approval = false + dismiss_stale_reviews_on_push = false + required_review_thread_resolution = true + } }) + } + restrict_deletions = { + enforcement = "active", target = "branch" + include_refs = ["~DEFAULT_BRANCH"], exclude_refs = [] + include_repositories = ["~ALL"], exclude_repositories = [] + bypass_actors = local._admin_bypass + rules = merge(local._empty_rules, { deletion = true }) + } + require_signed_commits = { + enforcement = "active", target = "branch" + include_refs = ["~ALL"], exclude_refs = [] + include_repositories = ["~ALL"], exclude_repositories = var.signed_commits_excluded_repositories + bypass_actors = [] + rules = merge(local._empty_rules, { required_signatures = true }) + } + block_force_pushes = { + enforcement = "active", target = "branch" + include_refs = ["~DEFAULT_BRANCH"], exclude_refs = [] + include_repositories = ["~ALL"], exclude_repositories = [] + bypass_actors = local._admin_bypass + rules = merge(local._empty_rules, { non_fast_forward = true }) + } + require_linear_history = { + enforcement = "active", target = "branch" + include_refs = ["~DEFAULT_BRANCH"], exclude_refs = [] + include_repositories = ["~ALL"], exclude_repositories = [] + bypass_actors = local._admin_bypass + rules = merge(local._empty_rules, { required_linear_history = true }) + } + } + + selected_presets = { for p in var.enabled_presets : p => local.presets[p] } + all_rulesets = merge(local.selected_presets, var.organization_rulesets) } resource "github_organization_ruleset" "internal" { diff --git a/modules/organization/tests/organization.tftest.hcl b/modules/organization/tests/organization.tftest.hcl index 539fd7c..4fb663d 100644 --- a/modules/organization/tests/organization.tftest.hcl +++ b/modules/organization/tests/organization.tftest.hcl @@ -32,3 +32,53 @@ run "rejects_bad_enforcement" { } expect_failures = [var.organization_rulesets] } + +run "default_presets_reproduce_prior_behavior" { + command = plan + + assert { + condition = github_organization_ruleset.internal["require_signed_commits"].rules[0].required_signatures + error_message = "require_signed_commits preset must enable required_signatures." + } + assert { + condition = github_organization_ruleset.internal["require_pull_request_reviews"].rules[0].pull_request[0].required_approving_review_count == 1 + error_message = "require_pull_request_reviews preset must require one approval." + } + assert { + condition = github_organization_ruleset.internal["restrict_deletions"].rules[0].deletion + error_message = "restrict_deletions preset must enable deletion protection." + } + assert { + condition = length(github_organization_ruleset.internal["require_signed_commits"].bypass_actors) == 0 + error_message = "require_signed_commits must have no bypass actors." + } +} + +run "signed_commits_exclusion_feeds_preset" { + command = plan + variables { + signed_commits_excluded_repositories = ["legacy-repo"] + } + assert { + condition = contains(github_organization_ruleset.internal["require_signed_commits"].conditions[0].repository_name[0].exclude, "legacy-repo") + error_message = "signed_commits_excluded_repositories must feed the require_signed_commits preset's exclusions." + } +} + +run "custom_ruleset_overrides_preset_by_name" { + command = plan + variables { + enabled_presets = ["require_signed_commits"] + organization_rulesets = { + require_signed_commits = { + include_refs = ["~ALL"] + bypass_actors = [{ actor_type = "OrganizationAdmin" }] + rules = { required_signatures = true } + } + } + } + assert { + condition = one(github_organization_ruleset.internal["require_signed_commits"].bypass_actors).actor_id == 1 + error_message = "A custom organization_rulesets entry must override the preset of the same name." + } +} diff --git a/modules/organization/variables.tf b/modules/organization/variables.tf index 56c651f..e4c0a73 100644 --- a/modules/organization/variables.tf +++ b/modules/organization/variables.tf @@ -77,3 +77,17 @@ variable "signed_commits_excluded_repositories" { type = list(string) default = [] } + +variable "enabled_presets" { + description = "Names of built-in ruleset presets to enable. See modules/organization/main.tf local.presets." + type = list(string) + default = ["require_pull_request_reviews", "restrict_deletions", "require_signed_commits"] + + validation { + condition = alltrue([for p in var.enabled_presets : contains([ + "require_pull_request_reviews", "restrict_deletions", "require_signed_commits", + "block_force_pushes", "require_linear_history", + ], p)]) + error_message = "enabled_presets entries must be known preset names." + } +} From c18606f43505258ededd72e51c78cf318e22df83 Mon Sep 17 00:00:00 2001 From: Joachim Hill-Grannec Date: Sat, 15 Aug 2026 21:40:02 -0600 Subject: [PATCH 4/8] feat: per-repo github_repository_ruleset with team-name bypass --- modules/repository/main.tf | 64 +++++++++++++++++++ .../repository/tests/repository.tftest.hcl | 39 +++++++++++ modules/repository/variables.tf | 40 ++++++++++++ 3 files changed, 143 insertions(+) diff --git a/modules/repository/main.tf b/modules/repository/main.tf index 2ba5d3e..07da275 100644 --- a/modules/repository/main.tf +++ b/modules/repository/main.tf @@ -95,6 +95,70 @@ resource "github_branch_default" "internal" { branch = each.value } +# +# Per-repository rulesets, with team-name bypass resolution via var.teams. +# + +locals { + # ":" => { repository, name, config } + repository_rulesets = merge([ + for r in var.repositories : { + for name, cfg in r.rulesets : + "${r.name}:${name}" => { repository = r.name, name = name, config = cfg } + } + ]...) +} + +resource "github_repository_ruleset" "internal" { + for_each = local.repository_rulesets + + name = each.value.name + repository = github_repository.internal[each.value.repository].name + enforcement = each.value.config.enforcement + target = each.value.config.target + + conditions { + ref_name { + include = each.value.config.include_refs + exclude = each.value.config.exclude_refs + } + } + + dynamic "bypass_actors" { + for_each = each.value.config.bypass_actors + content { + actor_id = ( + bypass_actors.value.actor_type == "OrganizationAdmin" ? 1 : + bypass_actors.value.actor_type == "Team" && bypass_actors.value.team != null ? + tonumber(var.teams[bypass_actors.value.team].id) : + bypass_actors.value.actor_id + ) + actor_type = bypass_actors.value.actor_type + bypass_mode = bypass_actors.value.bypass_mode + } + } + + rules { + creation = each.value.config.rules.creation + update = each.value.config.rules.update + deletion = each.value.config.rules.deletion + non_fast_forward = each.value.config.rules.non_fast_forward + required_signatures = each.value.config.rules.required_signatures + required_linear_history = each.value.config.rules.required_linear_history + + dynamic "pull_request" { + for_each = each.value.config.rules.pull_request != null ? [each.value.config.rules.pull_request] : [] + content { + required_approving_review_count = pull_request.value.required_approving_review_count + require_code_owner_review = pull_request.value.require_code_owner_review + require_last_push_approval = pull_request.value.require_last_push_approval + dismiss_stale_reviews_on_push = pull_request.value.dismiss_stale_reviews_on_push + required_review_thread_resolution = pull_request.value.required_review_thread_resolution + } + } + } +} + # # One team-repository grant per repository-team pair. A team listed at more than # one level gets the highest permission (merge order: admins > writers > readers). diff --git a/modules/repository/tests/repository.tftest.hcl b/modules/repository/tests/repository.tftest.hcl index 9b22ac3..a581482 100644 --- a/modules/repository/tests/repository.tftest.hcl +++ b/modules/repository/tests/repository.tftest.hcl @@ -219,3 +219,42 @@ run "rejects_disabling_all_merge_methods" { expect_failures = [var.repositories] } + +run "per_repo_ruleset_with_team_bypass" { + command = plan + variables { + repositories = [{ + name = "app" + description = "" + rulesets = { + "Protect main" = { + include_refs = ["~DEFAULT_BRANCH"] + bypass_actors = [{ actor_type = "Team", team = "platform" }] + rules = { non_fast_forward = true, pull_request = { required_approving_review_count = 2 } } + } + } + }] + } + assert { + condition = github_repository_ruleset.internal["app:Protect main"].rules[0].pull_request[0].required_approving_review_count == 2 + error_message = "Per-repo ruleset rules must pass through." + } + assert { + condition = one(github_repository_ruleset.internal["app:Protect main"].bypass_actors).actor_id == 1 + error_message = "Team bypass 'platform' must resolve to team id 1." + } +} + +run "rejects_ruleset_bypass_unknown_team" { + command = plan + variables { + repositories = [{ + name = "app" + description = "" + rulesets = { + r = { bypass_actors = [{ actor_type = "Team", team = "ghost" }], rules = {} } + } + }] + } + expect_failures = [var.repositories] +} diff --git a/modules/repository/variables.tf b/modules/repository/variables.tf index f70558f..1291ac2 100644 --- a/modules/repository/variables.tf +++ b/modules/repository/variables.tf @@ -43,6 +43,34 @@ variable "repositories" { include_all_branches = optional(bool, false) })) + rulesets = optional(map(object({ + enforcement = optional(string, "active") + target = optional(string, "branch") + include_refs = optional(list(string), ["~ALL"]) + exclude_refs = optional(list(string), []) + bypass_actors = optional(list(object({ + actor_type = string + actor_id = optional(number, 0) + team = optional(string) + bypass_mode = optional(string, "always") + })), []) + rules = object({ + creation = optional(bool, false) + update = optional(bool, false) + deletion = optional(bool, false) + non_fast_forward = optional(bool, false) + required_signatures = optional(bool, false) + required_linear_history = optional(bool, false) + pull_request = optional(object({ + required_approving_review_count = optional(number, 0) + require_code_owner_review = optional(bool, false) + require_last_push_approval = optional(bool, false) + dismiss_stale_reviews_on_push = optional(bool, false) + required_review_thread_resolution = optional(bool, false) + })) + }) + })), {}) + variables = optional(map(string), {}) environments = optional(map(object({ variables = optional(map(string), {}) @@ -109,6 +137,18 @@ variable "repositories" { ]) error_message = "At least one merge method (allow_merge_commit, allow_squash_merge, allow_rebase_merge) must be enabled on: ${join(", ", [for r in var.repositories : r.name if !(r.allow_merge_commit || r.allow_squash_merge || r.allow_rebase_merge)])}." } + + validation { + condition = alltrue(flatten([ + for r in var.repositories : [ + for rs in values(r.rulesets) : [ + for b in rs.bypass_actors : + b.actor_type != "Team" || b.team == null || contains(keys(var.teams), b.team) + ] + ] + ])) + error_message = "Ruleset bypass_actors reference a team not present in var.teams." + } } variable "teams" { From 23ab58b965b3b7c0e304620e8601dc2208d590ea Mon Sep 17 00:00:00 2001 From: Joachim Hill-Grannec Date: Sat, 15 Aug 2026 21:47:23 -0600 Subject: [PATCH 5/8] feat!: wire generic rulesets at root and migrate example BREAKING CHANGE: baseline_ruleset and require_signed_commits are removed. Use enabled_presets (defaults reproduce prior behavior) and organization_rulesets; per-repo rulesets via repositories[].rulesets. --- README.md | 6 +- docs/DESIGN.md | 20 +++--- examples/complete/main.tf | 20 +++++- main.tf | 4 +- modules/organization/README.md | 7 +-- modules/repository/README.md | 3 +- variables.tf | 111 ++++++++++++++++++++++++++++----- 7 files changed, 135 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 2cccc6b..ac0304b 100644 --- a/README.md +++ b/README.md @@ -106,14 +106,14 @@ root). | Name | Description | Type | Default | Required | | ---- | ----------- | ---- | ------- | :------: | | [all\_members\_team\_name](#input\_all\_members\_team\_name) | Name of the all-members team. | `string` | `"everyone"` | no | -| [baseline\_ruleset](#input\_baseline\_ruleset) | Baseline org-wide branch-protection ruleset. See the organization submodule for the object schema; set enabled = false to disable. |
object({
enabled = optional(bool, true)
block_deletion = optional(bool, true)
block_force_pushes = optional(bool, false)
required_approving_review_count = optional(number, 1)
require_code_owner_review = optional(bool, true)
require_last_push_approval = optional(bool, false)
dismiss_stale_reviews_on_push = optional(bool, false)
required_review_thread_resolution = optional(bool, true)
})
| `{}` | no | | [create\_all\_members\_team](#input\_create\_all\_members\_team) | Create an all-members team that grants default read access to non-restricted repositories. | `bool` | `true` | no | +| [enabled\_presets](#input\_enabled\_presets) | Names of built-in ruleset presets to enable. See modules/organization/main.tf local.presets. | `list(string)` |
[
"require_pull_request_reviews",
"restrict_deletions",
"require_signed_commits"
]
| no | +| [organization\_rulesets](#input\_organization\_rulesets) | Organization rulesets, keyed by name. Merged with (and overridden by name by)
the presets selected via enabled\_presets. bypass\_actors uses raw numeric
actor\_id (OrganizationAdmin needs none); team-name bypass is per-repo only. |
map(object({
enforcement = optional(string, "active")
target = optional(string, "branch")
include_refs = optional(list(string), ["~ALL"])
exclude_refs = optional(list(string), [])
include_repositories = optional(list(string), ["~ALL"])
exclude_repositories = optional(list(string), [])
bypass_actors = optional(list(object({
actor_type = string
actor_id = optional(number, 0)
bypass_mode = optional(string, "always")
})), [])
rules = object({
creation = optional(bool, false)
update = optional(bool, false)
deletion = optional(bool, false)
non_fast_forward = optional(bool, false)
required_signatures = optional(bool, false)
required_linear_history = optional(bool, false)
pull_request = optional(object({
required_approving_review_count = optional(number, 0)
require_code_owner_review = optional(bool, false)
require_last_push_approval = optional(bool, false)
dismiss_stale_reviews_on_push = optional(bool, false)
required_review_thread_resolution = optional(bool, false)
}))
})
}))
| `{}` | no | | [organization\_secrets](#input\_organization\_secrets) | Visibility config for org-level shared secrets. Values come from var.secrets.org. |
map(object({
visibility = optional(string, "private")
repositories = optional(list(string), [])
}))
| `{}` | no | | [organization\_variables](#input\_organization\_variables) | Org-level shared Actions variables (plaintext value + visibility). |
map(object({
value = string
visibility = optional(string, "private")
repositories = optional(list(string), [])
}))
| `{}` | no | | [owners](#input\_owners) | Explicit set of owner usernames (mapped to GitHub's `admin` role). Ignored when `owners_team` is set. | `set(string)` | `[]` | no | | [owners\_team](#input\_owners\_team) | Convenience: derive organization owners from the members of this team, by
name. `null` (default) means use the explicit `owners` set instead. There is
no hardcoded owners team — the consumer chooses. | `string` | `null` | no | -| [repositories](#input\_repositories) | Repositories and which teams may access them. See the repository submodule for the full object schema and validations. |
list(object({
name = string
description = string
visibility = optional(string, "private")
readers = optional(list(string), [])
writers = optional(list(string), [])
admins = optional(list(string), [])
restricted = optional(bool, false)

topics = optional(list(string), [])
homepage_url = optional(string, null)
has_issues = optional(bool, true)
has_wiki = optional(bool, false)
has_projects = optional(bool, false)
has_downloads = optional(bool, true)
is_template = optional(bool, false)
gitignore_template = optional(string, null)
license_template = optional(string, null)
archived = optional(bool, false)
archive_on_destroy = optional(bool, false)
default_branch = optional(string, null)
enable_pages = optional(bool, false)
signed_commits = optional(bool, true)

allow_merge_commit = optional(bool, true)
allow_squash_merge = optional(bool, false)
allow_rebase_merge = optional(bool, false)
allow_auto_merge = optional(bool, true)
delete_branch_on_merge = optional(bool, true)
merge_commit_message = optional(string, "PR_BODY")
merge_commit_title = optional(string, "PR_TITLE")

template = optional(object({
owner = string
repository = string
include_all_branches = optional(bool, false)
}))

variables = optional(map(string), {})
environments = optional(map(object({
variables = optional(map(string), {})
reviewers = optional(list(string), [])
wait_timer = optional(number, 0)
deployment_branch_policy = optional(object({
protected_branches = optional(bool, false)
custom_branch_policies = optional(list(string), [])
}))
})), {})
}))
| `[]` | no | -| [require\_signed\_commits](#input\_require\_signed\_commits) | Require signed commits on all branches of all repositories (except repositories with signed\_commits = false). | `bool` | `true` | no | +| [repositories](#input\_repositories) | Repositories and which teams may access them. See the repository submodule for the full object schema and validations. |
list(object({
name = string
description = string
visibility = optional(string, "private")
readers = optional(list(string), [])
writers = optional(list(string), [])
admins = optional(list(string), [])
restricted = optional(bool, false)

topics = optional(list(string), [])
homepage_url = optional(string, null)
has_issues = optional(bool, true)
has_wiki = optional(bool, false)
has_projects = optional(bool, false)
has_downloads = optional(bool, true)
is_template = optional(bool, false)
gitignore_template = optional(string, null)
license_template = optional(string, null)
archived = optional(bool, false)
archive_on_destroy = optional(bool, false)
default_branch = optional(string, null)
enable_pages = optional(bool, false)
signed_commits = optional(bool, true)

allow_merge_commit = optional(bool, true)
allow_squash_merge = optional(bool, false)
allow_rebase_merge = optional(bool, false)
allow_auto_merge = optional(bool, true)
delete_branch_on_merge = optional(bool, true)
merge_commit_message = optional(string, "PR_BODY")
merge_commit_title = optional(string, "PR_TITLE")

template = optional(object({
owner = string
repository = string
include_all_branches = optional(bool, false)
}))

rulesets = optional(map(object({
enforcement = optional(string, "active")
target = optional(string, "branch")
include_refs = optional(list(string), ["~ALL"])
exclude_refs = optional(list(string), [])
bypass_actors = optional(list(object({
actor_type = string
actor_id = optional(number, 0)
team = optional(string)
bypass_mode = optional(string, "always")
})), [])
rules = object({
creation = optional(bool, false)
update = optional(bool, false)
deletion = optional(bool, false)
non_fast_forward = optional(bool, false)
required_signatures = optional(bool, false)
required_linear_history = optional(bool, false)
pull_request = optional(object({
required_approving_review_count = optional(number, 0)
require_code_owner_review = optional(bool, false)
require_last_push_approval = optional(bool, false)
dismiss_stale_reviews_on_push = optional(bool, false)
required_review_thread_resolution = optional(bool, false)
}))
})
})), {})

variables = optional(map(string), {})
environments = optional(map(object({
variables = optional(map(string), {})
reviewers = optional(list(string), [])
wait_timer = optional(number, 0)
deployment_branch_policy = optional(object({
protected_branches = optional(bool, false)
custom_branch_policies = optional(list(string), [])
}))
})), {})
}))
| `[]` | no | | [secrets](#input\_secrets) | Decrypted secret values keyed by scope. Supplied already-decrypted by the caller; this module never performs decryption. |
object({
org = optional(map(string), {})
repos = optional(map(object({
actions = optional(map(string), {})
environments = optional(map(map(string)), {})
})), {})
})
|
{
"org": {},
"repos": {}
}
| no | | [teams](#input\_teams) | Teams and their membership. A team may hold predefined organization-level
GitHub roles via `org_roles`. Repository access is granted per repository via
readers/writers/admins, not through org roles. |
list(object({
name = string
description = string
members = optional(list(string), [])
org_roles = optional(list(string), [])
}))
| `[]` | no | | [users](#input\_users) | Organization members. `fullname`/`email` are informational; membership is keyed on `username`. |
list(object({
username = string
fullname = optional(string, "")
email = optional(string, "")
}))
| `[]` | no | diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 91962be..e2a3bbc 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -29,7 +29,7 @@ maintained suite that additionally covers ground none of them do: | Actions variables (repo + org) | ✅ | | Actions secrets (repo + org + environment) | ✅ | | Environments (reviewers, wait timers, branch policies) | ✅ | -| Organization rulesets (baseline + signed commits) | ✅ | +| Organization + per-repo rulesets (generic, preset-driven) | ✅ | | Organization roles assigned to teams | ✅ | ## Architecture @@ -41,15 +41,17 @@ modules/organization → modules/teams → modules/repository ``` - **`organization`** — org membership (owners mapped to `admin`, everyone else - `member`) and org rulesets (baseline branch protection + signed commits). Outputs - the membership map. + `member`) and generic organization rulesets: a `organization_rulesets` map + merged with curated, opt-in `enabled_presets` (PR reviews, deletion/force-push + protection, signed commits, linear history). Outputs the membership map. - **`teams`** — teams, team memberships, the optional all-members team, and org-role assignments (resolved by name against the org's predefined roles). Consumes the membership map + owner set; outputs a `name → {id, slug}` team map and the all-members team id. - **`repository`** — repositories, team↔repo grants, the default all-members read - grant, repository/environment Actions variables & secrets, and environments. - Consumes the team map; outputs a repository-id map. + grant, per-repo `rulesets` (same generic shape as the org rulesets, plus a + team-name bypass convenience), repository/environment Actions variables & + secrets, and environments. Consumes the team map; outputs a repository-id map. The **root** additionally manages organization-level Actions secrets and variables. These need both decrypted secret values and repository ids to resolve `selected` @@ -64,7 +66,9 @@ repository module (which would create a dependency cycle). convenience that derives owners from a named team. No owners team is assumed. - The **all-members team** is controlled by `create_all_members_team` / `all_members_team_name`. - - The **baseline ruleset** is a tunable object (and can be disabled). + - **Rulesets** are a generic `organization_rulesets` / per-repo `rulesets` map + input; curated presets are opt-in via `enabled_presets` rather than hardcoded + defaults baked into a single "baseline" object. - **Org roles** validate at plan time against the roles GitHub actually exposes, not a static allowlist. 2. **Secrets are never decrypted here.** The caller passes already-decrypted values @@ -76,7 +80,9 @@ repository module (which would create a dependency cycle). **Tier 1 — per-repo depth (`modules/repository`)** - Issue labels (`github_issue_label`), optional merge-with-github-defaults -- Per-repo `github_repository_ruleset` (composes with the org baseline ruleset) +- ~~Per-repo `github_repository_ruleset`~~ — done: `repositories[].rulesets` is a + generic map (same `rules`/`bypass_actors` shape as `organization_rulesets`, + plus a `team` bypass convenience), composing alongside org-level presets. - Expose currently-fixed repo settings: `topics`, `homepage_url`, merge-strategy toggles, `has_wiki`/`has_downloads`, `is_template`, template source, `gitignore`/`license_template`, `archived`/`archive_on_destroy`, `default_branch` diff --git a/examples/complete/main.tf b/examples/complete/main.tf index cc79cb0..2430780 100644 --- a/examples/complete/main.tf +++ b/examples/complete/main.tf @@ -50,6 +50,13 @@ module "orgkit" { writers = ["developers"] admins = ["owners"] variables = { NODE_ENV = "production" } + rulesets = { + "Protect main" = { + include_refs = ["~DEFAULT_BRANCH"] + bypass_actors = [{ actor_type = "Team", team = "owners" }] + rules = { required_linear_history = true } + } + } environments = { # protected_branches style: only protected branches may deploy. production = { @@ -122,8 +129,15 @@ module "orgkit" { } } - # The baseline ruleset is tunable; here we require two approvals. - baseline_ruleset = { - required_approving_review_count = 2 + # Enable a curated subset of built-in ruleset presets. + enabled_presets = ["require_pull_request_reviews", "restrict_deletions", "require_signed_commits", "block_force_pushes"] + + # A custom org ruleset alongside the presets. + organization_rulesets = { + "No Tag Deletes" = { + target = "tag" + bypass_actors = [{ actor_type = "OrganizationAdmin" }] + rules = { deletion = true } + } } } diff --git a/main.tf b/main.tf index ec3acb0..6202d64 100644 --- a/main.tf +++ b/main.tf @@ -24,8 +24,8 @@ module "organization" { members = local.usernames owners = local.owners - baseline_ruleset = var.baseline_ruleset - require_signed_commits = var.require_signed_commits + organization_rulesets = var.organization_rulesets + enabled_presets = var.enabled_presets signed_commits_excluded_repositories = local.signed_commits_excluded } diff --git a/modules/organization/README.md b/modules/organization/README.md index c199076..fbd0769 100644 --- a/modules/organization/README.md +++ b/modules/organization/README.md @@ -30,17 +30,16 @@ No modules. | Name | Type | | ---- | ---- | | [github_membership.internal](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/membership) | resource | -| [github_organization_ruleset.baseline](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/organization_ruleset) | resource | -| [github_organization_ruleset.signed_commits](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/organization_ruleset) | resource | +| [github_organization_ruleset.internal](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/organization_ruleset) | resource | ## Inputs | Name | Description | Type | Default | Required | | ---- | ----------- | ---- | ------- | :------: | -| [baseline\_ruleset](#input\_baseline\_ruleset) | Baseline org-wide branch-protection ruleset applied to the default branch of
every repository. Set `enabled = false` to disable it entirely. Defaults
reproduce a require-review + code-owner-review + thread-resolution policy. |
object({
enabled = optional(bool, true)
block_deletion = optional(bool, true)
block_force_pushes = optional(bool, false)
required_approving_review_count = optional(number, 1)
require_code_owner_review = optional(bool, true)
require_last_push_approval = optional(bool, false)
dismiss_stale_reviews_on_push = optional(bool, false)
required_review_thread_resolution = optional(bool, true)
})
| `{}` | no | +| [enabled\_presets](#input\_enabled\_presets) | Names of built-in ruleset presets to enable. See modules/organization/main.tf local.presets. | `list(string)` |
[
"require_pull_request_reviews",
"restrict_deletions",
"require_signed_commits"
]
| no | | [members](#input\_members) | Set of GitHub usernames that are members of the organization. | `set(string)` | `[]` | no | +| [organization\_rulesets](#input\_organization\_rulesets) | Organization rulesets, keyed by name. Merged with (and overridden by name by)
the presets selected via enabled\_presets. bypass\_actors uses raw numeric
actor\_id (OrganizationAdmin needs none); team-name bypass is per-repo only. |
map(object({
enforcement = optional(string, "active")
target = optional(string, "branch")
include_refs = optional(list(string), ["~ALL"])
exclude_refs = optional(list(string), [])
include_repositories = optional(list(string), ["~ALL"])
exclude_repositories = optional(list(string), [])
bypass_actors = optional(list(object({
actor_type = string
actor_id = optional(number, 0)
bypass_mode = optional(string, "always")
})), [])
rules = object({
creation = optional(bool, false)
update = optional(bool, false)
deletion = optional(bool, false)
non_fast_forward = optional(bool, false)
required_signatures = optional(bool, false)
required_linear_history = optional(bool, false)
pull_request = optional(object({
required_approving_review_count = optional(number, 0)
require_code_owner_review = optional(bool, false)
require_last_push_approval = optional(bool, false)
dismiss_stale_reviews_on_push = optional(bool, false)
required_review_thread_resolution = optional(bool, false)
}))
})
}))
| `{}` | no | | [owners](#input\_owners) | Subset of `members` that are organization owners (mapped to GitHub's `admin`
membership role); everyone else is a plain `member`. The consumer decides who
is an owner — there is no hardcoded owners team. | `set(string)` | `[]` | no | -| [require\_signed\_commits](#input\_require\_signed\_commits) | Require signed commits on all branches of all repositories (except those excluded). | `bool` | `true` | no | | [signed\_commits\_excluded\_repositories](#input\_signed\_commits\_excluded\_repositories) | Repository names excluded from the required-signed-commits ruleset. | `list(string)` | `[]` | no | ## Outputs diff --git a/modules/repository/README.md b/modules/repository/README.md index 7983b41..237f9d6 100644 --- a/modules/repository/README.md +++ b/modules/repository/README.md @@ -37,6 +37,7 @@ No modules. | [github_repository.internal](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/repository) | resource | | [github_repository_environment.internal](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/repository_environment) | resource | | [github_repository_environment_deployment_policy.internal](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/repository_environment_deployment_policy) | resource | +| [github_repository_ruleset.internal](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/repository_ruleset) | resource | | [github_repository_vulnerability_alerts.internal](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/repository_vulnerability_alerts) | resource | | [github_team_repository.everyone](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/team_repository) | resource | | [github_team_repository.internal](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/team_repository) | resource | @@ -46,7 +47,7 @@ No modules. | Name | Description | Type | Default | Required | | ---- | ----------- | ---- | ------- | :------: | | [all\_members\_team\_id](#input\_all\_members\_team\_id) | ID of the all-members team that receives default read access on non-restricted repositories. null disables the default read grant. | `string` | `null` | no | -| [repositories](#input\_repositories) | Repositories to manage and which teams may access them. `readers`, `writers`
and `admins` reference team names from `var.teams`. A team listed at more than
one level gets the highest permission (admin > write > read). Set
`restricted = true` to withhold the default all-members read grant. |
list(object({
name = string
description = string
visibility = optional(string, "private")
readers = optional(list(string), [])
writers = optional(list(string), [])
admins = optional(list(string), [])
restricted = optional(bool, false)

topics = optional(list(string), [])
homepage_url = optional(string, null)
has_issues = optional(bool, true)
has_wiki = optional(bool, false)
has_projects = optional(bool, false)
has_downloads = optional(bool, true)
is_template = optional(bool, false)
gitignore_template = optional(string, null)
license_template = optional(string, null)
archived = optional(bool, false)
archive_on_destroy = optional(bool, false)
default_branch = optional(string, null)
enable_pages = optional(bool, false)
signed_commits = optional(bool, true)

allow_merge_commit = optional(bool, true)
allow_squash_merge = optional(bool, false)
allow_rebase_merge = optional(bool, false)
allow_auto_merge = optional(bool, true)
delete_branch_on_merge = optional(bool, true)
merge_commit_message = optional(string, "PR_BODY")
merge_commit_title = optional(string, "PR_TITLE")

template = optional(object({
owner = string
repository = string
include_all_branches = optional(bool, false)
}))

variables = optional(map(string), {})
environments = optional(map(object({
variables = optional(map(string), {})
reviewers = optional(list(string), [])
wait_timer = optional(number, 0)
deployment_branch_policy = optional(object({
protected_branches = optional(bool, false)
custom_branch_policies = optional(list(string), [])
}))
})), {})
}))
| `[]` | no | +| [repositories](#input\_repositories) | Repositories to manage and which teams may access them. `readers`, `writers`
and `admins` reference team names from `var.teams`. A team listed at more than
one level gets the highest permission (admin > write > read). Set
`restricted = true` to withhold the default all-members read grant. |
list(object({
name = string
description = string
visibility = optional(string, "private")
readers = optional(list(string), [])
writers = optional(list(string), [])
admins = optional(list(string), [])
restricted = optional(bool, false)

topics = optional(list(string), [])
homepage_url = optional(string, null)
has_issues = optional(bool, true)
has_wiki = optional(bool, false)
has_projects = optional(bool, false)
has_downloads = optional(bool, true)
is_template = optional(bool, false)
gitignore_template = optional(string, null)
license_template = optional(string, null)
archived = optional(bool, false)
archive_on_destroy = optional(bool, false)
default_branch = optional(string, null)
enable_pages = optional(bool, false)
signed_commits = optional(bool, true)

allow_merge_commit = optional(bool, true)
allow_squash_merge = optional(bool, false)
allow_rebase_merge = optional(bool, false)
allow_auto_merge = optional(bool, true)
delete_branch_on_merge = optional(bool, true)
merge_commit_message = optional(string, "PR_BODY")
merge_commit_title = optional(string, "PR_TITLE")

template = optional(object({
owner = string
repository = string
include_all_branches = optional(bool, false)
}))

rulesets = optional(map(object({
enforcement = optional(string, "active")
target = optional(string, "branch")
include_refs = optional(list(string), ["~ALL"])
exclude_refs = optional(list(string), [])
bypass_actors = optional(list(object({
actor_type = string
actor_id = optional(number, 0)
team = optional(string)
bypass_mode = optional(string, "always")
})), [])
rules = object({
creation = optional(bool, false)
update = optional(bool, false)
deletion = optional(bool, false)
non_fast_forward = optional(bool, false)
required_signatures = optional(bool, false)
required_linear_history = optional(bool, false)
pull_request = optional(object({
required_approving_review_count = optional(number, 0)
require_code_owner_review = optional(bool, false)
require_last_push_approval = optional(bool, false)
dismiss_stale_reviews_on_push = optional(bool, false)
required_review_thread_resolution = optional(bool, false)
}))
})
})), {})

variables = optional(map(string), {})
environments = optional(map(object({
variables = optional(map(string), {})
reviewers = optional(list(string), [])
wait_timer = optional(number, 0)
deployment_branch_policy = optional(object({
protected_branches = optional(bool, false)
custom_branch_policies = optional(list(string), [])
}))
})), {})
}))
| `[]` | no | | [repository\_secrets](#input\_repository\_secrets) | Per-repository Actions and environment secret values (plaintext), keyed by
repository name. Supplied already-decrypted by the caller; this module never
performs decryption. |
map(object({
actions = optional(map(string), {})
environments = optional(map(map(string)), {})
}))
| `{}` | no | | [teams](#input\_teams) | Map of team name => { id, slug } for teams that may be granted repository access or set as environment reviewers. Supplied by the teams module. |
map(object({
id = string
slug = string
}))
| `{}` | no | diff --git a/variables.tf b/variables.tf index 567c088..1a00cf7 100644 --- a/variables.tf +++ b/variables.tf @@ -92,6 +92,34 @@ variable "repositories" { include_all_branches = optional(bool, false) })) + rulesets = optional(map(object({ + enforcement = optional(string, "active") + target = optional(string, "branch") + include_refs = optional(list(string), ["~ALL"]) + exclude_refs = optional(list(string), []) + bypass_actors = optional(list(object({ + actor_type = string + actor_id = optional(number, 0) + team = optional(string) + bypass_mode = optional(string, "always") + })), []) + rules = object({ + creation = optional(bool, false) + update = optional(bool, false) + deletion = optional(bool, false) + non_fast_forward = optional(bool, false) + required_signatures = optional(bool, false) + required_linear_history = optional(bool, false) + pull_request = optional(object({ + required_approving_review_count = optional(number, 0) + require_code_owner_review = optional(bool, false) + require_last_push_approval = optional(bool, false) + dismiss_stale_reviews_on_push = optional(bool, false) + required_review_thread_resolution = optional(bool, false) + })) + }) + })), {}) + variables = optional(map(string), {}) environments = optional(map(object({ variables = optional(map(string), {}) @@ -148,25 +176,76 @@ variable "organization_variables" { } } -variable "baseline_ruleset" { - description = "Baseline org-wide branch-protection ruleset. See the organization submodule for the object schema; set enabled = false to disable." - type = object({ - enabled = optional(bool, true) - block_deletion = optional(bool, true) - block_force_pushes = optional(bool, false) - required_approving_review_count = optional(number, 1) - require_code_owner_review = optional(bool, true) - require_last_push_approval = optional(bool, false) - dismiss_stale_reviews_on_push = optional(bool, false) - required_review_thread_resolution = optional(bool, true) - }) +variable "organization_rulesets" { + description = <<-EOT + Organization rulesets, keyed by name. Merged with (and overridden by name by) + the presets selected via enabled_presets. bypass_actors uses raw numeric + actor_id (OrganizationAdmin needs none); team-name bypass is per-repo only. + EOT + type = map(object({ + enforcement = optional(string, "active") + target = optional(string, "branch") + include_refs = optional(list(string), ["~ALL"]) + exclude_refs = optional(list(string), []) + include_repositories = optional(list(string), ["~ALL"]) + exclude_repositories = optional(list(string), []) + bypass_actors = optional(list(object({ + actor_type = string + actor_id = optional(number, 0) + bypass_mode = optional(string, "always") + })), []) + rules = object({ + creation = optional(bool, false) + update = optional(bool, false) + deletion = optional(bool, false) + non_fast_forward = optional(bool, false) + required_signatures = optional(bool, false) + required_linear_history = optional(bool, false) + pull_request = optional(object({ + required_approving_review_count = optional(number, 0) + require_code_owner_review = optional(bool, false) + require_last_push_approval = optional(bool, false) + dismiss_stale_reviews_on_push = optional(bool, false) + required_review_thread_resolution = optional(bool, false) + })) + }) + })) default = {} + + validation { + condition = alltrue([for r in values(var.organization_rulesets) : + contains(["active", "evaluate", "disabled"], r.enforcement)]) + error_message = "Ruleset enforcement must be one of: active, evaluate, disabled." + } + validation { + condition = alltrue([for r in values(var.organization_rulesets) : + contains(["branch", "tag"], r.target)]) + error_message = "Ruleset target must be one of: branch, tag." + } + validation { + condition = alltrue(flatten([for r in values(var.organization_rulesets) : + [for b in r.bypass_actors : contains(["OrganizationAdmin", "RepositoryRole", "Team", "Integration", "DeployKey"], b.actor_type)]])) + error_message = "bypass_actors.actor_type must be one of: OrganizationAdmin, RepositoryRole, Team, Integration, DeployKey." + } + validation { + condition = alltrue(flatten([for r in values(var.organization_rulesets) : + [for b in r.bypass_actors : contains(["always", "pull_request"], b.bypass_mode)]])) + error_message = "bypass_actors.bypass_mode must be one of: always, pull_request." + } } -variable "require_signed_commits" { - description = "Require signed commits on all branches of all repositories (except repositories with signed_commits = false)." - type = bool - default = true +variable "enabled_presets" { + description = "Names of built-in ruleset presets to enable. See modules/organization/main.tf local.presets." + type = list(string) + default = ["require_pull_request_reviews", "restrict_deletions", "require_signed_commits"] + + validation { + condition = alltrue([for p in var.enabled_presets : contains([ + "require_pull_request_reviews", "restrict_deletions", "require_signed_commits", + "block_force_pushes", "require_linear_history", + ], p)]) + error_message = "enabled_presets entries must be known preset names." + } } variable "create_all_members_team" { From e2cc4cade6004329a13a66aa4ba2fd638286064f Mon Sep 17 00:00:00 2001 From: Joachim Hill-Grannec Date: Sat, 15 Aug 2026 21:56:43 -0600 Subject: [PATCH 6/8] feat: validate per-repo ruleset enforcement/target/bypass to match org module Adds enforcement, target, bypass actor_type/bypass_mode, and Team-bypass completeness validations to modules/repository's repositories.rulesets, mirroring modules/organization's organization_rulesets validations. Also documents the dead actor_id=0 on the org module's admin bypass preset, and adds a negative test for the new enforcement validation. --- modules/organization/main.tf | 2 +- .../repository/tests/repository.tftest.hcl | 14 +++++ modules/repository/variables.tf | 56 +++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/modules/organization/main.tf b/modules/organization/main.tf index 4f421be..910ebf5 100644 --- a/modules/organization/main.tf +++ b/modules/organization/main.tf @@ -26,7 +26,7 @@ locals { creation = false, update = false, deletion = false, non_fast_forward = false, required_signatures = false, required_linear_history = false, pull_request = null } - _admin_bypass = [{ actor_type = "OrganizationAdmin", actor_id = 0, bypass_mode = "always" }] + _admin_bypass = [{ actor_type = "OrganizationAdmin", actor_id = 0, bypass_mode = "always" }] # actor_id is coerced to 1 for OrganizationAdmin in the resource presets = { require_pull_request_reviews = { diff --git a/modules/repository/tests/repository.tftest.hcl b/modules/repository/tests/repository.tftest.hcl index a581482..60f7207 100644 --- a/modules/repository/tests/repository.tftest.hcl +++ b/modules/repository/tests/repository.tftest.hcl @@ -258,3 +258,17 @@ run "rejects_ruleset_bypass_unknown_team" { } expect_failures = [var.repositories] } + +run "rejects_invalid_ruleset_enforcement" { + command = plan + variables { + repositories = [{ + name = "app" + description = "" + rulesets = { + r = { enforcement = "sometimes", rules = {} } + } + }] + } + expect_failures = [var.repositories] +} diff --git a/modules/repository/variables.tf b/modules/repository/variables.tf index 1291ac2..d54f304 100644 --- a/modules/repository/variables.tf +++ b/modules/repository/variables.tf @@ -149,6 +149,62 @@ variable "repositories" { ])) error_message = "Ruleset bypass_actors reference a team not present in var.teams." } + + validation { + condition = alltrue(flatten([ + for r in var.repositories : [ + for rs in values(r.rulesets) : + contains(["active", "evaluate", "disabled"], rs.enforcement) + ] + ])) + error_message = "Ruleset enforcement must be one of: active, evaluate, disabled." + } + + validation { + condition = alltrue(flatten([ + for r in var.repositories : [ + for rs in values(r.rulesets) : + contains(["branch", "tag"], rs.target) + ] + ])) + error_message = "Ruleset target must be one of: branch, tag." + } + + validation { + condition = alltrue(flatten([ + for r in var.repositories : [ + for rs in values(r.rulesets) : [ + for b in rs.bypass_actors : + contains(["OrganizationAdmin", "RepositoryRole", "Team", "Integration", "DeployKey"], b.actor_type) + ] + ] + ])) + error_message = "Ruleset bypass_actors.actor_type must be one of: OrganizationAdmin, RepositoryRole, Team, Integration, DeployKey." + } + + validation { + condition = alltrue(flatten([ + for r in var.repositories : [ + for rs in values(r.rulesets) : [ + for b in rs.bypass_actors : + contains(["always", "pull_request"], b.bypass_mode) + ] + ] + ])) + error_message = "Ruleset bypass_actors.bypass_mode must be one of: always, pull_request." + } + + validation { + condition = alltrue(flatten([ + for r in var.repositories : [ + for rs in values(r.rulesets) : [ + for b in rs.bypass_actors : + b.actor_type != "Team" || b.team != null || b.actor_id != 0 + ] + ] + ])) + error_message = "Ruleset bypass_actors with actor_type = \"Team\" must set either team (a team name) or a non-default actor_id (a raw team id) — not neither." + } } variable "teams" { From 9be3617d01389b1c4109e32ef65988624776a9f6 Mon Sep 17 00:00:00 2001 From: Joachim Hill-Grannec Date: Sat, 15 Aug 2026 22:03:02 -0600 Subject: [PATCH 7/8] docs: point README design link to docs/DESIGN.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ac0304b..12cd6eb 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ organization and Actions axes: (who is an owner, the all-members team, ruleset tuning) is an input, not a baked-in constant. -See [`docs/PLAN.md`](docs/PLAN.md) for the design rationale, a comparison with the +See [`docs/DESIGN.md`](docs/DESIGN.md) for the design rationale, a comparison with the existing ecosystem modules, and the feature roadmap. ## Architecture From 164070bee761dccd2c36f461d58f019491e8b706 Mon Sep 17 00:00:00 2001 From: Joachim Hill-Grannec Date: Sat, 15 Aug 2026 22:13:25 -0600 Subject: [PATCH 8/8] style: rename ruleset preset locals to snake_case for tflint --- modules/organization/main.tf | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/modules/organization/main.tf b/modules/organization/main.tf index 910ebf5..d3bf78a 100644 --- a/modules/organization/main.tf +++ b/modules/organization/main.tf @@ -22,19 +22,19 @@ locals { # Each preset is a full ruleset spec (same shape as var.organization_rulesets # entries). rules objects specify every field so preset and user maps share a # type when merged. - _empty_rules = { + empty_rules = { creation = false, update = false, deletion = false, non_fast_forward = false, required_signatures = false, required_linear_history = false, pull_request = null } - _admin_bypass = [{ actor_type = "OrganizationAdmin", actor_id = 0, bypass_mode = "always" }] # actor_id is coerced to 1 for OrganizationAdmin in the resource + admin_bypass = [{ actor_type = "OrganizationAdmin", actor_id = 0, bypass_mode = "always" }] # actor_id is coerced to 1 for OrganizationAdmin in the resource presets = { require_pull_request_reviews = { enforcement = "active", target = "branch" include_refs = ["~DEFAULT_BRANCH"], exclude_refs = [] include_repositories = ["~ALL"], exclude_repositories = [] - bypass_actors = local._admin_bypass - rules = merge(local._empty_rules, { pull_request = { + bypass_actors = local.admin_bypass + rules = merge(local.empty_rules, { pull_request = { required_approving_review_count = 1 require_code_owner_review = true require_last_push_approval = false @@ -46,29 +46,29 @@ locals { enforcement = "active", target = "branch" include_refs = ["~DEFAULT_BRANCH"], exclude_refs = [] include_repositories = ["~ALL"], exclude_repositories = [] - bypass_actors = local._admin_bypass - rules = merge(local._empty_rules, { deletion = true }) + bypass_actors = local.admin_bypass + rules = merge(local.empty_rules, { deletion = true }) } require_signed_commits = { enforcement = "active", target = "branch" include_refs = ["~ALL"], exclude_refs = [] include_repositories = ["~ALL"], exclude_repositories = var.signed_commits_excluded_repositories bypass_actors = [] - rules = merge(local._empty_rules, { required_signatures = true }) + rules = merge(local.empty_rules, { required_signatures = true }) } block_force_pushes = { enforcement = "active", target = "branch" include_refs = ["~DEFAULT_BRANCH"], exclude_refs = [] include_repositories = ["~ALL"], exclude_repositories = [] - bypass_actors = local._admin_bypass - rules = merge(local._empty_rules, { non_fast_forward = true }) + bypass_actors = local.admin_bypass + rules = merge(local.empty_rules, { non_fast_forward = true }) } require_linear_history = { enforcement = "active", target = "branch" include_refs = ["~DEFAULT_BRANCH"], exclude_refs = [] include_repositories = ["~ALL"], exclude_repositories = [] - bypass_actors = local._admin_bypass - rules = merge(local._empty_rules, { required_linear_history = true }) + bypass_actors = local.admin_bypass + rules = merge(local.empty_rules, { required_linear_history = true }) } }