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
35 changes: 24 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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.

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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.
6 changes: 6 additions & 0 deletions config/repositories.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
49 changes: 49 additions & 0 deletions infra/imports.tf
Original file line number Diff line number Diff line change
@@ -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"
}
14 changes: 14 additions & 0 deletions infra/locals.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
48 changes: 48 additions & 0 deletions infra/rulesets.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
}
}
}
25 changes: 0 additions & 25 deletions infra/security.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
}