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
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ GitOps workflow turning a declarative YAML organization definition into GitHub r
- **Repository metadata** - Define description, homepage URL, topics.
- **Environments** - Define deployment environments per repository or once for every repository.
- **Variables and secrets** - Define environment variables and secret placeholders.
- **Rulesets** - Protect default branches per repository or once for every repository.
- **Rulesets** - Protect default branches and release tags per repository or once for every repository.
- **GitOps Composite Action** - Manage configurations using pull requests and automate updates using a [composite action](action.yaml).
- **Terraform** - Uses Terraform under the hood to apply changes efficiently.
- **Terraform State Management** - Stores Terraform state securely in AWS S3.
Expand Down Expand Up @@ -146,8 +146,11 @@ organization: # OPTIONAL
- APP_PEM_FILE
rulesets: # OPTIONAL, DEFAULT none; added to every repository
default-branch:
target: branch # OPTIONAL, DEFAULT branch; branch or tag
bypass_apps: # OPTIONAL, DEFAULT none
- 123456
release-tags:
target: tag
repositories:
- name: repo-slug
# Metadata
Expand Down Expand Up @@ -204,7 +207,11 @@ Environments need the App's repository Administration permission, variables and

`organization.rulesets` is added to every repository's `rulesets`, with the same replace, `~` opt-out and repository-only semantics as [environments](#environments).

Every ruleset protects the repository's default branch: changes only through a pull request (no approval required, so a single maintainer can merge their own), no force pushes, no deletion, linear history, [conventional commit](https://www.conventionalcommits.org/) messages with a lowercase subject. No required status checks. On the GitHub Free plan, rulesets are available in public repositories only.
A `branch` ruleset (default `target`) protects the repository's default branch: changes only through a pull request (no approval required, so a single maintainer can merge their own), no force pushes, no deletion, linear history, [conventional commit](https://www.conventionalcommits.org/) messages with a lowercase subject. No required status checks.

A `tag` ruleset protects release tags `vX.Y.Z` (`refs/tags/v*.*.*`): no update, no deletion; creation stays allowed, e.g. for semantic-release. Major tags `vN` do not match, so a release App can still move them.

On the GitHub Free plan, rulesets are available in public repositories only.

`bypass_apps` lists GitHub App IDs that always bypass the ruleset, e.g. a release App pushing a changelog commit to the default branch. Pushes authenticated by `GITHUB_TOKEN` cannot bypass: a repository releasing with `GITHUB_TOKEN` must opt out.

Expand Down
2 changes: 1 addition & 1 deletion terraform/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ locals {
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"]
allowed_ruleset_keys = ["target", "bypass_apps"]

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

Expand Down
31 changes: 20 additions & 11 deletions terraform/modules/repository/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,12 @@ resource "github_repository_ruleset" "this" {

repository = github_repository.this.name
name = each.key
target = "branch"
target = each.value.target
enforcement = "active"

conditions {
ref_name {
include = ["~DEFAULT_BRANCH"]
include = [each.value.target == "tag" ? "refs/tags/v*.*.*" : "~DEFAULT_BRANCH"]
exclude = []
}
}
Expand All @@ -76,17 +76,26 @@ resource "github_repository_ruleset" "this" {

rules {
deletion = true
non_fast_forward = true
required_linear_history = true

commit_message_pattern {
name = "Conventional commit, lowercase subject"
operator = "regex"
pattern = "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\\([a-z0-9._/-]+\\))?!?: [^A-Z\\n]+(\\n|$)"
update = each.value.target == "tag"
non_fast_forward = each.value.target == "branch"
required_linear_history = each.value.target == "branch"

dynamic "commit_message_pattern" {
for_each = each.value.target == "branch" ? [1] : []

content {
name = "Conventional commit, lowercase subject"
operator = "regex"
pattern = "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\\([a-z0-9._/-]+\\))?!?: [^A-Z\\n]+(\\n|$)"
}
}

pull_request {
required_approving_review_count = 0
dynamic "pull_request" {
for_each = each.value.target == "branch" ? [1] : []

content {
required_approving_review_count = 0
}
}
}
}
54 changes: 54 additions & 0 deletions terraform/modules/repository/tests/repository.tftest.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,11 @@ run "ruleset" {
error_message = "Expected deletion and force push blocked."
}

assert {
condition = !github_repository_ruleset.this["default-branch"].rules[0].update
error_message = "Expected branch updates allowed."
}

assert {
condition = github_repository_ruleset.this["default-branch"].rules[0].required_linear_history
error_message = "Expected linear history required."
Expand All @@ -558,6 +563,55 @@ run "ruleset" {
}
}

run "ruleset_tag" {
command = plan

variables {
repository = {
name = "foo"
rulesets = { release-tags = { target = "tag" } }
}
}

assert {
condition = github_repository_ruleset.this["release-tags"].target == "tag" && github_repository_ruleset.this["release-tags"].enforcement == "active"
error_message = "Expected an active tag ruleset."
}

assert {
condition = github_repository_ruleset.this["release-tags"].conditions[0].ref_name[0].include == tolist(["refs/tags/v*.*.*"]) && length(github_repository_ruleset.this["release-tags"].conditions[0].ref_name[0].exclude) == 0
error_message = "Expected release tags vX.Y.Z only."
}

assert {
condition = github_repository_ruleset.this["release-tags"].rules[0].update && github_repository_ruleset.this["release-tags"].rules[0].deletion && github_repository_ruleset.this["release-tags"].rules[0].creation != true
error_message = "Expected update and deletion blocked, creation allowed."
}

assert {
condition = !github_repository_ruleset.this["release-tags"].rules[0].non_fast_forward && !github_repository_ruleset.this["release-tags"].rules[0].required_linear_history && length(github_repository_ruleset.this["release-tags"].rules[0].commit_message_pattern) == 0 && length(github_repository_ruleset.this["release-tags"].rules[0].pull_request) == 0
error_message = "Expected no branch rules."
}

assert {
condition = length(github_repository_ruleset.this["release-tags"].bypass_actors) == 0
error_message = "Expected no bypass actors."
}
}

run "ruleset_target_invalid" {
command = plan

variables {
repository = {
name = "foo"
rulesets = { default-branch = { target = "push" } }
}
}

expect_failures = [var.repository]
}

run "ruleset_bypass_apps_absent" {
command = plan

Expand Down
5 changes: 5 additions & 0 deletions terraform/modules/repository/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ variable "repository" {
reviewers = optional(list(string), [])
})), {})
rulesets = optional(map(object({
target = optional(string, "branch")
bypass_apps = optional(list(number), [])
})), {})
})
Expand Down Expand Up @@ -49,4 +50,8 @@ variable "repository" {
condition = try(alltrue([for r in values(var.repository.rulesets) : alltrue([for id in r.bypass_apps : id > 0 && floor(id) == id]) && length(distinct(r.bypass_apps)) == length(r.bypass_apps)]), false)
error_message = "Repository ${try(coalesce(var.repository.name), "")}: ruleset bypass_apps must be distinct GitHub App IDs (positive integers)."
}
validation {
condition = try(alltrue([for r in values(var.repository.rulesets) : contains(["branch", "tag"], r.target)]), false)
error_message = "Repository ${try(coalesce(var.repository.name), "")}: ruleset target must be branch or tag."
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
organization:
rulesets:
default-branch:
target: tag
enforcement: evaluate
repositories:
- name: foo
4 changes: 2 additions & 2 deletions terraform/tests/test-yaml.tftest.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ run "test_yaml" {
}

assert {
condition = alltrue([for m in module.repository : keys(m.rulesets) == ["default-branch"] && [for a in m.rulesets["default-branch"].bypass_actors : a.actor_id] == [3144447]])
error_message = "Expected the default-branch ruleset with the semantic-release App bypass in every repository."
condition = alltrue([for m in module.repository : keys(m.rulesets) == ["default-branch", "release-tags"] && [for a in m.rulesets["default-branch"].bypass_actors : a.actor_id] == [3144447] && m.rulesets["release-tags"].target == "tag" && length(m.rulesets["release-tags"].bypass_actors) == 0])
error_message = "Expected the default-branch ruleset with the semantic-release App bypass and the release-tags ruleset without bypass in every repository."
}
}
2 changes: 2 additions & 0 deletions test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ organization:
default-branch:
bypass_apps:
- 3144447 # bruzit-github-contents
release-tags:
target: tag
repositories:
- name: .github
description: "BruzIT Test organization profile and the declarative YAML definition of its repositories, reconciled into GitHub by GitHub Organization as Code."
Expand Down
Loading