diff --git a/README.md b/README.md
index 2cccc6b..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
@@ -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/organization/main.tf b/modules/organization/main.tf
index 535f9ef..d3bf78a 100644
--- a/modules/organization/main.tf
+++ b/modules/organization/main.tf
@@ -12,76 +12,114 @@ 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 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.
#
-resource "github_organization_ruleset" "baseline" {
- count = var.baseline_ruleset.enabled ? 1 : 0
-
- name = "Baseline"
- enforcement = "active"
- target = "branch"
-
- conditions {
- ref_name {
- exclude = []
- include = ["~DEFAULT_BRANCH"]
+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 = {
+ 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
+
+ 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
+ } })
}
-
- repository_name {
- exclude = []
- include = ["~ALL"]
+ 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 })
}
- }
-
- 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
+ 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 })
}
}
-}
-#
-# 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.
-#
+ 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" "signed_commits" {
- count = var.require_signed_commits ? 1 : 0
+resource "github_organization_ruleset" "internal" {
+ for_each = local.all_rulesets
- name = "Signed Commits"
- enforcement = "active"
- target = "branch"
+ name = each.key
+ enforcement = each.value.enforcement
+ target = each.value.target
conditions {
ref_name {
- exclude = []
- include = ["~ALL"]
+ include = each.value.include_refs
+ exclude = each.value.exclude_refs
}
-
repository_name {
- include = ["~ALL"]
- exclude = var.signed_commits_excluded_repositories
+ include = each.value.include_repositories
+ exclude = each.value.exclude_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..4fb663d
--- /dev/null
+++ b/modules/organization/tests/organization.tftest.hcl
@@ -0,0 +1,84 @@
+mock_provider "github" {}
+
+run "generic_ruleset_passes_through" {
+ command = plan
+
+ variables {
+ 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 {
+ organization_rulesets = {
+ bad = { enforcement = "sometimes", rules = {} }
+ }
+ }
+ 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 1a1f793..e4c0a73 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" {
@@ -44,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."
+ }
+}
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/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..60f7207 100644
--- a/modules/repository/tests/repository.tftest.hcl
+++ b/modules/repository/tests/repository.tftest.hcl
@@ -219,3 +219,56 @@ 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]
+}
+
+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 f70558f..d54f304 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,74 @@ 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."
+ }
+
+ 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" {
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" {