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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ To create a GitHub App and a GitHub App Installation:
- Variables: Read and write
- Organization permissions
- Administration: Read and write
- Members: Read and write
- Where can this GitHub App be installed?: _choose what suits you best_
- **Create GitHub App**
- _your app_
Expand Down Expand Up @@ -132,6 +133,9 @@ Create the configuration file:
```yaml
---
organization: # OPTIONAL
members: # OPTIONAL, DEFAULT none
admins: # REQUIRED; GitHub usernames
- octocat
environments: # OPTIONAL, DEFAULT none; added to every repository
release:
deployment_branches: # OPTIONAL, DEFAULT every branch
Expand Down Expand Up @@ -172,6 +176,12 @@ repositories:
default-branch: ~ # opts out of the organization ruleset
```

### Members

`organization.members.admins` lists the organization owners. Only owners are managed: other members, teams and outside collaborators stay unmanaged. A listed user who is not a member yet is invited and stays pending until they accept. Every owner has `prevent_destroy`, so removing one from the list fails the plan; demote or remove an owner explicitly (`removed` block or `terraform state rm`). Existing memberships are imported, which needs the Terraform workspace named after the organization.

Members need the App's organization Members permission, see [GitHub App](#github-app).

### Environments

`organization.environments` is added to every repository's `environments`. A repository environment of the same name replaces the organization one wholesale (no key-level merge), `~` opts the repository out of it, other names are repository-only.
Expand Down
23 changes: 22 additions & 1 deletion terraform/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ locals {
config = try(yamldecode(file(var.config)), {})

allowed_top_level_keys = ["organization", "repositories"]
allowed_organization_keys = ["environments", "rulesets"]
allowed_organization_keys = ["members", "environments", "rulesets"]
allowed_members_keys = ["admins"]
allowed_repository_keys = ["name", "description", "homepage_url", "topics", "is_template", "template", "environments", "rulesets"]
allowed_environment_keys = ["deployment_branches", "variables", "secrets", "reviewers"]
allowed_ruleset_keys = ["bypass_apps"]

organization_admins = try(toset(local.config.organization.members.admins), toset([]))

organization_environments = try({ for name, environment in local.config.organization.environments : name => environment }, {})
organization_rulesets = try({ for name, ruleset in local.config.organization.rulesets : name => ruleset }, {})

Expand All @@ -31,3 +34,21 @@ module "repository" {

repository = each.value
}

resource "github_membership" "admin" {
for_each = local.organization_admins

username = each.value
role = "admin"

lifecycle {
prevent_destroy = true
}
}

# Keep until every workspace has applied this; the workspace is named after the organization.
import {
for_each = local.organization_admins
to = github_membership.admin[each.key]
id = "${terraform.workspace}:${each.key}"
}
8 changes: 8 additions & 0 deletions terraform/tests/fixtures/members-admins-blank.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
organization:
members:
admins:
- bruzina
- " "
repositories:
- name: foo
8 changes: 8 additions & 0 deletions terraform/tests/fixtures/members-admins-duplicate-case.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
organization:
members:
admins:
- bruzina
- Bruzina
repositories:
- name: foo
6 changes: 6 additions & 0 deletions terraform/tests/fixtures/members-admins-empty.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
organization:
members:
admins: []
repositories:
- name: foo
7 changes: 7 additions & 0 deletions terraform/tests/fixtures/members-admins-map.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
organization:
members:
admins:
bruzina: admin
repositories:
- name: foo
5 changes: 5 additions & 0 deletions terraform/tests/fixtures/members-null.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
organization:
members: ~
repositories:
- name: foo
9 changes: 9 additions & 0 deletions terraform/tests/fixtures/members-unknown-key.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
organization:
members:
admins:
- bruzina
owners:
- octocat
repositories:
- name: foo
8 changes: 8 additions & 0 deletions terraform/tests/fixtures/members.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
organization:
members:
admins:
- bruzina
- octocat
repositories:
- name: foo
94 changes: 94 additions & 0 deletions terraform/tests/members.tftest.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
mock_provider "github" {}

override_resource {
target = github_membership.admin
}

run "members" {
command = plan

variables {
config = "tests/fixtures/members.yaml"
}

assert {
condition = keys(github_membership.admin) == ["bruzina", "octocat"] && alltrue([for m in github_membership.admin : m.role == "admin"])
error_message = "Expected admins bruzina and octocat."
}
}

run "no_members" {
command = plan

variables {
config = "tests/fixtures/one-repository.yaml"
}

assert {
condition = length(github_membership.admin) == 0
error_message = "Expected no memberships."
}
}

run "members_null" {
command = plan

variables {
config = "tests/fixtures/members-null.yaml"
}

assert {
condition = length(github_membership.admin) == 0
error_message = "Expected no memberships."
}
}

run "members_unknown_key" {
command = plan

variables {
config = "tests/fixtures/members-unknown-key.yaml"
}

expect_failures = [var.config]
}

run "members_admins_empty" {
command = plan

variables {
config = "tests/fixtures/members-admins-empty.yaml"
}

expect_failures = [var.config]
}

run "members_admins_map" {
command = plan

variables {
config = "tests/fixtures/members-admins-map.yaml"
}

expect_failures = [var.config]
}

run "members_admins_blank" {
command = plan

variables {
config = "tests/fixtures/members-admins-blank.yaml"
}

expect_failures = [var.config]
}

run "members_admins_duplicate_case" {
command = plan

variables {
config = "tests/fixtures/members-admins-duplicate-case.yaml"
}

expect_failures = [var.config]
}
9 changes: 9 additions & 0 deletions terraform/tests/test-yaml.tftest.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,22 @@ mock_provider "github" {
}
}

override_resource {
target = github_membership.admin
}

run "test_yaml" {
command = apply

variables {
config = "../test.yaml"
}

assert {
condition = keys(github_membership.admin) == ["bruzina"]
error_message = "Expected admin bruzina."
}

assert {
condition = keys(module.repository) == [".github", "template", "template-use"]
error_message = "Expected repositories .github, template, template-use."
Expand Down
4 changes: 4 additions & 0 deletions terraform/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ variable "config" {
condition = try(yamldecode(file(var.config)).organization == null, true) || try(length(setsubtract(keys(yamldecode(file(var.config)).organization), local.allowed_organization_keys)) == 0, false)
error_message = "File ${var.config}: organization must be a map with key(s) ${join(", ", local.allowed_organization_keys)}; unknown: ${try(join(", ", setsubtract(keys(yamldecode(file(var.config)).organization), local.allowed_organization_keys)), "")}"
}
validation {
condition = try(yamldecode(file(var.config)).organization.members == null, true) || try(length(setsubtract(keys(yamldecode(file(var.config)).organization.members), local.allowed_members_keys)) == 0 && length(concat(yamldecode(file(var.config)).organization.members.admins, [])) > 0 && alltrue([for u in yamldecode(file(var.config)).organization.members.admins : trimspace(u) != ""]) && length(distinct([for u in yamldecode(file(var.config)).organization.members.admins : lower(u)])) == length(yamldecode(file(var.config)).organization.members.admins), false)
error_message = "File ${var.config}: organization.members must be a map of ${join(", ", local.allowed_members_keys)}; admins must be a non-empty list of unique GitHub usernames."
}
validation {
condition = try(length(flatten([for s in concat([{ label = "organization", environments = try(yamldecode(file(var.config)).organization.environments, null), opt_out = false }], [for r in yamldecode(file(var.config)).repositories : { label = r.name, environments = try(r.environments, null), opt_out = true }]) : s.environments == null ? [] : !can(keys(s.environments)) ? ["${s.label}.environments"] : [for n, e in s.environments : "${s.label}.environments.${n}" if !(s.opt_out && e == null) && !try(length(setsubtract(keys(e), local.allowed_environment_keys)) == 0, false)]])) == 0, true)
error_message = "Invalid environment(s) in ${var.config}: ${try(join(", ", flatten([for s in concat([{ label = "organization", environments = try(yamldecode(file(var.config)).organization.environments, null), opt_out = false }], [for r in yamldecode(file(var.config)).repositories : { label = r.name, environments = try(r.environments, null), opt_out = true }]) : s.environments == null ? [] : !can(keys(s.environments)) ? ["${s.label}.environments"] : [for n, e in s.environments : "${s.label}.environments.${n}" if !(s.opt_out && e == null) && !try(length(setsubtract(keys(e), local.allowed_environment_keys)) == 0, false)]])), "")}; each must be a map of ${join(", ", local.allowed_environment_keys)}, or ~ under a repository to opt out of an organization environment."
Expand Down
3 changes: 3 additions & 0 deletions test.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
---
organization:
members:
admins:
- bruzina
environments:
release:
deployment_branches:
Expand Down
Loading