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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
strategy:
fail-fast: false
matrix:
component: [vpc, postgres-instance, app-alb, dummy]
component: [vpc, postgres-instance, app-alb, dummy, github]
steps:
- uses: actions/checkout@v6

Expand Down
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ pins that tag, so this file is the human-readable answer to "what's in v0.2.0?".

## [Unreleased]

_Nothing yet. Add entries here as you change modules; move them under a version when you tag._
### Added
- `github` — repository factory component (`integrations/github` provider). Manages GitHub
repos as code via a `repositories` map: visibility, description, topics, default branch, and
optional branch protection. First component requiring a credential (`GITHUB_TOKEN`); intended
to be owned by `infra-environments-dev` only, since repos are org-scoped.

## [0.2.0] - 2026-06-03

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ and **[CHANGELOG.md](./CHANGELOG.md)** for what changed in each tagged version.
| `postgres-instance` | RDS PostgreSQL instance | `database_address`, `database_arn` (+secrets) |
| `app-alb` | Public Application Load Balancer | `alb_dns_name`, `alb_arn`, `target_group_arn` |
| `dummy` | Credential-free CI/CD test (random/local/null) | `pet_name`, `artifact_path` |
| `github` | GitHub repositories as code (repo factory) | `repository_names`, `repository_urls` |

`vpc`, `postgres-instance`, and `app-alb` form a dependency chain:
**`vpc` → `postgres-instance` / `app-alb`**. `dummy` has no dependencies — it exists only to
Expand Down
86 changes: 86 additions & 0 deletions docs/superpowers/specs/2026-06-04-github-component-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# `github` component — design

**Date:** 2026-06-04
**Status:** Approved
**Repos touched:** `infra-components` (the module), `infra-environments-dev` (consumption)

## Goal

A reusable Terraform component that manages GitHub repositories-as-code, consumed by
`infra-environments-dev` via the existing Terragrunt pattern. Dev is the **single owner**
of GitHub-as-code (prod does not re-declare it) so two states never fight over org-scoped
resources.

## Approach

**Repository factory** — one component driven by a `repositories` map input. Each map entry
configures one repo (visibility, description, topics, default branch, optional branch
protection). Adding/changing a repo on the dev side is a single tfvars map edit; the module
never changes. No repos are seeded — dev ships with `repositories = {}` plus a commented
example.

## Module: `infra-components/github/terraform/`

Standard 4-file anatomy.

### `versions.tf`
- `required_version >= 1.5`
- provider `integrations/github` `~> 6.0`

### `variables.tf`
- `global` — accepted **only to satisfy the repo convention**. `_shared.hcl` always injects
`-var-file=global.tfvars`, so the variable must exist or Terraform errors on an undefined
var. GitHub resources have no region/tags, so its values are intentionally unused here;
documented as such.
- `github_owner` (string, default `"officialdad"`) — the org the provider operates on.
- `repositories` — map of objects, key = repo name:

```hcl
map(object({
description = optional(string, "")
visibility = optional(string, "private") # private | public | internal
topics = optional(list(string), [])
default_branch = optional(string, "main")
has_issues = optional(bool, true)
branch_protection = optional(object({
required_approving_review_count = optional(number, 1)
required_status_checks = optional(list(string), [])
enforce_admins = optional(bool, false)
})) # null/omitted = no protection
}))
```
Default `{}`. A `visibility` validation restricts it to `private|public|internal`.

### `main.tf`
- `provider "github" { owner = var.github_owner }` — token read from `GITHUB_TOKEN` env var
(no secret in git).
- `github_repository.this` — `for_each = var.repositories`
- `github_branch_default.this` — `for_each = var.repositories`
- `github_branch_protection.this` — `for_each` only over entries with a `branch_protection`
block; `pattern` = the repo's default branch.

### `outputs.tf`
- `repository_names` — map key → full name
- `repository_urls` — map key → `html_url`

## Auth & operational notes (README)
- **First component that needs a secret.** Requires `GITHUB_TOKEN` (PAT or GitHub App token)
with `repo` (+ `admin:org` if managing org-internal settings). It cannot run on the
credential-free `TG_BACKEND=local` path the `dummy` component uses.
- **Existing repos:** `github_repository` *creates* repos. To bring an already-existing repo
under management, `terraform import` it first or Terraform will try to create a duplicate
and fail. (Not exercised now — dev ships with an empty map.)

## Dev consumption: `infra-environments-dev/`
Thin, identical grain to the other components:
- `components.hcl` — add `github = "github"`.
- `github/versions.hcl` — `locals { github = "main" }` (dev tracks `main`).
- `github/terragrunt.hcl` — `include "root"` + `include "shared"`, no upstream deps.
- `github/terraform.tfvars` — `github_owner = "officialdad"`, `repositories = {}` with a
commented example entry showing the full per-repo shape.

## Out of scope (YAGNI)
- Teams, team membership, collaborators, repo secrets/variables, org rulesets, webhooks.
Easy to add as further optional fields/resources later if needed.
- Seeding real repos / importing them.
- Prod wiring (dev is sole owner).
79 changes: 79 additions & 0 deletions github/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# github

Manages **GitHub repositories as code**. A repository factory: you pass a `repositories` map
and the module creates/configures one repo per entry — visibility, description, topics,
default branch, and optional branch protection. Adding or changing a repo is a single map
edit on the consumer side; the module never changes.

This is the **first component that needs a credential** (a `GITHUB_TOKEN`), so unlike `dummy`
it cannot run on the credential-free `TG_BACKEND=local` path. Because GitHub repos are
**org-scoped, not per-environment**, exactly one environment should own this component —
`infra-environments-dev` is the designated owner.

## What it creates

Per entry in `repositories`:

- `github_repository` — the repo (visibility, description, topics, `has_issues`, `auto_init`).
- `github_branch_default` — sets the default branch.
- `github_branch_protection` — only when the entry includes a `branch_protection` block
(required reviews, optional required status checks, enforce-admins).

## Auth

The provider reads `GITHUB_TOKEN` from the environment — a PAT or GitHub App token with at
least `repo` scope (`admin:org` if you manage org-internal settings). **No token is stored in
this module or in git.** Export it before running:

```bash
export GITHUB_TOKEN=ghp_...
```

## Inputs

| Name | Type | Default | Description |
| -------------- | ------------- | -------------- | ------------------------------------------------------------------ |
| `global` | object | — | Env-wide context. Accepted for convention only; **unused** here. |
| `github_owner` | string | `officialdad` | The GitHub org (or user) the provider operates on. |
| `repositories` | map(object) | `{}` | Repositories to manage, keyed by repo name (see shape below). |

### `repositories` entry shape

```hcl
repositories = {
"my-repo" = {
description = "What it is"
visibility = "private" # private | public | internal
topics = ["terraform"]
default_branch = "main"
has_issues = true

# Omit this block entirely to leave the branch unprotected.
branch_protection = {
required_approving_review_count = 1
required_status_checks = ["ci"] # status check contexts; [] = none
enforce_admins = false
}
}
}
```

## Outputs

| Name | Description |
| ------------------ | -------------------------------------------- |
| `repository_names` | Map of key → full name (`owner/repo`). |
| `repository_urls` | Map of key → HTML URL. |

## Managing repos that already exist

`github_repository` **creates** repos. To bring an existing repo under management, import it
first or Terraform will try to create a duplicate and fail:

```bash
terraform import 'github_repository.this["my-repo"]' my-repo
```

## Dependencies

None. It does not consume any other component's outputs.
57 changes: 57 additions & 0 deletions github/terraform/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Manages GitHub repositories as code. One provider plus three resources fanned out over
# the `repositories` map, so adding a repo on the consumer side is a single map entry.
#
# Auth: the provider reads GITHUB_TOKEN from the environment (a PAT or GitHub App token).
# No secret is stored in this module or committed to git. This is the first component that
# needs a credential — it cannot run on the credential-free TG_BACKEND=local path.

provider "github" {
owner = var.github_owner
}

resource "github_repository" "this" {
for_each = var.repositories

name = each.key
description = each.value.description
visibility = each.value.visibility
topics = each.value.topics
has_issues = each.value.has_issues

# Give a newly-created repo an initial commit + default branch so the branch_default and
# branch_protection resources below have something to point at. No-op on imported repos.
auto_init = true
}

resource "github_branch_default" "this" {
for_each = var.repositories

repository = github_repository.this[each.key].name
branch = each.value.default_branch
}

resource "github_branch_protection" "this" {
# Only repos that declared a branch_protection block.
for_each = {
for name, cfg in var.repositories : name => cfg
if cfg.branch_protection != null
}

repository_id = github_repository.this[each.key].node_id
pattern = each.value.default_branch
enforce_admins = each.value.branch_protection.enforce_admins

required_pull_request_reviews {
required_approving_review_count = each.value.branch_protection.required_approving_review_count
}

# Only emit a required_status_checks block when contexts were supplied, otherwise an empty
# block would still mark the branch as requiring (zero) checks.
dynamic "required_status_checks" {
for_each = length(each.value.branch_protection.required_status_checks) > 0 ? [1] : []
content {
strict = true
contexts = each.value.branch_protection.required_status_checks
}
}
}
9 changes: 9 additions & 0 deletions github/terraform/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
output "repository_names" {
value = { for k, r in github_repository.this : k => r.full_name }
description = "Map of repository key -> full name (owner/repo)."
}

output "repository_urls" {
value = { for k, r in github_repository.this : k => r.html_url }
description = "Map of repository key -> HTML URL."
}
30 changes: 30 additions & 0 deletions github/terraform/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
variable "github_owner" {
type = string
description = "The GitHub organization (or user) the provider operates on."
default = "officialdad"
}

variable "repositories" {
type = map(object({
description = optional(string, "")
visibility = optional(string, "private")
topics = optional(list(string), [])
default_branch = optional(string, "main")
has_issues = optional(bool, true)
branch_protection = optional(object({
required_approving_review_count = optional(number, 1)
required_status_checks = optional(list(string), [])
enforce_admins = optional(bool, false)
}))
}))
description = "Repositories to manage, keyed by repo name. Each value configures one repo; omit branch_protection to leave the default branch unprotected."
default = {}

validation {
condition = alltrue([
for cfg in values(var.repositories) :
contains(["private", "public", "internal"], cfg.visibility)
])
error_message = "Each repository visibility must be one of: private, public, internal."
}
}
10 changes: 10 additions & 0 deletions github/terraform/versions.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
terraform {
required_version = ">= 1.5"

required_providers {
github = {
source = "integrations/github"
version = "~> 6.0"
}
}
}