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
10 changes: 10 additions & 0 deletions .terraform-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ output:
mode: replace
template: |
<!-- BEGIN_TF_DOCS -->
# tfroot-github

OpenTofu root for Make IT Work Cloud GitHub organization infrastructure.

## Agent and operator contract

- This root owns centralized repository policy, protections, Dependabot callers, and distributed Actions configuration.
- Read [`AGENTS.md`](AGENTS.md) before changing managed paths or workflow callers; downstream edits to generated files are overwritten.
- Pull-request CI plans are the validation authority. `main` is an environment-gated apply path; do not run local OpenTofu or secret-decryption operations from an agent session.

{{ .Content }}

## Operator guides
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
<!-- BEGIN_TF_DOCS -->
# tfroot-github

OpenTofu root for Make IT Work Cloud GitHub organization infrastructure.

## Agent and operator contract

- This root owns centralized repository policy, protections, Dependabot callers, and distributed Actions configuration.
- Read [`AGENTS.md`](AGENTS.md) before changing managed paths or workflow callers; downstream edits to generated files are overwritten.
- Pull-request CI plans are the validation authority. `main` is an environment-gated apply path; do not run local OpenTofu or secret-decryption operations from an agent session.

## Requirements

| Name | Version |
Expand Down
48 changes: 7 additions & 41 deletions gh-dependabot.tf
Original file line number Diff line number Diff line change
Expand Up @@ -12,29 +12,13 @@
# - Kubernetes image tags in kustomize-cluster: no Dependabot ecosystem exists

locals {
dependabot_ecosystems = {
".github" = ["github-actions"]
"cflan" = ["github-actions", "pip"]
"hero-host-config" = ["github-actions"]
"images" = ["github-actions", "docker"]
"kustomize-cluster" = ["github-actions"]
"shared-workflows" = ["github-actions"]
"terraform-libvirt-domain" = ["github-actions", "opentofu"]
"tfroot-aws" = ["github-actions", "opentofu"]
"tfroot-cloudflare" = ["github-actions", "opentofu"]
"tfroot-github" = ["github-actions", "opentofu"]
"tfroot-libvirt" = ["github-actions", "opentofu"]
"tfroot-namecheap" = ["github-actions", "opentofu"]
"tfroot-twilio" = ["github-actions", "opentofu"]
"www" = ["github-actions"]
}
dependabot_docker_directories = ["gh-cli", "tfroot-runner"]

dependabot_configs = {
for repo, ecosystems in local.dependabot_ecosystems : repo => {
for name, repository in local.repositories : name => {
version = 2
updates = concat(
[for ecosystem in ecosystems : {
[for ecosystem in repository.dependabot_ecosystems : {
package-ecosystem = ecosystem
directory = "/"
schedule = {
Expand All @@ -57,9 +41,10 @@ locals {
patterns = ["*"]
}
}
} if contains(ecosystems, "docker")]
} if contains(repository.dependabot_ecosystems, "docker")]
)
}
if length(repository.dependabot_ecosystems) > 0
}
}

Expand All @@ -85,28 +70,9 @@ resource "github_repository_file" "dependabot" {

locals {
# Caller for the dependabot-notify reusable workflow in shared-workflows.
# The reusable lives at _dependabot-notify.yml because this file's target
# path is managed in every repo, shared-workflows included; a reusable at
# the caller's path would be overwritten by this resource on every apply.
# Fires only when Dependabot itself opens the PR; posts a synthetic alert
# to the cluster Alertmanager (see AGENTS.md, "Dependabot PR Alerting").
dependabot_notify_workflow = <<-EOT
---
# Managed by tfroot-github (gh-dependabot.tf); local edits are overwritten.
name: dependabot-notify

on:
pull_request:
types: [opened, reopened]

permissions: {}

jobs:
notify:
if: github.actor == 'dependabot[bot]'
uses: makeitworkcloud/shared-workflows/.github/workflows/_dependabot-notify.yml@main
secrets: inherit
EOT
# It is named _dependabot-notify.yml there because this root owns the caller
# path in every repository, including shared-workflows itself.
dependabot_notify_workflow = templatefile("${path.module}/templates/dependabot-notify.yml.tftpl", {})
}

resource "github_repository_file" "dependabot_notify" {
Expand Down
35 changes: 15 additions & 20 deletions gh-protections.tf
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,14 @@ data "github_app" "chart_updater" {
slug = "makeitworkbot"
}

# Every active public repository requires a pull request with its configured CI
# checks passing before merge, except repositories explicitly assigned the
# relaxed protection profile below. Private repositories are deliberately
# excluded: GitHub Free cannot enforce these protections there, and personal
# private repositories are outside the organization's review policy. The check
# map lives in main.tf because check-run names differ by repository and GitHub
# treats an unknown required check as pending.
# Strict protection requires pull requests and configured CI checks. Private
# repositories remain excluded because their GitHub plan does not enforce this
# organization policy through this resource.
resource "github_branch_protection" "protections" {
for_each = toset([
for repo in local.github_repositories : repo
if !contains(local.archived_github_repositories, repo) && !contains(local.private_github_repositories, repo) && !contains(local.relaxed_branch_protection_github_repositories, repo)
])
for_each = {
for name, repository in local.repositories : name => repository
if !repository.archived && !repository.private && repository.protection_profile == "strict"
}

repository_id = github_repository.repositories[each.key].node_id
pattern = "main"
Expand All @@ -25,7 +21,7 @@ resource "github_branch_protection" "protections" {
require_conversation_resolution = true
required_status_checks {
strict = true
contexts = local.required_status_checks_by_repository[each.key]
contexts = each.value.required_status_checks
}
# This block requires a pull request while retaining the solo-maintainer
# workflow: zero approvals, no code-owner gate, and no bypass actors.
Expand Down Expand Up @@ -55,15 +51,14 @@ resource "github_branch_protection" "protections" {
]
}

# The relaxed profile is for public personal repositories: pull-request-only
# writes and basic branch integrity, while allowing any pull request to merge
# without a CI, approval, code-owner, or conversation-resolution gate. Private
# repositories are deliberately excluded from all branch-protection resources.
# The relaxed profile retains pull-request-only writes and basic branch
# integrity, but does not require CI, approvals, code owners, or resolved
# conversations. Private repositories remain excluded from this resource.
resource "github_branch_protection" "relaxed_protections" {
for_each = toset([
for repo in local.relaxed_branch_protection_github_repositories : repo
if !contains(local.archived_github_repositories, repo) && !contains(local.private_github_repositories, repo)
])
for_each = {
for name, repository in local.repositories : name => repository
if !repository.archived && !repository.private && repository.protection_profile == "relaxed"
}

repository_id = github_repository.repositories[each.key].node_id
pattern = "main"
Expand Down
10 changes: 5 additions & 5 deletions gh-repositories.tf
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
resource "github_repository" "repositories" {
for_each = local.github_repositories
for_each = local.repositories
name = each.key
archived = contains(local.archived_github_repositories, each.key)
visibility = contains(local.private_github_repositories, each.key) ? "private" : var.github_visibility
archived = each.value.archived
visibility = each.value.private ? "private" : var.github_visibility
auto_init = true
allow_squash_merge = true
allow_merge_commit = true
allow_rebase_merge = false
# Auto-merge stays off except for repositories whose automation-created pull
# requests are expected to merge themselves once required checks pass.
allow_auto_merge = contains(local.auto_merge_github_repositories, each.key)
allow_auto_merge = each.value.allow_auto_merge
delete_branch_on_merge = true
squash_merge_commit_title = "PR_TITLE"
squash_merge_commit_message = "PR_BODY"
topics = contains(local.active_github_repositories, each.key) ? local.topics_by_repository[each.key] : null
topics = each.value.archived ? null : each.value.topics
lifecycle {
ignore_changes = [
description,
Expand Down
12 changes: 3 additions & 9 deletions imports.tf
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import {
id = "channel-project"
}

# These files were seeded through tfroot-twilio PR #1 because its temporary
# protection rejects direct file creation. Import them before central management
# so the first reconciled apply does not attempt another protected direct write.
# These files were seeded through pull requests because protected branches
# reject direct creation. Keep the imports until a reviewed no-op plan confirms
# state ownership and central management of the matching live file.
import {
to = github_repository_file.dependabot["tfroot-twilio"]
id = "tfroot-twilio:.github/dependabot.yml:"
Expand All @@ -21,12 +21,6 @@ import {
id = "tfroot-twilio:.github/workflows/dependabot-notify.yml:"
}

# The kustomize-cluster dependabot-notify workflow was seeded through the
# pull-request path before this resource instance existed in state; the
# direct write in apply run 189 was rejected by the destination's required
# pull-request protection. The live file content matches the managed
# template exactly, so import it before central management, mirroring the
# tfroot-twilio adoption above.
import {
to = github_repository_file.dependabot_notify["kustomize-cluster"]
id = "kustomize-cluster:.github/workflows/dependabot-notify.yml:"
Expand Down
Loading
Loading