diff --git a/README.md b/README.md index e39d017..5d89cb7 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Everything is described in four YAML files; OpenTofu turns them into GitHub sett | `config/organization.yaml` | Org profile, member privileges, security defaults for new repos, optional default-branch ruleset | | `config/members.yaml` | Who is in the org and who is an owner | | `config/teams.yaml` | Teams (up to 3 levels), their maintainers/members (by member ID), and their repository access | -| `config/repositories.yaml` | Every repository: description, topics, visibility, features, merge settings, archived | +| `config/repositories.yaml` | Every repository: description, topics, visibility, features, merge settings, archived, default-branch ruleset and its required checks | Changes go through pull requests: the **plan** workflow comments the exact diff on the PR, merging to `main` runs **apply**, and a nightly **drift** job fails if anyone changed the org by hand. @@ -24,8 +24,8 @@ infra/ OpenTofu root module — run every tofu command fro members.tf github_membership teams.tf github_team, github_team_members, github_team_repository repositories.tf github_repository - security.tf Dependabot, Actions permissions, the github-iac plan ruleset - rulesets.tf github_organization_ruleset (optional) + security.tf Dependabot, Actions permissions + rulesets.tf github_repository_ruleset per repository, github_organization_ruleset (optional) checks.tf config sanity checks (warnings in plan) versions.tf provider, S3 backend, state encryption backend.hcl state bucket and region (not secret, committed) @@ -45,7 +45,7 @@ git push -u origin main ``` Public, because the organization is on GitHub Free, where repository rulesets only apply to public -repositories, and the "Require a passing plan" ruleset in `infra/security.tf` is what keeps unplanned +repositories, and github-iac's ruleset, which requires the `plan` check, is what keeps unplanned changes out of `main`. Everything in `config/` (the billing email included) and every plan comment is therefore public. @@ -168,9 +168,21 @@ and, if it needs team access, give a team access in `teams.yaml` (`my-new-servic (`prevent_destroy`); to stop managing one, remove it from YAML and run `tofu state rm 'github_repository.this["name"]'`. -**Protect default branches everywhere** — `default_branch_ruleset` in `organization.yaml` creates an -organization ruleset, which needs GitHub Team. On GitHub Free, setting `enabled: true` makes the apply -fail; protect a single repository with a `github_repository_ruleset` instead (see `infra/security.tf`). +**Default-branch protection** — every repository gets the same `Main branch protection` ruleset +(`infra/rulesets.tf`): the branch can't be deleted or force-pushed, changes arrive through pull requests +with every conversation resolved and only the merge methods the repository allows, and the checks in +`required_checks` must pass on a branch that is up to date. `required_checks` defaults to +`[files, code]` and names GitHub Actions jobs, so set it per repository to the jobs its pull requests +report (a matrix job reports one check per entry, such as `code (seatmap)`). `branch_protection: false` +leaves a repository without one. + +```yaml + my-service: + required_checks: [files, code, executable] +``` + +`default_branch_ruleset` in `organization.yaml` creates an organization ruleset on top, which needs +GitHub Team. On GitHub Free, setting `enabled: true` makes the apply fail. **Harden a repository** — these optional keys in `config/repositories.yaml` are managed only for repos that set them (all are free on public repositories): @@ -200,9 +212,10 @@ repositories. Secret scanning and push protection are not managed here. - Nested teams inherit their parent's repository access. A child team's `repositories` lists only what it gets on top of that (an extra repo, or a higher permission); revoking a repo from the parent revokes it from the children too. -- Outside collaborators, Actions secrets, webhooks, GitHub App installations (DigitalOcean) and other - per-repo rulesets are not managed yet; the provider supports most of them - (`github_repository_collaborators`, `github_actions_organization_secret`, - `github_organization_webhook`, `github_repository_ruleset`) if you want to extend this. +- Outside collaborators, Actions secrets, webhooks and GitHub App installations (DigitalOcean) are not + managed yet; the provider supports most of them (`github_repository_collaborators`, + `github_actions_organization_secret`, `github_organization_webhook`) if you want to extend this. +- A rule added to a ruleset by hand is removed by the next apply, and so is any rule type the provider + doesn't know yet, such as code quality. - If applying `github_organization_settings` fails with the App token (some fields need an owner), set `manage_settings: false` or apply that part once locally with an owner's token. diff --git a/config/repositories.yaml b/config/repositories.yaml index 8fbe4ff..9eb6056 100644 --- a/config/repositories.yaml +++ b/config/repositories.yaml @@ -20,9 +20,12 @@ defaults: squash_merge_commit_title: PR_TITLE squash_merge_commit_message: BLANK web_commit_signoff_required: true + branch_protection: true + required_checks: [files, code] repositories: ".github": has_wiki: true + branch_protection: false allow_merge_commit: true allow_rebase_merge: true allow_auto_merge: false @@ -50,6 +53,7 @@ repositories: allow_auto_merge: false github-iac: description: "OpenTofu configuration of the MyPreflight GitHub organization" + required_checks: [plan] has_issues: false has_projects: false squash_merge_commit_message: PR_BODY @@ -75,9 +79,11 @@ repositories: description: "City postcard image generator, that is used by MyPreflight platform as a unique collectibles." product: description: "MyPreflight knowledge base: product decisions, standards, architecture and brand." + required_checks: [markdownlint] has_issues: false transponder-app: description: "Desktop companion feeding simulator positions to MyPreflight and publishing Discord rich presence" + required_checks: [files, code, executable] homepage_url: "https://mypreflight.io" has_wiki: true allow_auto_merge: false diff --git a/infra/imports.tf b/infra/imports.tf new file mode 100644 index 0000000..7bc9afb --- /dev/null +++ b/infra/imports.tf @@ -0,0 +1,49 @@ +moved { + from = github_repository_ruleset.github_iac_plan + to = github_repository_ruleset.default_branch["github-iac"] +} + +import { + to = github_repository_ruleset.default_branch["adsb-receiver-api"] + id = "adsb-receiver-api:20952365" +} + +import { + to = github_repository_ruleset.default_branch["aerolopa-provider"] + id = "aerolopa-provider:21036225" +} + +import { + to = github_repository_ruleset.default_branch["api"] + id = "api:2856650" +} + +import { + to = github_repository_ruleset.default_branch["app"] + id = "app:2843988" +} + +import { + to = github_repository_ruleset.default_branch["landing"] + id = "landing:21995511" +} + +import { + to = github_repository_ruleset.default_branch["osm-provider"] + id = "osm-provider:20548043" +} + +import { + to = github_repository_ruleset.default_branch["postcard-generator"] + id = "postcard-generator:21585090" +} + +import { + to = github_repository_ruleset.default_branch["product"] + id = "product:25468006" +} + +import { + to = github_repository_ruleset.default_branch["transponder-app"] + id = "transponder-app:21283082" +} diff --git a/infra/locals.tf b/infra/locals.tf index b830a53..db0b63b 100644 --- a/infra/locals.tf +++ b/infra/locals.tf @@ -105,4 +105,18 @@ locals { for name, repo in local.repositories_config.repositories : name => merge(local.repository_defaults, repo) }, {}) + + github_actions_integration_id = 15368 + + protected_repositories = { + for name, repo in local.repositories : name => { + required_checks = try([for check in repo.required_checks : check], []) + merge_methods = concat( + try(repo.allow_merge_commit, true) ? ["merge"] : [], + try(repo.allow_squash_merge, true) ? ["squash"] : [], + try(repo.allow_rebase_merge, true) ? ["rebase"] : [], + ) + } + if try(repo.branch_protection, false) == true && try(repo.archived, false) != true + } } diff --git a/infra/rulesets.tf b/infra/rulesets.tf index b8de280..25e83ea 100644 --- a/infra/rulesets.tf +++ b/infra/rulesets.tf @@ -44,3 +44,51 @@ resource "github_organization_ruleset" "default_branch" { } } } + +resource "github_repository_ruleset" "default_branch" { + for_each = local.protected_repositories + + name = "Main branch protection" + repository = github_repository.this[each.key].name + target = "branch" + enforcement = "active" + + conditions { + ref_name { + include = ["~DEFAULT_BRANCH"] + exclude = [] + } + } + + rules { + deletion = true + non_fast_forward = true + + pull_request { + allowed_merge_methods = each.value.merge_methods + required_approving_review_count = 0 + dismiss_stale_reviews_on_push = false + require_code_owner_review = false + require_last_push_approval = false + required_review_thread_resolution = true + } + + dynamic "required_status_checks" { + for_each = length(each.value.required_checks) > 0 ? [each.value.required_checks] : [] + + content { + strict_required_status_checks_policy = true + do_not_enforce_on_create = false + + dynamic "required_check" { + for_each = required_status_checks.value + + content { + context = required_check.value + integration_id = local.github_actions_integration_id + } + } + } + } + } +} diff --git a/infra/security.tf b/infra/security.tf index 72182eb..f221ccb 100644 --- a/infra/security.tf +++ b/infra/security.tf @@ -51,28 +51,3 @@ resource "github_actions_repository_permissions" "this" { } } } - -resource "github_repository_ruleset" "github_iac_plan" { - name = "Require a passing plan" - repository = github_repository.this["github-iac"].name - target = "branch" - enforcement = "active" - - conditions { - ref_name { - include = ["~DEFAULT_BRANCH"] - exclude = [] - } - } - - rules { - required_status_checks { - strict_required_status_checks_policy = true - - required_check { - context = "plan" - integration_id = 15368 - } - } - } -}