diff --git a/README.md b/README.md new file mode 100644 index 0000000..48cd478 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# jackin-github-terraform + +Manages GitHub repository settings and branch/tag protection for the +`jackin-project` organization: 9 repositories, each with repository merge +settings plus `protect-main` and `protect-tags` rulesets. + +## Layout + +- `variables.tf` — `repository_policies` inventory (single source of truth). +- `locals.tf` — `check_templates` copy-paste source for check lists. +- `repositories.tf` — `module.repository_policy` wiring. +- `modules/repository-policy/` — shared policy module (identical across orgs). +- `checks.tf` — invariant guards (no scripts). +- `imports.tf` — bootstrap import triples (one per repo: settings + 2 rulesets). +- `providers.tf`, `versions.tf`, `outputs.tf` — provider, pins, outputs. + +## Adding a repository + +1. Add one entry to `repository_policies` in `variables.tf`, copying a check + list from `locals.tf` (`standard` = `["Required", "DCO"]`, + `policy` = `["Policy"]`); bespoke checks inline with a comment citing the + producing workflow. Repos with no CI use `required_checks = []` plus a + non-empty `no_ci_reason` and extend the allowlist check in `checks.tf`. +2. Add one import triple (settings + `protect_main` + `protect_tags`) to + `imports.tf` using the live numeric ruleset IDs. + +## Verification + +```sh +tofu fmt -check -recursive +tofu init -backend=false -input=false -lockfile=readonly +tofu validate -no-color +``` diff --git a/checks.tf b/checks.tf index 0a2037a..73ce701 100644 --- a/checks.tf +++ b/checks.tf @@ -5,24 +5,40 @@ check "mandatory_target_repositories_present" { assert { - condition = length(setsubtract([ - "homebrew-tap", - "jackin", - "jackin-agent-smith", - "jackin-dev", - "jackin-github-terraform", - "jackin-role-action", - "jackin-sentinel", - "jackin-the-architect", - ], keys(var.repository_policies))) == 0 - error_message = "One or more mandatory jackin-project target repositories are missing from repository_policies." + condition = toset(keys(var.repository_policies)) == toset(["homebrew-tap", "jackin", "jackin-agent-smith", "jackin-dev", "jackin-github-terraform", "jackin-marketplace", "jackin-role-action", "jackin-sentinel", "jackin-the-architect"]) + error_message = "repository_policies must contain exactly the 9 managed jackin-project repositories (8 standard plus jackin-marketplace)." + } +} + +check "all_managed_repositories_have_checks_or_reason" { + assert { + condition = alltrue([ + for name, policy in var.repository_policies : + ( + length(policy.required_checks) > 0 + && alltrue([for c in policy.required_checks : trimspace(c) != ""]) + && policy.no_ci_reason == null + ) || ( + length(policy.required_checks) == 0 + && policy.no_ci_reason != null + && trimspace(policy.no_ci_reason) != "" + ) + ]) + error_message = "Each entry must satisfy: non-empty blank-free required_checks with null no_ci_reason, XOR empty checks with non-empty no_ci_reason." + } +} + +check "no_ci_exception_allowlist_is_explicit" { + assert { + condition = length([for name, policy in var.repository_policies : name if policy.no_ci_reason != null]) == 0 + error_message = "No jackin-project repository carries a no_ci_reason exception; every entry must declare required checks." } } check "self_protection_enforced" { assert { - condition = lookup(var.repository_policies, "jackin-github-terraform", null) != null && var.repository_policies["jackin-github-terraform"].disposition == "FullRuleset" - error_message = "jackin-github-terraform must protect itself with FullRuleset disposition." + condition = contains(keys(var.repository_policies), "jackin-github-terraform") + error_message = "jackin-github-terraform must remain in the managed repository inventory; every inventory entry receives both rulesets." } } @@ -37,6 +53,16 @@ check "jackin_terraform_required_checks_match_ci" { } } +check "update_branch_suggestions_disabled" { + assert { + condition = alltrue([ + for name, repo in module.repository_policy.repository_settings : + repo.allow_update_branch == false + ]) + error_message = "All managed repositories must disable GitHub update-branch suggestions." + } +} + check "delete_branch_on_merge_mandate" { assert { condition = alltrue([ @@ -60,3 +86,25 @@ check "squash_only_merge_mandate" { error_message = "All managed repositories must enforce squash-only merge policy with PR_TITLE/PR_BODY." } } + +check "thread_resolution_and_active_enforcement" { + assert { + condition = alltrue([ + for name, gate in module.repository_policy.ruleset_enforcement : + gate.enforcement == "active" && + gate.tags_enforcement == "active" && + gate.required_review_thread_resolution == true + ]) + error_message = "All protect-main/protect-tags rulesets must enforce active with required review-thread resolution." + } +} + +check "zero_bypass_actors" { + assert { + condition = alltrue([ + for name, gate in module.repository_policy.ruleset_enforcement : + length(gate.bypass_actors) == 0 + ]) + error_message = "No repository may declare bypass actors on its protect-main ruleset." + } +} diff --git a/imports.tf b/imports.tf new file mode 100644 index 0000000..49bf96b --- /dev/null +++ b/imports.tf @@ -0,0 +1,137 @@ +# Adopt the existing GitHub resources into the default local state before managing them. +# These stable IDs prevent a fresh state from planning duplicate repositories or rulesets. + +import { + to = module.repository_policy.github_repository.managed_settings["homebrew-tap"] + id = "homebrew-tap" +} + +import { + to = module.repository_policy.github_repository_ruleset.protect_main["homebrew-tap"] + id = "homebrew-tap:15170998" +} + +import { + to = module.repository_policy.github_repository_ruleset.protect_tags["homebrew-tap"] + id = "homebrew-tap:15179223" +} + +import { + to = module.repository_policy.github_repository.managed_settings["jackin"] + id = "jackin" +} + +import { + to = module.repository_policy.github_repository_ruleset.protect_main["jackin"] + id = "jackin:14746904" +} + +import { + to = module.repository_policy.github_repository_ruleset.protect_tags["jackin"] + id = "jackin:15179226" +} + +import { + to = module.repository_policy.github_repository.managed_settings["jackin-agent-smith"] + id = "jackin-agent-smith" +} + +import { + to = module.repository_policy.github_repository_ruleset.protect_main["jackin-agent-smith"] + id = "jackin-agent-smith:14742580" +} + +import { + to = module.repository_policy.github_repository_ruleset.protect_tags["jackin-agent-smith"] + id = "jackin-agent-smith:15179228" +} + +import { + to = module.repository_policy.github_repository.managed_settings["jackin-dev"] + id = "jackin-dev" +} + +import { + to = module.repository_policy.github_repository_ruleset.protect_main["jackin-dev"] + id = "jackin-dev:14742570" +} + +import { + to = module.repository_policy.github_repository_ruleset.protect_tags["jackin-dev"] + id = "jackin-dev:15179232" +} + +import { + to = module.repository_policy.github_repository.managed_settings["jackin-github-terraform"] + id = "jackin-github-terraform" +} + +import { + to = module.repository_policy.github_repository_ruleset.protect_main["jackin-github-terraform"] + id = "jackin-github-terraform:15175310" +} + +import { + to = module.repository_policy.github_repository_ruleset.protect_tags["jackin-github-terraform"] + id = "jackin-github-terraform:15179227" +} + +import { + to = module.repository_policy.github_repository.managed_settings["jackin-marketplace"] + id = "jackin-marketplace" +} + +import { + to = module.repository_policy.github_repository_ruleset.protect_main["jackin-marketplace"] + id = "jackin-marketplace:14742574" +} + +import { + to = module.repository_policy.github_repository_ruleset.protect_tags["jackin-marketplace"] + id = "jackin-marketplace:15179224" +} + +import { + to = module.repository_policy.github_repository.managed_settings["jackin-role-action"] + id = "jackin-role-action" +} + +import { + to = module.repository_policy.github_repository_ruleset.protect_main["jackin-role-action"] + id = "jackin-role-action:15170996" +} + +import { + to = module.repository_policy.github_repository_ruleset.protect_tags["jackin-role-action"] + id = "jackin-role-action:16515394" +} + +import { + to = module.repository_policy.github_repository.managed_settings["jackin-sentinel"] + id = "jackin-sentinel" +} + +import { + to = module.repository_policy.github_repository_ruleset.protect_main["jackin-sentinel"] + id = "jackin-sentinel:17461058" +} + +import { + to = module.repository_policy.github_repository_ruleset.protect_tags["jackin-sentinel"] + id = "jackin-sentinel:17461059" +} + +import { + to = module.repository_policy.github_repository.managed_settings["jackin-the-architect"] + id = "jackin-the-architect" +} + +import { + to = module.repository_policy.github_repository_ruleset.protect_main["jackin-the-architect"] + id = "jackin-the-architect:14742576" +} + +import { + to = module.repository_policy.github_repository_ruleset.protect_tags["jackin-the-architect"] + id = "jackin-the-architect:15179230" +} diff --git a/locals.tf b/locals.tf new file mode 100644 index 0000000..10d2a19 --- /dev/null +++ b/locals.tf @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2026 Alexey Zhokhov +# SPDX-License-Identifier: Apache-2.0 + +# Canonical check-list templates. Terraform requires variable defaults to be +# literals, so inventory entries in variables.tf repeat these lists verbatim; +# keep both in sync. Templates are the documented copy-paste source for adding +# a repo (see README.md). +locals { + check_templates = { + standard = ["Required", "DCO"] + policy = ["Policy"] + } +} diff --git a/migrations.tf b/migrations.tf deleted file mode 100644 index 13676a0..0000000 --- a/migrations.tf +++ /dev/null @@ -1,18 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Alexey Zhokhov -# SPDX-License-Identifier: Apache-2.0 - -# Module refactoring: migrate root-level resources into module.repository_policy -moved { - from = github_repository.managed_settings - to = module.repository_policy.github_repository.managed_settings -} - -moved { - from = github_repository_ruleset.protect_main - to = module.repository_policy.github_repository_ruleset.protect_main -} - -moved { - from = github_repository_ruleset.protect_tags - to = module.repository_policy.github_repository_ruleset.protect_tags -} diff --git a/modules/repository-policy/main.tf b/modules/repository-policy/main.tf index 74db1db..fd8aa2d 100644 --- a/modules/repository-policy/main.tf +++ b/modules/repository-policy/main.tf @@ -1,31 +1,27 @@ +# SPDX-FileCopyrightText: 2026 Alexey Zhokhov +# SPDX-License-Identifier: Apache-2.0 + terraform { required_version = ">= 1.5" required_providers { github = { source = "integrations/github" - version = "~> 6.0" + version = "~> 6.13" } } } locals { - managed_settings_repositories = toset([ - for repo, policy in var.repository_policies : repo - if contains(["FullRuleset", "RepoSettingsOnly"], policy.disposition) - ]) - - full_ruleset_repositories = toset([ - for repo, policy in var.repository_policies : repo - if policy.disposition == "FullRuleset" - ]) + managed_repositories = toset(keys(var.repository_policies)) } # Repository-level merge settings (Tier 1 enforcement across all repos) resource "github_repository" "managed_settings" { - for_each = local.managed_settings_repositories + for_each = local.managed_repositories - name = each.value + name = each.value + visibility = var.repository_policies[each.value].visibility # Strict squash-only merge policy allow_squash_merge = true @@ -49,7 +45,6 @@ resource "github_repository" "managed_settings" { has_projects, has_wiki, has_discussions, - visibility, archived, topics, vulnerability_alerts, @@ -63,12 +58,12 @@ resource "github_repository" "managed_settings" { # Branch ruleset on main / default branch (Tier 2 ref protection) resource "github_repository_ruleset" "protect_main" { - for_each = local.full_ruleset_repositories + for_each = local.managed_repositories name = "protect-main" target = "branch" enforcement = "active" - repository = each.value + repository = github_repository.managed_settings[each.value].name conditions { ref_name { @@ -113,12 +108,12 @@ resource "github_repository_ruleset" "protect_main" { # Tag ruleset protecting all tags resource "github_repository_ruleset" "protect_tags" { - for_each = local.full_ruleset_repositories + for_each = local.managed_repositories name = "protect-tags" target = "tag" enforcement = "active" - repository = each.value + repository = github_repository.managed_settings[each.value].name conditions { ref_name { diff --git a/modules/repository-policy/outputs.tf b/modules/repository-policy/outputs.tf index beb94d3..ca5b382 100644 --- a/modules/repository-policy/outputs.tf +++ b/modules/repository-policy/outputs.tf @@ -1,11 +1,9 @@ -output "managed_settings_repositories" { - description = "Set of repository names with managed repository-level merge settings" - value = local.managed_settings_repositories -} +# SPDX-FileCopyrightText: 2026 Alexey Zhokhov +# SPDX-License-Identifier: Apache-2.0 -output "full_ruleset_repositories" { - description = "Set of repository names with active branch and tag rulesets" - value = local.full_ruleset_repositories +output "managed_repositories" { + description = "Set of managed repository names (inventory keys)" + value = local.managed_repositories } output "repository_settings" { @@ -19,6 +17,33 @@ output "repository_settings" { squash_merge_commit_message = v.squash_merge_commit_message allow_update_branch = v.allow_update_branch delete_branch_on_merge = v.delete_branch_on_merge + visibility = v.visibility + } + } +} + +output "required_check_contexts" { + description = "Map of repository name to its required status-check contexts" + value = { + for k, v in var.repository_policies : k => v.required_checks + } +} + +output "repository_ids" { + description = "Map of repository name to repository ID" + value = { + for k, v in github_repository.managed_settings : k => v.repo_id + } +} + +output "ruleset_enforcement" { + description = "Map of protect-main ruleset enforcement gates per repository" + value = { + for k, v in github_repository_ruleset.protect_main : k => { + enforcement = v.enforcement + tags_enforcement = github_repository_ruleset.protect_tags[k].enforcement + required_review_thread_resolution = v.rules[0].pull_request[0].required_review_thread_resolution + bypass_actors = v.bypass_actors } } } diff --git a/modules/repository-policy/variables.tf b/modules/repository-policy/variables.tf index 2899ee8..7dd4042 100644 --- a/modules/repository-policy/variables.tf +++ b/modules/repository-policy/variables.tf @@ -1,24 +1,35 @@ +# SPDX-FileCopyrightText: 2026 Alexey Zhokhov +# SPDX-License-Identifier: Apache-2.0 + variable "repository_policies" { description = "Map of repository names to their protection policy configurations" type = map(object({ - disposition = string # "FullRuleset" | "RepoSettingsOnly" visibility = string # "public" | "private" required_checks = list(string) + no_ci_reason = optional(string) })) validation { condition = alltrue([ for name, config in var.repository_policies : - contains(["FullRuleset", "RepoSettingsOnly"], config.disposition) + contains(["public", "private"], config.visibility) ]) - error_message = "disposition must be either 'FullRuleset' or 'RepoSettingsOnly'." + error_message = "visibility must be either 'public' or 'private'." } validation { condition = alltrue([ for name, config in var.repository_policies : - contains(["public", "private"], config.visibility) + ( + length(config.required_checks) > 0 + && alltrue([for c in config.required_checks : trimspace(c) != ""]) + && config.no_ci_reason == null + ) || ( + length(config.required_checks) == 0 + && config.no_ci_reason != null + && trimspace(config.no_ci_reason) != "" + ) ]) - error_message = "visibility must be either 'public' or 'private'." + error_message = "Each entry must satisfy: non-empty blank-free required_checks with null no_ci_reason, XOR empty checks with non-empty no_ci_reason." } } diff --git a/outputs.tf b/outputs.tf new file mode 100644 index 0000000..15b6a23 --- /dev/null +++ b/outputs.tf @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: 2026 Alexey Zhokhov +# SPDX-License-Identifier: Apache-2.0 + +output "managed_repositories" { + description = "Set of managed repository names (inventory keys)" + value = module.repository_policy.managed_repositories +} + +output "repository_settings" { + description = "Map of repository merge and lifecycle settings" + value = module.repository_policy.repository_settings +} + +output "required_check_contexts" { + description = "Map of repository name to its required status-check contexts" + value = module.repository_policy.required_check_contexts +} + +output "repository_ids" { + description = "Map of repository name to repository ID" + value = module.repository_policy.repository_ids +} diff --git a/provider.tf b/providers.tf similarity index 52% rename from provider.tf rename to providers.tf index 5ade5ba..776805c 100644 --- a/provider.tf +++ b/providers.tf @@ -2,5 +2,6 @@ # SPDX-License-Identifier: Apache-2.0 provider "github" { - owner = "jackin-project" + owner = var.github_organization + token = var.github_token != null ? var.github_token : null } diff --git a/branch-protection.tf b/repositories.tf similarity index 100% rename from branch-protection.tf rename to repositories.tf diff --git a/tests/verify-policy.sh b/tests/verify-policy.sh deleted file mode 100755 index ef3ac27..0000000 --- a/tests/verify-policy.sh +++ /dev/null @@ -1,157 +0,0 @@ -#!/usr/bin/env bash -# SPDX-FileCopyrightText: 2026 Alexey Zhokhov -# SPDX-License-Identifier: Apache-2.0 -set -euo pipefail - -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$REPO_ROOT" - -echo "=== 1. Checking OpenTofu formatting ===" -tofu fmt -check -recursive - -echo "=== 2. Validating OpenTofu configuration ===" -tofu validate - -echo "=== 3. Verifying mandatory target inventory coverage ===" -MANDATORY_TARGETS=( - "homebrew-tap" - "jackin" - "jackin-agent-smith" - "jackin-dev" - "jackin-github-terraform" - "jackin-role-action" - "jackin-sentinel" - "jackin-the-architect" -) - -python3 - << 'EOF' -import re, sys - -with open("variables.tf") as f: - content = f.read() - -mandatory = [ - "homebrew-tap", - "jackin", - "jackin-agent-smith", - "jackin-dev", - "jackin-github-terraform", - "jackin-role-action", - "jackin-sentinel", - "jackin-the-architect", -] - -missing = [] -for repo in mandatory: - pattern = rf'"{re.escape(repo)}"\s*=\s*\{{' - if not re.search(pattern, content): - missing.append(repo) - -if missing: - print(f"FAILED: Missing mandatory repositories in variables.tf: {missing}") - sys.exit(1) - -print(f"SUCCESS: All {len(mandatory)} mandatory jackin-project target repositories are declared.") -EOF - -echo "=== 4. Verifying canonical policy invariants in module ===" -python3 - << 'EOF' -import re, sys - -with open("modules/repository-policy/main.tf") as f: - mod = f.read() - -def check_pattern(pattern, desc): - if not re.search(pattern, mod): - print(f"FAILED invariant: {desc}") - sys.exit(1) - -check_pattern(r'allow_squash_merge\s*=\s*true', "allow_squash_merge = true") -check_pattern(r'allow_merge_commit\s*=\s*false', "allow_merge_commit = false") -check_pattern(r'allow_rebase_merge\s*=\s*false', "allow_rebase_merge = false") -check_pattern(r'squash_merge_commit_title\s*=\s*"PR_TITLE"', "squash_merge_commit_title = PR_TITLE") -check_pattern(r'squash_merge_commit_message\s*=\s*"PR_BODY"', "squash_merge_commit_message = PR_BODY") -update_branch_values = re.findall(r'^\s*allow_update_branch\s*=\s*(true|false)\s*$', mod, re.M) -if update_branch_values != ["false"]: - print(f"FAILED invariant: expected one allow_update_branch = false setting; found {update_branch_values}") - sys.exit(1) -check_pattern(r'delete_branch_on_merge\s*=\s*true', "delete_branch_on_merge = true") -check_pattern(r'allowed_merge_methods\s*=\s*\[\s*"squash"\s*\]', "allowed_merge_methods = ['squash']") -check_pattern(r'required_linear_history\s*=\s*true', "required_linear_history = true") -check_pattern(r'deletion\s*=\s*true', "deletion = true") -check_pattern(r'non_fast_forward\s*=\s*true', "non_fast_forward = true") -check_pattern(r'required_review_thread_resolution\s*=\s*true', "required_review_thread_resolution = true") -strict_freshness_values = re.findall(r'^\s*strict_required_status_checks_policy\s*=\s*(true|false)\s*$', mod, re.M) -if strict_freshness_values != ["false"]: - print(f"FAILED invariant: expected one strict_required_status_checks_policy = false setting; found {strict_freshness_values}") - sys.exit(1) - -assert 'bypass_actors' not in mod, "No bypass_actors allowed on core protection" - -print("SUCCESS: All canonical policy invariants verified in modules/repository-policy/main.tf.") -EOF - -echo "=== 5. Verifying configured required CI contexts ===" -python3 - << 'EOF' -import re, sys - -with open("variables.tf") as f: - content = f.read() - -expected = { - "homebrew-tap": {"Required", "DCO"}, - "jackin": {"Required", "DCO"}, - "jackin-agent-smith": {"Required", "DCO"}, - "jackin-dev": {"Required", "DCO"}, - "jackin-github-terraform": {"Required", "DCO"}, - "jackin-role-action": {"Required", "DCO"}, - "jackin-sentinel": {"Required", "DCO"}, - "jackin-the-architect": {"Required", "DCO"}, - "jackin-marketplace": {"Policy"}, -} - -policies = {} -for name, body in re.findall(r'"([^"]+)"\s*=\s*\{(.*?)\n\s*\}', content, re.S): - checks = re.search(r'required_checks\s*=\s*\[([^\]]*)\]', body) - policies[name] = re.findall(r'"([^"]+)"', checks.group(1)) if checks else [] - -missing = sorted(set(expected) - set(policies)) -incorrect = { - name: policies[name] - for name, contexts in expected.items() - if name in policies and (set(policies[name]) != contexts or len(policies[name]) != len(contexts)) -} -if missing or incorrect: - print(f"FAILED: Missing policy entries: {missing}; incorrect required contexts: {incorrect}.") - sys.exit(1) - -print("SUCCESS: All 8 target repos require Required and DCO; marketplace retains its verified Policy context.") -EOF - -echo "=== 6. Verifying every protected repository has required CI contexts ===" -python3 - << 'EOF' -import re, sys - -with open("variables.tf") as f: - content = f.read() - -policies = re.findall(r'"([^"]+)"\s*=\s*\{(.*?)\n\s*\}', content, re.S) -empty = [] -protected_count = 0 -for name, body in policies: - disposition = re.search(r'disposition\s*=\s*"([^"]+)"', body) - checks = re.search(r'required_checks\s*=\s*\[([^\]]*)\]', body) - contexts = re.findall(r'"([^"]+)"', checks.group(1)) if checks else [] - if disposition and disposition.group(1) == "FullRuleset": - protected_count += 1 - if not contexts: - empty.append(name) - -if empty: - print(f"FAILED: Protected repositories have no required CI contexts: {empty}") - sys.exit(1) - -print(f"SUCCESS: All {protected_count} protected repositories have required CI contexts.") -EOF - -echo "=== ALL VERIFICATIONS PASSED ===" diff --git a/variables.tf b/variables.tf index 6e0e0fe..6ea481f 100644 --- a/variables.tf +++ b/variables.tf @@ -1,59 +1,87 @@ # SPDX-FileCopyrightText: 2026 Alexey Zhokhov # SPDX-License-Identifier: Apache-2.0 +variable "github_organization" { + description = "GitHub organization that owns the managed repositories" + type = string + default = "jackin-project" +} + +variable "github_token" { + description = "GitHub token for the github provider; null falls back to GITHUB_TOKEN env" + type = string + default = null + sensitive = true +} + variable "repository_policies" { description = "Authoritative map of managed repository names to their protection policy configuration" type = map(object({ - disposition = string # "FullRuleset" | "RepoSettingsOnly" visibility = string # "public" | "private" required_checks = list(string) + no_ci_reason = optional(string) })) default = { # The current Velnor CI aggregator is Required; DCO is an independent app check. "homebrew-tap" = { - disposition = "FullRuleset" visibility = "public" required_checks = ["Required", "DCO"] } "jackin" = { - disposition = "FullRuleset" visibility = "public" required_checks = ["Required", "DCO"] } "jackin-agent-smith" = { - disposition = "FullRuleset" visibility = "public" required_checks = ["Required", "DCO"] } "jackin-dev" = { - disposition = "FullRuleset" visibility = "public" required_checks = ["Required", "DCO"] } "jackin-github-terraform" = { - disposition = "FullRuleset" visibility = "public" required_checks = ["Required", "DCO"] } "jackin-marketplace" = { - disposition = "FullRuleset" visibility = "public" required_checks = ["Policy"] } "jackin-role-action" = { - disposition = "FullRuleset" visibility = "public" required_checks = ["Required", "DCO"] } "jackin-sentinel" = { - disposition = "FullRuleset" visibility = "public" required_checks = ["Required", "DCO"] } "jackin-the-architect" = { - disposition = "FullRuleset" visibility = "public" required_checks = ["Required", "DCO"] } } + + validation { + condition = alltrue([ + for name, config in var.repository_policies : + contains(["public", "private"], config.visibility) + ]) + error_message = "visibility must be either 'public' or 'private'." + } + + validation { + condition = alltrue([ + for name, config in var.repository_policies : + ( + length(config.required_checks) > 0 + && alltrue([for c in config.required_checks : trimspace(c) != ""]) + && config.no_ci_reason == null + ) || ( + length(config.required_checks) == 0 + && config.no_ci_reason != null + && trimspace(config.no_ci_reason) != "" + ) + ]) + error_message = "Each entry must satisfy: non-empty blank-free required_checks with null no_ci_reason, XOR empty checks with non-empty no_ci_reason." + } }