Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
```
74 changes: 61 additions & 13 deletions checks.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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)."
Comment on lines +8 to +9

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Make the inventory assertion operation-blocking

When repository_policies contains an extra or misspelled key, this assertion only produces a warning because OpenTofu check failures do not stop planning or applying. The module subsequently iterates over that key and can create or manage an unintended GitHub repository despite the message claiming that exactly nine repositories are allowed; enforce this through variable validation or a resource/module precondition instead.

Useful? React with 👍 / 👎.

}
}

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

Expand All @@ -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([
Expand All @@ -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."
}
}
137 changes: 137 additions & 0 deletions imports.tf
Original file line number Diff line number Diff line change
@@ -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"
Comment on lines +10 to +11

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scope bootstrap imports to the configured organization

When github_organization is overridden for another owner, fresh-state planning still attempts to import this ruleset using a numeric ID taken from jackin-project; the repository-name import resolves under the configured owner, but its ruleset IDs do not. Consequently the newly configurable provider cannot bootstrap another organization and the import fails, so either keep the owner fixed or make the import IDs owner-specific.

Useful? React with 👍 / 👎.

}

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"
}
13 changes: 13 additions & 0 deletions locals.tf
Original file line number Diff line number Diff line change
@@ -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"]
}
}
18 changes: 0 additions & 18 deletions migrations.tf

This file was deleted.

29 changes: 12 additions & 17 deletions modules/repository-policy/main.tf
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -49,7 +45,6 @@ resource "github_repository" "managed_settings" {
has_projects,
has_wiki,
has_discussions,
visibility,
archived,
topics,
vulnerability_alerts,
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading