From ece47bd95e917ffa9d0507b4a412fd49cfe97bc1 Mon Sep 17 00:00:00 2001 From: Alexandru Marcencov Date: Thu, 15 Oct 2026 10:40:00 +0200 Subject: [PATCH] fix: Allow OrganizationRole bypass actor type in rulesets GitHub returns `OrganizationRole` for custom organization roles in ruleset bypass actors, but the schema validation rejected it, so such rulesets could not be managed. Resolves #3674 Co-Authored-By: Claude Opus 5.5 --- docs/resources/organization_ruleset.md | 2 +- docs/resources/repository_ruleset.md | 2 +- .../resource_github_organization_ruleset.go | 4 +- ...source_github_organization_ruleset_test.go | 64 ++++++++++++++++++ github/resource_github_repository_ruleset.go | 4 +- ...resource_github_repository_ruleset_test.go | 66 +++++++++++++++++++ 6 files changed, 136 insertions(+), 6 deletions(-) diff --git a/docs/resources/organization_ruleset.md b/docs/resources/organization_ruleset.md index b97af0fc27..57450ef8b2 100644 --- a/docs/resources/organization_ruleset.md +++ b/docs/resources/organization_ruleset.md @@ -310,7 +310,7 @@ The `rules` block supports the following: - `actor_id` - (Optional) (Number) The ID of the actor that can bypass a ruleset. Must be omitted for ID-less actor types: `OrganizationAdmin`, `EnterpriseOwner`, and `DeployKey` — the GitHub API does not use an ID for these types and will ignore any value set. -- `actor_type` (String) The type of actor that can bypass a ruleset. Can be one of: `RepositoryRole`, `Team`, `Integration`, `OrganizationAdmin`, `DeployKey`, `EnterpriseOwner`. +- `actor_type` (String) The type of actor that can bypass a ruleset. Can be one of: `RepositoryRole`, `Team`, `Integration`, `OrganizationAdmin`, `OrganizationRole`, `DeployKey`, `EnterpriseOwner`. - `bypass_mode` - (Optional) (String) When the specified actor can bypass the ruleset. pull_request means that an actor can only bypass rules on pull requests. Can be one of: `always`, `pull_request`, `exempt`. diff --git a/docs/resources/repository_ruleset.md b/docs/resources/repository_ruleset.md index e5350c664d..abeb195f21 100644 --- a/docs/resources/repository_ruleset.md +++ b/docs/resources/repository_ruleset.md @@ -298,7 +298,7 @@ The `rules` block supports the following: - `actor_id` - (Optional) (Number) The ID of the actor that can bypass a ruleset. If `actor_type` is `Integration`, `actor_id` is a GitHub App ID. App ID can be obtained by following instructions from the [Get an App API docs](https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#get-an-app). If `actor_type` is `User`, `actor_id` is the numeric GitHub user ID. Some actor types such as `OrganizationAdmin`, `EnterpriseOwner`, and `DeployKey` do not have an ID — this argument should not be set in those cases as the GitHub API will ignore it. -- `actor_type` (String) The type of actor that can bypass a ruleset. Can be one of: `RepositoryRole`, `Team`, `Integration`, `OrganizationAdmin`, `DeployKey`, `EnterpriseOwner`, `User`. +- `actor_type` (String) The type of actor that can bypass a ruleset. Can be one of: `RepositoryRole`, `Team`, `Integration`, `OrganizationAdmin`, `OrganizationRole`, `DeployKey`, `EnterpriseOwner`, `User`. - `bypass_mode` - (Optional) (String) When the specified actor can bypass the ruleset. pull_request means that an actor can only bypass rules on pull requests. Can be one of: `always`, `pull_request`, `exempt`. diff --git a/github/resource_github_organization_ruleset.go b/github/resource_github_organization_ruleset.go index 56c6d9c038..8fd58c068b 100644 --- a/github/resource_github_organization_ruleset.go +++ b/github/resource_github_organization_ruleset.go @@ -68,8 +68,8 @@ func resourceGithubOrganizationRuleset() *schema.Resource { "actor_type": { Type: schema.TypeString, Required: true, - ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"Integration", "OrganizationAdmin", "RepositoryRole", "Team", "DeployKey", "EnterpriseOwner"}, false)), - Description: "The type of actor that can bypass a ruleset. Can be one of: `Integration`, `OrganizationAdmin`, `RepositoryRole`, `Team`, `DeployKey`, or `EnterpriseOwner`.", + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"Integration", "OrganizationAdmin", "OrganizationRole", "RepositoryRole", "Team", "DeployKey", "EnterpriseOwner"}, false)), + Description: "The type of actor that can bypass a ruleset. Can be one of: `Integration`, `OrganizationAdmin`, `OrganizationRole`, `RepositoryRole`, `Team`, `DeployKey`, or `EnterpriseOwner`.", }, "bypass_mode": { Type: schema.TypeString, diff --git a/github/resource_github_organization_ruleset_test.go b/github/resource_github_organization_ruleset_test.go index 7d3ceef5af..41f3458369 100644 --- a/github/resource_github_organization_ruleset_test.go +++ b/github/resource_github_organization_ruleset_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-testing/compare" "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/knownvalue" @@ -1503,6 +1504,69 @@ resource "github_organization_ruleset" "test" { }, }) }) + + t.Run("create_branch_ruleset_with_organization_role_bypass", func(t *testing.T) { + t.Parallel() + + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + rulesetName := fmt.Sprintf("%s-branch-ruleset-%s", testResourcePrefix, randomID) + roleName := fmt.Sprintf("%sorg-role-%s", testResourcePrefix, randomID) + + config := fmt.Sprintf(` +resource "github_organization_role" "test" { + name = "%s" + permissions = [] +} + +resource "github_organization_ruleset" "test" { + name = "%s" + target = "branch" + enforcement = "active" + + conditions { + ref_name { + include = ["~ALL"] + exclude = [] + } + + repository_name { + include = ["~ALL"] + exclude = [] + } + } + + bypass_actors { + actor_id = github_organization_role.test.role_id + actor_type = "OrganizationRole" + bypass_mode = "always" + } + + rules { + branch_name_pattern { + name = "test" + negate = false + operator = "starts_with" + pattern = "test" + } + } +} +`, roleName, rulesetName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessEnterprise(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("bypass_actors").AtSliceIndex(0).AtMapKey("actor_type"), knownvalue.StringExact("OrganizationRole")), + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("bypass_actors").AtSliceIndex(0).AtMapKey("bypass_mode"), knownvalue.StringExact("always")), + statecheck.CompareValuePairs("github_organization_ruleset.test", tfjsonpath.New("bypass_actors").AtSliceIndex(0).AtMapKey("actor_id"), "github_organization_role.test", tfjsonpath.New("role_id"), compare.ValuesSame()), + }, + }, + }, + }) + }) } func TestOrganizationPushRulesetSupport(t *testing.T) { diff --git a/github/resource_github_repository_ruleset.go b/github/resource_github_repository_ruleset.go index b7499de276..023af78467 100644 --- a/github/resource_github_repository_ruleset.go +++ b/github/resource_github_repository_ruleset.go @@ -73,8 +73,8 @@ func resourceGithubRepositoryRuleset() *schema.Resource { "actor_type": { Type: schema.TypeString, Required: true, - ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"RepositoryRole", "Team", "Integration", "OrganizationAdmin", "DeployKey", "EnterpriseOwner", "User"}, false)), - Description: "The type of actor that can bypass a ruleset. Can be one of: `RepositoryRole`, `Team`, `Integration`, `OrganizationAdmin`, `DeployKey`, `EnterpriseOwner`, or `User`. See https://docs.github.com/en/rest/repos/rules for more information.", + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"RepositoryRole", "Team", "Integration", "OrganizationAdmin", "OrganizationRole", "DeployKey", "EnterpriseOwner", "User"}, false)), + Description: "The type of actor that can bypass a ruleset. Can be one of: `RepositoryRole`, `Team`, `Integration`, `OrganizationAdmin`, `OrganizationRole`, `DeployKey`, `EnterpriseOwner`, or `User`. See https://docs.github.com/en/rest/repos/rules for more information.", }, "bypass_mode": { Type: schema.TypeString, diff --git a/github/resource_github_repository_ruleset_test.go b/github/resource_github_repository_ruleset_test.go index 5910ba6207..84fc4e5715 100644 --- a/github/resource_github_repository_ruleset_test.go +++ b/github/resource_github_repository_ruleset_test.go @@ -9,6 +9,7 @@ import ( "github.com/google/go-github/v92/github" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/hashicorp/terraform-plugin-log/tflogtest" + "github.com/hashicorp/terraform-plugin-testing/compare" "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/knownvalue" @@ -648,6 +649,71 @@ resource "github_repository_ruleset" "test" { }) }) + t.Run("create_branch_ruleset_with_organization_role_bypass", func(t *testing.T) { + t.Parallel() + + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%srepo-ruleset-%s", testResourcePrefix, randomID) + roleName := fmt.Sprintf("%sorg-role-%s", testResourcePrefix, randomID) + + config := fmt.Sprintf(` +resource "github_repository" "test" { + name = "%s" + auto_init = false + visibility = "%s" +} + +resource "github_organization_role" "test" { + name = "%s" + permissions = [] +} + +resource "github_repository_ruleset" "test" { + name = "test" + repository = github_repository.test.id + target = "branch" + enforcement = "active" + + conditions { + ref_name { + include = ["~ALL"] + exclude = [] + } + } + + bypass_actors { + actor_id = github_organization_role.test.role_id + actor_type = "OrganizationRole" + bypass_mode = "always" + } + + rules { + branch_name_pattern { + name = "test" + negate = false + operator = "starts_with" + pattern = "test" + } + } +} +`, repoName, testAccConf.testRepositoryVisibility, roleName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessEnterprise(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_repository_ruleset.test", tfjsonpath.New("bypass_actors").AtSliceIndex(0).AtMapKey("actor_type"), knownvalue.StringExact("OrganizationRole")), + statecheck.ExpectKnownValue("github_repository_ruleset.test", tfjsonpath.New("bypass_actors").AtSliceIndex(0).AtMapKey("bypass_mode"), knownvalue.StringExact("always")), + statecheck.CompareValuePairs("github_repository_ruleset.test", tfjsonpath.New("bypass_actors").AtSliceIndex(0).AtMapKey("actor_id"), "github_organization_role.test", tfjsonpath.New("role_id"), compare.ValuesSame()), + }, + }, + }, + }) + }) + t.Run("skips update and delete on archived repository", func(t *testing.T) { t.Parallel()