From fcbc862a91a04d5c85fce9839d994e824902e39a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bru=C5=BEina?= Date: Thu, 8 Oct 2026 17:25:27 +0200 Subject: [PATCH] feat: protect release tags --- README.md | 11 +++- terraform/main.tf | 2 +- terraform/modules/repository/main.tf | 31 +++++++---- .../repository/tests/repository.tftest.hcl | 54 +++++++++++++++++++ terraform/modules/repository/variables.tf | 5 ++ .../unknown-organization-ruleset-key.yaml | 2 +- terraform/tests/test-yaml.tftest.hcl | 4 +- test.yaml | 2 + 8 files changed, 94 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index d838ae2..64ec4f4 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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. diff --git a/terraform/main.tf b/terraform/main.tf index 8edb318..805ef27 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -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([])) diff --git a/terraform/modules/repository/main.tf b/terraform/modules/repository/main.tf index 9028b38..1450ccd 100644 --- a/terraform/modules/repository/main.tf +++ b/terraform/modules/repository/main.tf @@ -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 = [] } } @@ -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 + } } } } diff --git a/terraform/modules/repository/tests/repository.tftest.hcl b/terraform/modules/repository/tests/repository.tftest.hcl index e218e60..68b96f3 100644 --- a/terraform/modules/repository/tests/repository.tftest.hcl +++ b/terraform/modules/repository/tests/repository.tftest.hcl @@ -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." @@ -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 diff --git a/terraform/modules/repository/variables.tf b/terraform/modules/repository/variables.tf index 0ff84c3..51de712 100644 --- a/terraform/modules/repository/variables.tf +++ b/terraform/modules/repository/variables.tf @@ -17,6 +17,7 @@ variable "repository" { reviewers = optional(list(string), []) })), {}) rulesets = optional(map(object({ + target = optional(string, "branch") bypass_apps = optional(list(number), []) })), {}) }) @@ -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." + } } diff --git a/terraform/tests/fixtures/unknown-organization-ruleset-key.yaml b/terraform/tests/fixtures/unknown-organization-ruleset-key.yaml index c679102..90e7057 100644 --- a/terraform/tests/fixtures/unknown-organization-ruleset-key.yaml +++ b/terraform/tests/fixtures/unknown-organization-ruleset-key.yaml @@ -2,6 +2,6 @@ organization: rulesets: default-branch: - target: tag + enforcement: evaluate repositories: - name: foo diff --git a/terraform/tests/test-yaml.tftest.hcl b/terraform/tests/test-yaml.tftest.hcl index c696680..f7fa785 100644 --- a/terraform/tests/test-yaml.tftest.hcl +++ b/terraform/tests/test-yaml.tftest.hcl @@ -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." } } diff --git a/test.yaml b/test.yaml index 7996c3e..8a1bb9c 100644 --- a/test.yaml +++ b/test.yaml @@ -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."