diff --git a/infra/.gitignore b/infra/.gitignore index b5093cd..57a3fb6 100644 --- a/infra/.gitignore +++ b/infra/.gitignore @@ -12,3 +12,8 @@ backend.hcl *.auto.tfvars terraform.tfvars !terraform.tfvars.example + +# Local backend overrides (developer-specific) +backend_override.tf +*_override.tf +!*_override.tf.example diff --git a/infra/github/.terraform.lock.hcl b/infra/github/.terraform.lock.hcl new file mode 100644 index 0000000..98c5926 --- /dev/null +++ b/infra/github/.terraform.lock.hcl @@ -0,0 +1,24 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/integrations/github" { + version = "6.12.1" + constraints = "~> 6.2" + hashes = [ + "h1:bGz4LIep/7PVrqy6P8cTYbAJpdxXGrupUJjkCczlzIs=", + "zh:3e1a4081ecb9518fdf0074db83c16ad00dc81ffe8249a6e3cf1894e947e28df6", + "zh:4cb8224b7f530795b674ac044675f6b22a7c9154f55eb9f76c5af6c7534056a4", + "zh:560bc08637926191f6871a89e986022ca67c70afda5bebca34b5216e6fac69c9", + "zh:5a70b5d2ac650c5c9819a1875411ebda229d0fcc6c9f57f9d751852ca3cd77ac", + "zh:8668d93bd4dc2ffa2545e1473af600a925d479b16033a71a4498a16f3b683c0c", + "zh:86eacc6059fd057948e178b665ba5cce74bd5488a9e1035734e60ff5ef1b6f8f", + "zh:a329fac98881d8dfc211a9bdc0ec6f2948f0b0c2704d1b6cbe5307403c7ad1b2", + "zh:dadd44abab3c52b9d572955afaef1658790e17ea355ee22b58996d81d28e02d8", + "zh:de9f455ef342cc38fb76bce844bfcd376fb81a4b9f9bc2fae023ff99efdf1338", + "zh:f8c6d2e8351b334491790358574e0a30a7c6d7f5b80f7daf32a7c0f3e9b1ab19", + "zh:fab41971a3edee04ab6eceaeab4eeb9a2b2f38a2af3b06eda93e2117b64994be", + "zh:fb1279b566dd9c8c117b2e4e0cc8344413b8fc8f2a3e24be22a9b2610551777b", + "zh:fbd1fee2c9df3aa19cf8851ce134dea6e45ea01cb85695c1726670c285797e25", + "zh:fe79d2a861fb9af420fa5bd7f02c031b2a0a3edf5dbc46022c8ecc7a33cf2b6d", + ] +} diff --git a/infra/github/README.md b/infra/github/README.md new file mode 100644 index 0000000..ec6579e --- /dev/null +++ b/infra/github/README.md @@ -0,0 +1,47 @@ +# GitHub repo configuration (OpenTofu) + +Manages branch protection (via repository rulesets) and deployment +environments for `JMR-dev/dev_blog`. + +This module is also the source-of-truth copy that is mirrored to the +[`gh-repo-bootstrap`](https://github.com/JMR-dev/gh-repo-bootstrap) repo, +which packages it as a reusable module + `gh` CLI extension +(`gh repo-bootstrap /`). + +## Prerequisites + +- [OpenTofu](https://opentofu.org/) >= 1.8 +- A GitHub token with `repo` + `admin:repo_hook` scopes. Easiest: + ```sh + export GITHUB_TOKEN=$(gh auth token) + ``` +- (For remote state) Cloudflare R2 credentials — copy + `backend.hcl.example` to `backend.hcl` and fill it in. + +## Usage + +```sh +# First time only: +tofu init -backend-config=backend.hcl + +# Apply: +tofu apply +``` + +To run with **local state** (no R2 needed) for experimentation, comment +out the `backend "s3"` block in `versions.tf`, then `tofu init` again. + +## What it manages + +- A repository ruleset on the default branch enforcing: + - No deletion + - No force-push + - Required PR with N approving reviews (configurable) + - Resolved review threads + - Optional signed commits +- The set of GitHub deployment environments listed in `environments`. + +It does **not** create the repository itself, and does not manage +repo-level settings (merge button options, default branch, topic, etc.). +Add those via `gh api` or import the `github_repository` resource if you +need them under OpenTofu control. diff --git a/infra/github/backend.hcl.example b/infra/github/backend.hcl.example new file mode 100644 index 0000000..9ef801b --- /dev/null +++ b/infra/github/backend.hcl.example @@ -0,0 +1,8 @@ +# Example Cloudflare R2 backend configuration. +# Copy to `backend.hcl` (gitignored) and fill in your values, then run: +# tofu init -backend-config=backend.hcl + +bucket = "dev-blog-tfstate" +endpoints = { s3 = "https://.r2.cloudflarestorage.com" } +access_key = "" +secret_key = "" diff --git a/infra/github/main.tf b/infra/github/main.tf new file mode 100644 index 0000000..1d7054e --- /dev/null +++ b/infra/github/main.tf @@ -0,0 +1,46 @@ +data "github_repository" "this" { + name = var.repo_name +} + +resource "github_repository_ruleset" "default_branch" { + repository = data.github_repository.this.name + name = var.ruleset_name + target = "branch" + enforcement = "active" + + conditions { + ref_name { + include = ["refs/heads/${var.default_branch}"] + exclude = [] + } + } + + dynamic "bypass_actors" { + for_each = var.bypass_actors + content { + actor_id = bypass_actors.value.actor_id + actor_type = bypass_actors.value.actor_type + bypass_mode = bypass_actors.value.bypass_mode + } + } + + rules { + deletion = true + non_fast_forward = true + required_signatures = var.require_signed_commits + + pull_request { + required_approving_review_count = var.required_reviews + dismiss_stale_reviews_on_push = true + require_code_owner_review = false + require_last_push_approval = false + required_review_thread_resolution = true + } + } +} + +resource "github_repository_environment" "envs" { + for_each = toset(var.environments) + repository = data.github_repository.this.name + environment = each.value +} diff --git a/infra/github/outputs.tf b/infra/github/outputs.tf new file mode 100644 index 0000000..ed4cb4d --- /dev/null +++ b/infra/github/outputs.tf @@ -0,0 +1,14 @@ +output "repository_full_name" { + value = data.github_repository.this.full_name + description = "Full name (owner/repo) of the repository being managed." +} + +output "ruleset_id" { + value = github_repository_ruleset.default_branch.id + description = "ID of the branch protection ruleset." +} + +output "environments" { + value = sort([for e in github_repository_environment.envs : e.environment]) + description = "Environments managed by this configuration." +} diff --git a/infra/github/terraform.tfvars.example b/infra/github/terraform.tfvars.example new file mode 100644 index 0000000..d8d2218 --- /dev/null +++ b/infra/github/terraform.tfvars.example @@ -0,0 +1,11 @@ +repo_owner = "JMR-dev" +repo_name = "dev_blog" +default_branch = "main" +required_reviews = 1 +require_signed_commits = false +environments = [ + "production", + "staging", + "preview", + "development", +] diff --git a/infra/github/variables.tf b/infra/github/variables.tf new file mode 100644 index 0000000..97271ed --- /dev/null +++ b/infra/github/variables.tf @@ -0,0 +1,56 @@ +variable "repo_owner" { + description = "GitHub user or organization that owns the repository." + type = string +} + +variable "repo_name" { + description = "Repository name (without owner prefix)." + type = string +} + +variable "default_branch" { + description = "Branch protected by the ruleset." + type = string + default = "main" +} + +variable "required_reviews" { + description = "Number of required approving reviews on PRs targeting the default branch." + type = number + default = 1 +} + +variable "require_signed_commits" { + description = "Require signed commits on the protected branch." + type = bool + default = false +} + +variable "environments" { + description = "List of GitHub deployment environments to ensure exist." + type = list(string) + default = [] +} + +variable "ruleset_name" { + description = "Name to give the branch protection ruleset." + type = string + default = "default-branch-protection" +} + +variable "bypass_actors" { + description = <<-EOT + Actors permitted to bypass the ruleset. Each entry needs: + - actor_id: numeric ID (for built-in repo roles: 1=read, 2=triage, + 3=write, 4=maintain, 5=admin) + - actor_type: one of RepositoryRole, Team, Integration, + OrganizationAdmin, DeployKey + - bypass_mode: "always" or "pull_request" + EOT + type = list(object({ + actor_id = number + actor_type = string + bypass_mode = string + })) + default = [] +} diff --git a/infra/github/versions.tf b/infra/github/versions.tf new file mode 100644 index 0000000..30edcd3 --- /dev/null +++ b/infra/github/versions.tf @@ -0,0 +1,33 @@ +terraform { + required_version = ">= 1.8.0" + + required_providers { + github = { + source = "integrations/github" + version = "~> 6.2" + } + } + + # Cloudflare R2 (S3-compatible) backend, mirroring infra/versions.tf. + # Account-specific values are supplied at init time: + # tofu init -backend-config=backend.hcl + # For local-only experimentation, comment this entire block out and + # OpenTofu will fall back to the local backend. + backend "s3" { + key = "dev_blog/github.tfstate" + region = "auto" + + skip_credentials_validation = true + skip_metadata_api_check = true + skip_region_validation = true + skip_requesting_account_id = true + skip_s3_checksum = true + use_path_style = true + } +} + +provider "github" { + owner = var.repo_owner + # Token is read from the GITHUB_TOKEN environment variable. + # Easiest source: `export GITHUB_TOKEN=$(gh auth token)`. +}