Skip to content

Commit 18c860e

Browse files
committed
Add validation for push rules
As they differ from `branch` and `tag` rules Signed-off-by: Timo Sand <[email protected]>
1 parent cec204f commit 18c860e

2 files changed

Lines changed: 220 additions & 1 deletion

File tree

github/resource_github_organization_ruleset.go

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/google/go-github/v77/github"
1313
"github.com/hashicorp/terraform-plugin-log/tflog"
1414
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
15+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff"
1516
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
1617
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
1718
)
@@ -28,7 +29,10 @@ func resourceGithubOrganizationRuleset() *schema.Resource {
2829

2930
SchemaVersion: 1,
3031

31-
CustomizeDiff: validateConditionsFieldBasedOnTarget,
32+
CustomizeDiff: customdiff.All(
33+
validateConditionsFieldBasedOnTarget,
34+
validateRulesFieldBasedOnTarget,
35+
),
3236

3337
Schema: map[string]*schema.Schema{
3438
"name": {
@@ -781,3 +785,124 @@ func validateConditionsFieldBasedOnTarget(ctx context.Context, d *schema.Resourc
781785
}
782786
return nil
783787
}
788+
789+
// branchTagOnlyRules contains rules that are only valid for branch and tag targets.
790+
//
791+
// These rules apply to ref-based operations (branches and tags) and are not supported
792+
// for push rulesets which operate on file content.
793+
//
794+
// To verify/maintain this list:
795+
// 1. Check the GitHub API documentation for organization rulesets:
796+
// https://docs.github.com/en/rest/orgs/rules?apiVersion=2022-11-28#create-an-organization-repository-ruleset
797+
// 2. The API docs don't clearly separate push vs branch/tag rules. To verify,
798+
// attempt to create a push ruleset via API or UI with each rule type.
799+
// Push rulesets will reject branch/tag rules with "Invalid rule '<name>'" error.
800+
// 3. Generally, push rules deal with file content (paths, sizes, extensions),
801+
// while branch/tag rules deal with ref lifecycle and merge requirements.
802+
var branchTagOnlyRules = []string{
803+
"creation",
804+
"update",
805+
"deletion",
806+
"required_linear_history",
807+
"required_signatures",
808+
"pull_request",
809+
"required_status_checks",
810+
"non_fast_forward",
811+
"commit_message_pattern",
812+
"commit_author_email_pattern",
813+
"committer_email_pattern",
814+
"branch_name_pattern",
815+
"tag_name_pattern",
816+
"required_workflows",
817+
"required_code_scanning",
818+
}
819+
820+
// pushOnlyRules contains rules that are only valid for push targets.
821+
//
822+
// These rules apply to push operations and control what content can be pushed
823+
// to repositories. They are not supported for branch or tag rulesets.
824+
//
825+
// To verify/maintain this list:
826+
// 1. Check the GitHub API documentation for organization rulesets:
827+
// https://docs.github.com/en/rest/orgs/rules?apiVersion=2022-11-28#create-an-organization-repository-ruleset
828+
// 2. The API docs don't clearly separate push vs branch/tag rules. To verify,
829+
// attempt to create a branch ruleset via API or UI with each rule type.
830+
// Branch rulesets will reject push-only rules with an error.
831+
// 3. Push rules control file content: paths, sizes, extensions, path lengths.
832+
var pushOnlyRules = []string{
833+
"file_path_restriction",
834+
"max_file_path_length",
835+
"file_extension_restriction",
836+
"max_file_size",
837+
}
838+
839+
func validateRulesForPushTarget(ctx context.Context, d *schema.ResourceDiff, _ any) error {
840+
tflog.Debug(ctx, "Validating rules for push target")
841+
rulesRaw := d.Get("rules").([]any)
842+
if len(rulesRaw) == 0 {
843+
tflog.Debug(ctx, "No rules block, skipping validation")
844+
return nil
845+
}
846+
847+
rules := rulesRaw[0].(map[string]any)
848+
849+
for _, ruleName := range branchTagOnlyRules {
850+
ruleValue := rules[ruleName]
851+
if ruleValue == nil {
852+
continue
853+
}
854+
switch v := ruleValue.(type) {
855+
case bool:
856+
if v {
857+
tflog.Debug(ctx, "Invalid rule for push target", map[string]any{"rule": ruleName, "value": v})
858+
return fmt.Errorf("rule %q is not valid for push target; push targets only support: %v", ruleName, pushOnlyRules)
859+
}
860+
case []any:
861+
if len(v) > 0 {
862+
tflog.Debug(ctx, "Invalid rule for push target", map[string]any{"rule": ruleName, "value": v})
863+
return fmt.Errorf("rule %q is not valid for push target; push targets only support: %v", ruleName, pushOnlyRules)
864+
}
865+
}
866+
}
867+
tflog.Debug(ctx, "Rules validation passed for push target")
868+
return nil
869+
}
870+
871+
func validateRulesForBranchAndTagTargets(ctx context.Context, d *schema.ResourceDiff, _ any) error {
872+
target := d.Get("target").(string)
873+
tflog.Debug(ctx, "Validating rules for branch/tag target", map[string]any{"target": target})
874+
rulesRaw := d.Get("rules").([]any)
875+
if len(rulesRaw) == 0 {
876+
tflog.Debug(ctx, "No rules block, skipping validation")
877+
return nil
878+
}
879+
880+
rules := rulesRaw[0].(map[string]any)
881+
882+
for _, ruleName := range pushOnlyRules {
883+
ruleValue := rules[ruleName]
884+
if ruleValue == nil {
885+
continue
886+
}
887+
if ruleList, ok := ruleValue.([]any); ok && len(ruleList) > 0 {
888+
tflog.Debug(ctx, "Invalid rule for branch/tag target", map[string]any{"rule": ruleName, "target": target})
889+
return fmt.Errorf("rule %q is only valid for push target, not for %s target", ruleName, target)
890+
}
891+
}
892+
tflog.Debug(ctx, "Rules validation passed for branch/tag target", map[string]any{"target": target})
893+
return nil
894+
}
895+
896+
func validateRulesFieldBasedOnTarget(ctx context.Context, d *schema.ResourceDiff, meta any) error {
897+
target := d.Get("target").(string)
898+
tflog.Debug(ctx, "Validating rules field based on target", map[string]any{"target": target})
899+
900+
switch target {
901+
case "branch", "tag":
902+
return validateRulesForBranchAndTagTargets(ctx, d, meta)
903+
case "push":
904+
return validateRulesForPushTarget(ctx, d, meta)
905+
}
906+
907+
return nil
908+
}

github/resource_github_organization_ruleset_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,100 @@ func TestGithubOrganizationRulesets(t *testing.T) {
739739
})
740740
})
741741

742+
t.Run("Validates push target rejects branch/tag rules", func(t *testing.T) {
743+
resourceName := "test-push-reject-branch-rules"
744+
config := fmt.Sprintf(`
745+
resource "github_organization_ruleset" "%s" {
746+
name = "test-push-branch-rule-%s"
747+
target = "push"
748+
enforcement = "active"
749+
750+
conditions {
751+
repository_name {
752+
include = ["~ALL"]
753+
exclude = []
754+
}
755+
}
756+
757+
rules {
758+
# 'creation' is a branch/tag rule, not valid for push target
759+
creation = true
760+
}
761+
}
762+
`, resourceName, randomID)
763+
764+
testCase := func(t *testing.T, mode string) {
765+
resource.Test(t, resource.TestCase{
766+
PreCheck: func() { skipUnlessMode(t, mode) },
767+
Providers: testAccProviders,
768+
Steps: []resource.TestStep{
769+
{
770+
Config: config,
771+
ExpectError: regexp.MustCompile("rule .* is not valid for push target"),
772+
},
773+
},
774+
})
775+
}
776+
777+
t.Run("with an enterprise account", func(t *testing.T) {
778+
testCase(t, enterprise)
779+
})
780+
781+
t.Run("with an organization account", func(t *testing.T) {
782+
t.Skip("organization account not supported for this operation, since it needs a paid Team plan.")
783+
})
784+
})
785+
786+
t.Run("Validates branch target rejects push-only rules", func(t *testing.T) {
787+
resourceName := "test-branch-reject-push-rules"
788+
config := fmt.Sprintf(`
789+
resource "github_organization_ruleset" "%s" {
790+
name = "test-branch-push-rule-%s"
791+
target = "branch"
792+
enforcement = "active"
793+
794+
conditions {
795+
ref_name {
796+
include = ["~ALL"]
797+
exclude = []
798+
}
799+
repository_name {
800+
include = ["~ALL"]
801+
exclude = []
802+
}
803+
}
804+
805+
rules {
806+
# 'max_file_size' is a push-only rule, not valid for branch target
807+
max_file_size {
808+
max_file_size = 100
809+
}
810+
}
811+
}
812+
`, resourceName, randomID)
813+
814+
testCase := func(t *testing.T, mode string) {
815+
resource.Test(t, resource.TestCase{
816+
PreCheck: func() { skipUnlessMode(t, mode) },
817+
Providers: testAccProviders,
818+
Steps: []resource.TestStep{
819+
{
820+
Config: config,
821+
ExpectError: regexp.MustCompile("rule .* is only valid for push target"),
822+
},
823+
},
824+
})
825+
}
826+
827+
t.Run("with an enterprise account", func(t *testing.T) {
828+
testCase(t, enterprise)
829+
})
830+
831+
t.Run("with an organization account", func(t *testing.T) {
832+
t.Skip("organization account not supported for this operation, since it needs a paid Team plan.")
833+
})
834+
})
835+
742836
t.Run("Creates push ruleset with repository_name only", func(t *testing.T) {
743837
resourceName := "test-push-repo-name-only"
744838
config := fmt.Sprintf(`

0 commit comments

Comments
 (0)